Merge branch agent_teams_features into main
This commit is contained in:
commit
41717c5c7e
17 changed files with 609 additions and 281 deletions
376
package.json
376
package.json
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
88
src/renderer/components/team/ProvisioningProgressBlock.tsx
Normal file
88
src/renderer/components/team/ProvisioningProgressBlock.tsx
Normal 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)]">→</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)]">→</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
121
src/renderer/components/team/activity/ActiveTasksBlock.tsx
Normal file
121
src/renderer/components/team/activity/ActiveTasksBlock.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue