Merge branch agent_teams_features into main

This commit is contained in:
iliya 2026-02-23 14:57:10 +02:00
commit 41717c5c7e
17 changed files with 609 additions and 281 deletions

View file

@ -1,197 +1,195 @@
{
"name": "claude-agent-teams-ui",
"type": "module",
"version": "0.1.0",
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
"license": "AGPL-3.0",
"author": {
"name": "Илия (777genius)",
"email": "quantjumppro@gmail.com"
},
"homepage": "https://github.com/777genius/claude_agent_teams_ui",
"repository": {
"type": "git",
"url": "https://github.com/777genius/claude_agent_teams_ui.git"
},
"bugs": {
"url": "https://github.com/777genius/claude_agent_teams_ui/issues"
},
"main": "dist-electron/main/index.cjs",
"scripts": {
"dev": "trap 'kill 0' INT; electron-vite dev",
"dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'",
"build": "electron-vite build",
"dist": "electron-builder --mac --win --linux",
"dist:mac": "electron-builder --mac --publish always",
"dist:mac:arm64": "electron-builder --mac --arm64 --publish always",
"dist:mac:x64": "electron-builder --mac --x64 --publish always",
"dist:win": "electron-builder --win --publish always",
"dist:linux": "electron-builder --linux --publish always",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
"fix": "pnpm lint:fix && pnpm format",
"quality": "pnpm check && pnpm format:check && npx knip",
"test:chunks": "tsx test/test-chunk-building.ts",
"test:semantic": "tsx test/test-semantic-steps.ts",
"test:noise": "tsx test/test-noise-filtering.ts",
"test:task-filtering": "tsx test/test-task-filtering.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts",
"standalone": "tsx src/main/standalone.ts",
"standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts",
"standalone:start": "node dist-standalone/index.cjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-virtual": "^3.10.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "^3.6.0",
"electron-updater": "^6.7.3",
"fastify": "^5.7.4",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.562.0",
"mdast-util-to-hast": "^13.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"ssh-config": "^5.0.4",
"ssh2": "^1.17.0",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"zustand": "^4.5.0"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@eslint/js": "^9.39.2",
"@tailwindcss/typography": "^0.5.19",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "^25.0.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/ssh2": "^1.15.5",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^3.1.4",
"autoprefixer": "^10.4.17",
"electron": "^40.3.0",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^5.3.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sonarjs": "^3.0.6",
"eslint-plugin-tailwindcss": "^3.18.2",
"globals": "^17.2.0",
"happy-dom": "^20.0.2",
"knip": "^5.82.1",
"postcss": "^8.4.35",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.4.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^5.4.2",
"vitest": "^3.1.4"
},
"build": {
"appId": "com.claudecode.context",
"productName": "Claude Agent Teams UI",
"directories": {
"output": "release"
"name": "claude-agent-teams-ui",
"type": "module",
"version": "0.1.0",
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
"license": "AGPL-3.0",
"author": {
"name": "Илия (777genius)",
"email": "quantjumppro@gmail.com"
},
"files": [
"out/renderer/**",
"dist-electron/**",
"package.json"
],
"asar": true,
"asarUnpack": [
"out/renderer/**"
],
"npmRebuild": false,
"extraMetadata": {
"main": "dist-electron/main/index.cjs"
"homepage": "https://github.com/777genius/claude_agent_teams_ui",
"repository": {
"type": "git",
"url": "https://github.com/777genius/claude_agent_teams_ui.git"
},
"mac": {
"category": "public.app-category.developer-tools",
"target": [
"dmg",
"zip"
],
"hardenedRuntime": true,
"gatekeeperAssess": false,
"notarize": true,
"entitlements": "resources/entitlements.mac.plist",
"entitlementsInherit": "resources/entitlements.mac.inherit.plist",
"icon": "resources/icons/mac/icon.icns"
"bugs": {
"url": "https://github.com/777genius/claude_agent_teams_ui/issues"
},
"dmg": {
"sign": false
"main": "dist-electron/main/index.cjs",
"scripts": {
"dev": "electron-vite dev",
"dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'",
"build": "electron-vite build",
"dist": "electron-builder --mac --win --linux",
"dist:mac": "electron-builder --mac --publish always",
"dist:mac:arm64": "electron-builder --mac --arm64 --publish always",
"dist:mac:x64": "electron-builder --mac --x64 --publish always",
"dist:win": "electron-builder --win --publish always",
"dist:linux": "electron-builder --linux --publish always",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
"fix": "pnpm lint:fix && pnpm format",
"quality": "pnpm check && pnpm format:check && npx knip",
"test:chunks": "tsx test/test-chunk-building.ts",
"test:semantic": "tsx test/test-semantic-steps.ts",
"test:noise": "tsx test/test-noise-filtering.ts",
"test:task-filtering": "tsx test/test-task-filtering.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts",
"standalone": "tsx src/main/standalone.ts",
"standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts",
"standalone:start": "node dist-standalone/index.cjs"
},
"win": {
"target": [
"nsis"
],
"icon": "resources/icons/win/icon.ico"
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-virtual": "^3.10.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "^3.6.0",
"electron-updater": "^6.7.3",
"fastify": "^5.7.4",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.562.0",
"mdast-util-to-hast": "^13.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"ssh-config": "^5.0.4",
"ssh2": "^1.17.0",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"zustand": "^4.5.0"
},
"linux": {
"target": [
"AppImage",
"deb",
"rpm",
"pacman"
],
"icon": "resources/icons/png",
"category": "Development"
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@eslint/js": "^9.39.2",
"@tailwindcss/typography": "^0.5.19",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "^25.0.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/ssh2": "^1.15.5",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^3.1.4",
"autoprefixer": "^10.4.17",
"electron": "^40.3.0",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^5.3.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sonarjs": "^3.0.6",
"eslint-plugin-tailwindcss": "^3.18.2",
"globals": "^17.2.0",
"happy-dom": "^20.0.2",
"knip": "^5.82.1",
"postcss": "^8.4.35",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.4.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^5.4.2",
"vitest": "^3.1.4"
},
"deb": {
"afterInstall": "resources/afterInstall.sh"
"build": {
"appId": "com.claudecode.context",
"productName": "Claude Agent Teams UI",
"directories": {
"output": "release"
},
"files": [
"out/renderer/**",
"dist-electron/**",
"package.json"
],
"asar": true,
"asarUnpack": [
"out/renderer/**"
],
"npmRebuild": false,
"extraMetadata": {
"main": "dist-electron/main/index.cjs"
},
"mac": {
"category": "public.app-category.developer-tools",
"target": [
"dmg",
"zip"
],
"hardenedRuntime": true,
"gatekeeperAssess": false,
"notarize": true,
"entitlements": "resources/entitlements.mac.plist",
"entitlementsInherit": "resources/entitlements.mac.inherit.plist",
"icon": "resources/icons/mac/icon.icns"
},
"dmg": {
"sign": false
},
"win": {
"target": [
"nsis"
],
"icon": "resources/icons/win/icon.ico"
},
"linux": {
"target": [
"AppImage",
"deb",
"rpm",
"pacman"
],
"icon": "resources/icons/png",
"category": "Development"
},
"deb": {
"afterInstall": "resources/afterInstall.sh"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true
},
"publish": [{
"provider": "github",
"releaseType": "draft"
}]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true
},
"publish": [
{
"provider": "github",
"releaseType": "draft"
}
]
},
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
}
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
}

View file

@ -769,7 +769,8 @@ async function handleGetMemberLogs(
async function handleGetLogsForTask(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
taskId: unknown,
options?: { owner?: string; status?: string }
): Promise<IpcResult<MemberLogSummary[]>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
@ -779,8 +780,15 @@ async function handleGetLogsForTask(
if (!vTask.valid) {
return { success: false, error: vTask.error ?? 'Invalid taskId' };
}
const opts =
options && typeof options === 'object'
? {
owner: typeof options.owner === 'string' ? options.owner : undefined,
status: typeof options.status === 'string' ? options.status : undefined,
}
: undefined;
return wrapTeamHandler('getLogsForTask', () =>
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!)
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts)
);
}

View file

@ -104,8 +104,14 @@ export class TeamMemberLogsFinder {
/**
* Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.).
* When the task is in_progress and has an owner, also includes that owner's session logs so
* the executor's current activity is visible even before the JSONL mentions the task id.
*/
async findLogsForTask(teamName: string, taskId: string): Promise<MemberLogSummary[]> {
async findLogsForTask(
teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
): Promise<MemberLogSummary[]> {
const discovery = await this.discoverProjectSessions(teamName);
if (!discovery) return [];
@ -159,6 +165,32 @@ export class TeamMemberLogsFinder {
}
}
const includeOwnerSessions =
options?.status === 'in_progress' &&
typeof options?.owner === 'string' &&
options.owner.trim().length > 0;
if (includeOwnerSessions) {
const ownerLogs = await this.findMemberLogs(teamName, options.owner!.trim());
const seen = new Set<string>();
for (const log of results) {
const key =
log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`;
seen.add(key);
}
for (const log of ownerLogs) {
const key =
log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`;
if (!seen.has(key)) {
seen.add(key);
results.push(log);
}
}
}
return results.sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
);
@ -307,11 +339,18 @@ export class TeamMemberLogsFinder {
private async fileMentionsTaskId(filePath: string, taskId: string): Promise<boolean> {
const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const patterns = [
const numericTaskId = /^\d+$/.test(taskId) ? taskId : null;
const patterns: RegExp[] = [
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
new RegExp(`#${escaped}\\b`),
];
if (numericTaskId) {
patterns.push(
new RegExp(`"task_id"\\s*:\\s*${numericTaskId}\\b`),
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`)
);
}
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

View file

@ -573,8 +573,17 @@ const electronAPI: ElectronAPI = {
getMemberLogs: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_MEMBER_LOGS, teamName, memberName);
},
getLogsForTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_LOGS_FOR_TASK, teamName, taskId);
getLogsForTask: async (
teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
) => {
return invokeIpcWithResult<MemberLogSummary[]>(
TEAM_GET_LOGS_FOR_TASK,
teamName,
taskId,
options
);
},
getMemberStats: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<MemberFullStats>(TEAM_GET_MEMBER_STATS, teamName, memberName);

View file

@ -0,0 +1,88 @@
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { Loader2 } from 'lucide-react';
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
import type { ProvisioningStep } from './provisioningSteps';
export interface ProvisioningProgressBlockProps {
/** Title above the steps, e.g. "Launching team" */
title: string;
/** Optional status message */
message?: string | null;
/** Index of the current step in STEP_ORDER (0-based), or -1 if unknown */
currentStepIndex: number;
/** Show spinner next to title */
loading?: boolean;
/** Cancel button label and handler */
onCancel?: (() => void) | null;
className?: string;
}
export const ProvisioningProgressBlock = ({
title,
message,
currentStepIndex,
loading = false,
onCancel,
className,
}: ProvisioningProgressBlockProps): React.JSX.Element => {
return (
<div
className={cn(
'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
className
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{loading ? (
<Loader2 className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]" />
) : null}
<p className="text-xs font-medium text-[var(--color-text)]">{title}</p>
</div>
{onCancel ? (
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 px-2 text-xs"
onClick={onCancel}
>
Cancel
</Button>
) : null}
</div>
{message ? <p className="mt-1.5 text-xs text-[var(--color-text-muted)]">{message}</p> : null}
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5">
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => {
const isDone = currentStepIndex >= 0 && index < currentStepIndex;
const isCurrent = currentStepIndex >= 0 && index === currentStepIndex;
return (
<div key={step} className="flex items-center gap-1">
<Badge
variant="secondary"
className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
isDone && 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200',
isCurrent &&
'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]'
)}
>
<span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]">
{index + 1}
</span>
{STEP_LABELS[step]}
</Badge>
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null}
</div>
);
})}
</div>
</div>
);
};

View file

@ -9,6 +9,7 @@ import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
import { ActivityTimeline } from './activity/ActivityTimeline';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog';
@ -661,6 +662,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div>
}
>
<ActiveTasksBlock
members={data.members}
tasks={data.tasks}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
/>
<ActivityTimeline
messages={filteredMessages}
members={data.members}
@ -678,6 +685,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<ReviewDialog
open={requestChangesTaskId !== null}
teamName={teamName}
taskId={requestChangesTaskId}
onCancel={() => setRequestChangesTaskId(null)}
onSubmit={(comment) => {

View file

@ -1,13 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { CheckCircle2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
import { STEP_ORDER } from './provisioningSteps';
import type { ProvisioningStep } from './provisioningSteps';
import type { TeamProvisioningProgress } from '@shared/types';
@ -137,51 +136,20 @@ export const TeamProvisioningBanner = ({
if (isActive) {
return (
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-[var(--color-text-muted)]">{progress.message}</p>
{canCancel ? (
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 px-2 text-xs"
onClick={() => {
void cancelProvisioning(progress.runId);
}}
>
Cancel
</Button>
) : null}
</div>
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5">
{STEP_ORDER.map((step, index) => {
const isDone = progressStepIndex >= 0 && index < progressStepIndex;
const isCurrent = progressStepIndex >= 0 && index === progressStepIndex;
return (
<div key={step} className="flex items-center gap-1">
<Badge
variant="secondary"
className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
isDone && 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200',
isCurrent &&
'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]'
)}
>
<span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]">
{index + 1}
</span>
{STEP_LABELS[step]}
</Badge>
{index < STEP_ORDER.length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null}
</div>
);
})}
</div>
<div className="mb-3">
<ProvisioningProgressBlock
title="Launching team"
message={progress.message}
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
loading
onCancel={
canCancel
? () => {
void cancelProvisioning(progress.runId);
}
: null
}
/>
</div>
);
}

View file

@ -0,0 +1,121 @@
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { Loader2 } from 'lucide-react';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
interface ActiveTasksBlockProps {
members: ResolvedTeamMember[];
tasks: TeamTask[];
onMemberClick?: (member: ResolvedTeamMember) => void;
onTaskClick?: (task: TeamTask) => void;
}
export const ActiveTasksBlock = ({
members,
tasks,
onMemberClick,
onTaskClick,
}: ActiveTasksBlockProps): React.JSX.Element | null => {
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const working = members.filter((m) => m.currentTaskId != null);
if (working.length === 0) return null;
return (
<div className="mb-3 space-y-1.5">
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Сейчас в работе
</p>
{working.map((member) => {
const taskId = member.currentTaskId!;
const task = taskMap.get(taskId);
const colors = getTeamColorSet(member.color ?? '');
const roleLabel = formatAgentRole(
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
);
return (
<article
key={`${member.name}-${taskId}`}
className="overflow-hidden rounded-md"
style={{
backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
}}
>
<div className="flex items-center gap-2 px-3 py-2">
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-70" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
<Loader2
className="size-3.5 shrink-0 animate-spin"
style={{ color: colors.border }}
/>
{onMemberClick ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
onClick={() => onMemberClick(member)}
>
{member.name}
</button>
) : (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{member.name}
</span>
)}
{roleLabel ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{roleLabel}
</span>
) : null}
<span
className="min-w-0 flex-1 truncate text-[10px]"
style={{ color: CARD_ICON_MUTED }}
>
выполняет
</span>
{task &&
(onTaskClick ? (
<button
type="button"
className="truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ border: `1px solid ${colors.border}40` }}
onClick={() => onTaskClick(task)}
title={task.subject}
>
#{task.id} {task.subject.slice(0, 40)}
{task.subject.length > 40 ? '…' : ''}
</button>
) : (
<span
className="truncate px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)]"
style={{ border: `1px solid ${colors.border}40` }}
title={task.subject}
>
#{task.id} {task.subject.slice(0, 40)}
{task.subject.length > 40 ? '…' : ''}
</span>
))}
</div>
</article>
);
})}
</div>
);
};

View file

@ -4,9 +4,14 @@ import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'
interface ReplyQuoteBlockProps {
reply: ParsedMessageReply;
/** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */
bodyMaxHeight?: string;
}
export const ReplyQuoteBlock = ({ reply }: ReplyQuoteBlockProps): React.JSX.Element => (
export const ReplyQuoteBlock = ({
reply,
bodyMaxHeight = 'max-h-56',
}: ReplyQuoteBlockProps): React.JSX.Element => (
<div className="space-y-2">
<div
className="rounded-md border-l-2 border-[var(--color-border-emphasis)] bg-[var(--color-surface)] px-3 py-2"
@ -17,6 +22,6 @@ export const ReplyQuoteBlock = ({ reply }: ReplyQuoteBlockProps): React.JSX.Elem
</span>
<p className="line-clamp-3 text-xs text-[var(--color-text-muted)]">{reply.originalText}</p>
</div>
<MarkdownViewer content={reply.replyText} maxHeight="max-h-56" copyable />
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable />
</div>
);

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
@ -13,10 +13,12 @@ import {
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { Textarea } from '@renderer/components/ui/textarea';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { Project, TeamLaunchRequest, TeamProvisioningPrepareResult } from '@shared/types';
interface LaunchTeamDialogProps {
@ -72,7 +74,7 @@ export const LaunchTeamDialog = ({
const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project');
const [selectedProjectPath, setSelectedProjectPath] = useState('');
const [customCwd, setCustomCwd] = useState('');
const [prompt, setPrompt] = useState('');
const promptDraft = useDraftPersistence({ key: `launchTeam:${teamName}:prompt` });
const [projects, setProjects] = useState<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
@ -88,7 +90,6 @@ export const LaunchTeamDialog = ({
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
setPrompt('');
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
@ -195,6 +196,16 @@ export const LaunchTeamDialog = ({
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
projects.map((p) => ({
id: p.path,
name: p.name,
subtitle: p.path,
})),
[projects]
);
const activeError = localError ?? provisioningError;
const handleSubmit = (): void => {
@ -210,7 +221,7 @@ export const LaunchTeamDialog = ({
await onLaunch({
teamName,
cwd: effectiveCwd,
prompt: prompt.trim() || undefined,
prompt: promptDraft.value.trim() || undefined,
});
resetFormState();
onClose();
@ -362,12 +373,20 @@ export const LaunchTeamDialog = ({
<Label htmlFor="launch-prompt" className="text-xs text-[var(--color-text-muted)]">
Prompt (optional)
</Label>
<Textarea
<MentionableTextarea
id="launch-prompt"
className="min-h-[100px] resize-y text-xs"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Instructions for team lead..."
className="min-h-[100px] text-xs"
minRows={4}
maxRows={12}
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
placeholder="Instructions for team lead... Use @ to mention projects."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
</div>
</div>

View file

@ -1,5 +1,3 @@
import { useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -11,9 +9,11 @@ import {
} from '@renderer/components/ui/dialog';
import { Label } from '@renderer/components/ui/label';
import { Textarea } from '@renderer/components/ui/textarea';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
interface ReviewDialogProps {
open: boolean;
teamName: string;
taskId: string | null;
onCancel: () => void;
onSubmit: (comment?: string) => void;
@ -21,20 +21,23 @@ interface ReviewDialogProps {
export const ReviewDialog = ({
open,
teamName,
taskId,
onCancel,
onSubmit,
}: ReviewDialogProps): React.JSX.Element => {
const [comment, setComment] = useState('');
const draft = useDraftPersistence({
key: `requestChanges:${teamName}:${taskId ?? ''}`,
enabled: Boolean(teamName && taskId),
});
const handleCancel = (): void => {
setComment('');
onCancel();
};
const handleSubmit = (): void => {
const trimmed = comment.trim() || undefined;
setComment('');
const trimmed = draft.value.trim() || undefined;
draft.clearDraft();
onSubmit(trimmed);
};
@ -58,10 +61,13 @@ export const ReviewDialog = ({
<Textarea
id="review-comment"
className="min-h-[110px] text-xs"
value={comment}
value={draft.value}
placeholder="Describe what needs to change..."
onChange={(event) => setComment(event.target.value)}
onChange={(event) => draft.setValue(event.target.value)}
/>
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<DialogFooter>

View file

@ -11,7 +11,7 @@ import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessage
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { formatDistanceToNow } from 'date-fns';
import { MessageSquare, Reply, Send, X } from 'lucide-react';
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
@ -36,6 +36,16 @@ export const TaskCommentsSection = ({
const commentsRef = useMarkCommentsRead(teamName, taskId, comments);
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
const toggleCommentExpanded = useCallback((commentId: string) => {
setExpandedCommentIds((prev) => {
const next = new Set(prev);
if (next.has(commentId)) next.delete(commentId);
else next.add(commentId);
return next;
});
}, []);
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
@ -106,6 +116,18 @@ export const TaskCommentsSection = ({
: formatDistanceToNow(date, { addSuffix: true });
})()}
</span>
<button
type="button"
className="flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() => toggleCommentExpanded(comment.id)}
title={expandedCommentIds.has(comment.id) ? 'Свернуть' : 'Развернуть'}
>
{expandedCommentIds.has(comment.id) ? (
<ChevronUp size={12} />
) : (
<ChevronDown size={12} />
)}
</button>
<button
type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
@ -123,10 +145,17 @@ export const TaskCommentsSection = ({
<div className="text-xs">
{(() => {
const reply = parseMessageReply(comment.text);
const expanded = expandedCommentIds.has(comment.id);
return reply ? (
<ReplyQuoteBlock reply={reply} />
<ReplyQuoteBlock
reply={reply}
bodyMaxHeight={expanded ? undefined : 'max-h-56'}
/>
) : (
<MarkdownViewer content={comment.text} maxHeight="max-h-[120px]" />
<MarkdownViewer
content={comment.text}
maxHeight={expanded ? undefined : 'max-h-[120px]'}
/>
);
})()}
</div>

View file

@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { Badge } from '@renderer/components/ui/badge';
@ -10,6 +12,7 @@ import {
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { markAsRead } from '@renderer/services/commentReadStorage';
import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers';
import { formatDistanceToNow } from 'date-fns';
import {
@ -48,6 +51,19 @@ export const TaskDetailDialog = ({
}: TaskDetailDialogProps): React.JSX.Element => {
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
useEffect(() => {
if (!open || !currentTask) return;
const comments = currentTask.comments ?? [];
if (comments.length === 0) return;
const latest = Math.max(...comments.map((c) => new Date(c.createdAt).getTime()));
if (latest > 0) markAsRead(teamName, currentTask.id, latest);
}, [open, teamName, currentTask?.id, currentTask?.comments]);
const handleDependencyClick = (taskId: string): void => {
onClose();
onScrollToTask?.(taskId);
};
if (!currentTask) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
@ -66,11 +82,6 @@ export const TaskDetailDialog = ({
const blockedByIds = currentTask.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = currentTask.blocks?.filter((id) => id.length > 0) ?? [];
const handleDependencyClick = (taskId: string): void => {
onClose();
onScrollToTask?.(taskId);
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-h-[85vh] min-w-0 overflow-y-auto overflow-x-hidden sm:max-w-4xl">
@ -222,7 +233,12 @@ export const TaskDetailDialog = ({
<h4 className="mb-2 text-xs font-medium text-[var(--color-text-muted)]">
Execution Logs
</h4>
<MemberLogsTab teamName={teamName} taskId={currentTask.id} />
<MemberLogsTab
teamName={teamName}
taskId={currentTask.id}
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
/>
</div>
<DialogFooter>

View file

@ -20,12 +20,17 @@ interface MemberLogsTabProps {
teamName: string;
memberName?: string;
taskId?: string;
/** When viewing task logs: include owner's sessions when task is in_progress */
taskOwner?: string;
taskStatus?: string;
}
export const MemberLogsTab = ({
teamName,
memberName,
taskId,
taskOwner,
taskStatus,
}: MemberLogsTabProps): React.JSX.Element => {
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
const [loading, setLoading] = useState(true);
@ -47,7 +52,10 @@ export const MemberLogsTab = ({
}
const result =
taskId != null
? await api.teams.getLogsForTask(teamName, taskId)
? await api.teams.getLogsForTask(teamName, taskId, {
owner: taskOwner,
status: taskStatus,
})
: await api.teams.getMemberLogs(teamName, memberName!);
if (!cancelled) {
setLogs(result);
@ -66,7 +74,7 @@ export const MemberLogsTab = ({
return () => {
cancelled = true;
};
}, [teamName, memberName, taskId]);
}, [teamName, memberName, taskId, taskOwner, taskStatus]);
const handleExpand = useCallback(
async (log: MemberLogSummary) => {

View file

@ -23,10 +23,10 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
const Badge = ({ className, variant, ...props }: BadgeProps): React.JSX.Element => {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
};
// eslint-disable-next-line react-refresh/only-export-components -- Standard shadcn export pattern

View file

@ -39,11 +39,13 @@ const DialogContent = React.forwardRef<
)}
{...props}
>
<div className="sticky top-0 z-10 -mr-6 -mt-6 h-0 w-full overflow-visible">
<DialogPrimitive.Close className="absolute right-0 top-0 rounded-sm p-1.5 opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-[var(--color-border-emphasis)] disabled:pointer-events-none">
<X className="size-4 text-[var(--color-text-muted)]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-[var(--color-border-emphasis)] disabled:pointer-events-none">
<X className="size-4 text-[var(--color-text-muted)]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));

View file

@ -361,7 +361,11 @@ export interface TeamsAPI {
aliveList: () => Promise<string[]>;
createConfig: (request: TeamCreateConfigRequest) => Promise<void>;
getMemberLogs: (teamName: string, memberName: string) => Promise<MemberLogSummary[]>;
getLogsForTask: (teamName: string, taskId: string) => Promise<MemberLogSummary[]>;
getLogsForTask: (
teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
) => Promise<MemberLogSummary[]>;
getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>;
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
getAllTasks: () => Promise<GlobalTask[]>;