feat: update pricing configuration and enhance file content handling
- Removed outdated pricing entries for Claude models from pricing.json to streamline configuration. - Enhanced the getFileContent method in FileContentResolver to accept snippets for improved content diffing. - Updated IPC methods to support new parameters for file content retrieval, improving data handling in the review process. - Introduced accurate line addition and removal tracking in file diffs, enhancing the review experience. - Improved UI components to reflect changes in file content handling, ensuring better user interaction during reviews.
This commit is contained in:
parent
50efe267e4
commit
321673ff6d
22 changed files with 1000 additions and 575 deletions
|
|
@ -1633,37 +1633,6 @@
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346
|
"tool_use_system_prompt_tokens": 346
|
||||||
},
|
},
|
||||||
"us/claude-sonnet-4-6": {
|
|
||||||
"cache_creation_input_token_cost": 0.000004125,
|
|
||||||
"cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
|
|
||||||
"cache_read_input_token_cost": 3.3e-7,
|
|
||||||
"cache_read_input_token_cost_above_200k_tokens": 6.6e-7,
|
|
||||||
"input_cost_per_token": 0.0000033,
|
|
||||||
"input_cost_per_token_above_200k_tokens": 0.0000066,
|
|
||||||
"litellm_provider": "anthropic",
|
|
||||||
"max_input_tokens": 200000,
|
|
||||||
"max_output_tokens": 64000,
|
|
||||||
"max_tokens": 64000,
|
|
||||||
"mode": "chat",
|
|
||||||
"output_cost_per_token": 0.0000165,
|
|
||||||
"output_cost_per_token_above_200k_tokens": 0.00002475,
|
|
||||||
"search_context_cost_per_query": {
|
|
||||||
"search_context_size_high": 0.01,
|
|
||||||
"search_context_size_low": 0.01,
|
|
||||||
"search_context_size_medium": 0.01
|
|
||||||
},
|
|
||||||
"supports_assistant_prefill": true,
|
|
||||||
"supports_computer_use": true,
|
|
||||||
"supports_function_calling": true,
|
|
||||||
"supports_pdf_input": true,
|
|
||||||
"supports_prompt_caching": true,
|
|
||||||
"supports_reasoning": true,
|
|
||||||
"supports_response_schema": true,
|
|
||||||
"supports_tool_choice": true,
|
|
||||||
"supports_vision": true,
|
|
||||||
"tool_use_system_prompt_tokens": 346,
|
|
||||||
"inference_geo": "us"
|
|
||||||
},
|
|
||||||
"claude-sonnet-4-5-20250929-v1:0": {
|
"claude-sonnet-4-5-20250929-v1:0": {
|
||||||
"cache_creation_input_token_cost": 0.00000375,
|
"cache_creation_input_token_cost": 0.00000375,
|
||||||
"cache_read_input_token_cost": 3e-7,
|
"cache_read_input_token_cost": 3e-7,
|
||||||
|
|
@ -1855,100 +1824,11 @@
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346
|
"tool_use_system_prompt_tokens": 346,
|
||||||
},
|
"provider_specific_entry": {
|
||||||
"fast/claude-opus-4-6": {
|
"us": 1.1,
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"fast": 6
|
||||||
"cache_creation_input_token_cost_above_200k_tokens": 0.0000125,
|
}
|
||||||
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
|
||||||
"cache_read_input_token_cost": 5e-7,
|
|
||||||
"cache_read_input_token_cost_above_200k_tokens": 0.000001,
|
|
||||||
"input_cost_per_token": 0.00003,
|
|
||||||
"input_cost_per_token_above_200k_tokens": 0.00001,
|
|
||||||
"litellm_provider": "anthropic",
|
|
||||||
"max_input_tokens": 1000000,
|
|
||||||
"max_output_tokens": 128000,
|
|
||||||
"max_tokens": 128000,
|
|
||||||
"mode": "chat",
|
|
||||||
"output_cost_per_token": 0.00015,
|
|
||||||
"output_cost_per_token_above_200k_tokens": 0.0000375,
|
|
||||||
"search_context_cost_per_query": {
|
|
||||||
"search_context_size_high": 0.01,
|
|
||||||
"search_context_size_low": 0.01,
|
|
||||||
"search_context_size_medium": 0.01
|
|
||||||
},
|
|
||||||
"supports_assistant_prefill": false,
|
|
||||||
"supports_computer_use": true,
|
|
||||||
"supports_function_calling": true,
|
|
||||||
"supports_pdf_input": true,
|
|
||||||
"supports_prompt_caching": true,
|
|
||||||
"supports_reasoning": true,
|
|
||||||
"supports_response_schema": true,
|
|
||||||
"supports_tool_choice": true,
|
|
||||||
"supports_vision": true,
|
|
||||||
"tool_use_system_prompt_tokens": 346
|
|
||||||
},
|
|
||||||
"us/claude-opus-4-6": {
|
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
|
||||||
"cache_creation_input_token_cost_above_200k_tokens": 0.00001375,
|
|
||||||
"cache_creation_input_token_cost_above_1hr": 0.000011,
|
|
||||||
"cache_read_input_token_cost": 5.5e-7,
|
|
||||||
"cache_read_input_token_cost_above_200k_tokens": 0.0000011,
|
|
||||||
"input_cost_per_token": 0.0000055,
|
|
||||||
"input_cost_per_token_above_200k_tokens": 0.000011,
|
|
||||||
"litellm_provider": "anthropic",
|
|
||||||
"max_input_tokens": 200000,
|
|
||||||
"max_output_tokens": 128000,
|
|
||||||
"max_tokens": 128000,
|
|
||||||
"mode": "chat",
|
|
||||||
"output_cost_per_token": 0.0000275,
|
|
||||||
"output_cost_per_token_above_200k_tokens": 0.00004125,
|
|
||||||
"search_context_cost_per_query": {
|
|
||||||
"search_context_size_high": 0.01,
|
|
||||||
"search_context_size_low": 0.01,
|
|
||||||
"search_context_size_medium": 0.01
|
|
||||||
},
|
|
||||||
"supports_assistant_prefill": false,
|
|
||||||
"supports_computer_use": true,
|
|
||||||
"supports_function_calling": true,
|
|
||||||
"supports_pdf_input": true,
|
|
||||||
"supports_prompt_caching": true,
|
|
||||||
"supports_reasoning": true,
|
|
||||||
"supports_response_schema": true,
|
|
||||||
"supports_tool_choice": true,
|
|
||||||
"supports_vision": true,
|
|
||||||
"tool_use_system_prompt_tokens": 346
|
|
||||||
},
|
|
||||||
"fast/us/claude-opus-4-6": {
|
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
|
||||||
"cache_creation_input_token_cost_above_200k_tokens": 0.00001375,
|
|
||||||
"cache_creation_input_token_cost_above_1hr": 0.000011,
|
|
||||||
"cache_read_input_token_cost": 5.5e-7,
|
|
||||||
"cache_read_input_token_cost_above_200k_tokens": 0.0000011,
|
|
||||||
"input_cost_per_token": 0.00003,
|
|
||||||
"input_cost_per_token_above_200k_tokens": 0.000011,
|
|
||||||
"litellm_provider": "anthropic",
|
|
||||||
"max_input_tokens": 200000,
|
|
||||||
"max_output_tokens": 128000,
|
|
||||||
"max_tokens": 128000,
|
|
||||||
"mode": "chat",
|
|
||||||
"output_cost_per_token": 0.00015,
|
|
||||||
"output_cost_per_token_above_200k_tokens": 0.00004125,
|
|
||||||
"search_context_cost_per_query": {
|
|
||||||
"search_context_size_high": 0.01,
|
|
||||||
"search_context_size_low": 0.01,
|
|
||||||
"search_context_size_medium": 0.01
|
|
||||||
},
|
|
||||||
"supports_assistant_prefill": false,
|
|
||||||
"supports_computer_use": true,
|
|
||||||
"supports_function_calling": true,
|
|
||||||
"supports_pdf_input": true,
|
|
||||||
"supports_prompt_caching": true,
|
|
||||||
"supports_reasoning": true,
|
|
||||||
"supports_response_schema": true,
|
|
||||||
"supports_tool_choice": true,
|
|
||||||
"supports_vision": true,
|
|
||||||
"tool_use_system_prompt_tokens": 346
|
|
||||||
},
|
},
|
||||||
"claude-opus-4-6-20260205": {
|
"claude-opus-4-6-20260205": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
|
@ -1979,69 +1859,11 @@
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346
|
"tool_use_system_prompt_tokens": 346,
|
||||||
},
|
"provider_specific_entry": {
|
||||||
"fast/claude-opus-4-6-20260205": {
|
"us": 1.1,
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"fast": 6
|
||||||
"cache_creation_input_token_cost_above_200k_tokens": 0.0000125,
|
}
|
||||||
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
|
||||||
"cache_read_input_token_cost": 5e-7,
|
|
||||||
"cache_read_input_token_cost_above_200k_tokens": 0.000001,
|
|
||||||
"input_cost_per_token": 0.00003,
|
|
||||||
"input_cost_per_token_above_200k_tokens": 0.00001,
|
|
||||||
"litellm_provider": "anthropic",
|
|
||||||
"max_input_tokens": 1000000,
|
|
||||||
"max_output_tokens": 128000,
|
|
||||||
"max_tokens": 128000,
|
|
||||||
"mode": "chat",
|
|
||||||
"output_cost_per_token": 0.00015,
|
|
||||||
"output_cost_per_token_above_200k_tokens": 0.0000375,
|
|
||||||
"search_context_cost_per_query": {
|
|
||||||
"search_context_size_high": 0.01,
|
|
||||||
"search_context_size_low": 0.01,
|
|
||||||
"search_context_size_medium": 0.01
|
|
||||||
},
|
|
||||||
"supports_assistant_prefill": false,
|
|
||||||
"supports_computer_use": true,
|
|
||||||
"supports_function_calling": true,
|
|
||||||
"supports_pdf_input": true,
|
|
||||||
"supports_prompt_caching": true,
|
|
||||||
"supports_reasoning": true,
|
|
||||||
"supports_response_schema": true,
|
|
||||||
"supports_tool_choice": true,
|
|
||||||
"supports_vision": true,
|
|
||||||
"tool_use_system_prompt_tokens": 346
|
|
||||||
},
|
|
||||||
"us/claude-opus-4-6-20260205": {
|
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
|
||||||
"cache_creation_input_token_cost_above_200k_tokens": 0.00001375,
|
|
||||||
"cache_creation_input_token_cost_above_1hr": 0.000011,
|
|
||||||
"cache_read_input_token_cost": 5.5e-7,
|
|
||||||
"cache_read_input_token_cost_above_200k_tokens": 0.0000011,
|
|
||||||
"input_cost_per_token": 0.0000055,
|
|
||||||
"input_cost_per_token_above_200k_tokens": 0.000011,
|
|
||||||
"litellm_provider": "anthropic",
|
|
||||||
"max_input_tokens": 200000,
|
|
||||||
"max_output_tokens": 128000,
|
|
||||||
"max_tokens": 128000,
|
|
||||||
"mode": "chat",
|
|
||||||
"output_cost_per_token": 0.0000275,
|
|
||||||
"output_cost_per_token_above_200k_tokens": 0.00004125,
|
|
||||||
"search_context_cost_per_query": {
|
|
||||||
"search_context_size_high": 0.01,
|
|
||||||
"search_context_size_low": 0.01,
|
|
||||||
"search_context_size_medium": 0.01
|
|
||||||
},
|
|
||||||
"supports_assistant_prefill": false,
|
|
||||||
"supports_computer_use": true,
|
|
||||||
"supports_function_calling": true,
|
|
||||||
"supports_pdf_input": true,
|
|
||||||
"supports_prompt_caching": true,
|
|
||||||
"supports_reasoning": true,
|
|
||||||
"supports_response_schema": true,
|
|
||||||
"supports_tool_choice": true,
|
|
||||||
"supports_vision": true,
|
|
||||||
"tool_use_system_prompt_tokens": 346
|
|
||||||
},
|
},
|
||||||
"claude-sonnet-4-20250514": {
|
"claude-sonnet-4-20250514": {
|
||||||
"deprecation_date": "2026-05-14",
|
"deprecation_date": "2026-05-14",
|
||||||
|
|
|
||||||
|
|
@ -241,10 +241,11 @@ async function handleGetFileContent(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
filePath: string
|
filePath: string,
|
||||||
|
snippets: SnippetDiff[] = []
|
||||||
): Promise<IpcResult<FileChangeWithContent>> {
|
): Promise<IpcResult<FileChangeWithContent>> {
|
||||||
return wrapReviewHandler('getFileContent', () =>
|
return wrapReviewHandler('getFileContent', () =>
|
||||||
getContentResolver().getFileContent(teamName, memberName, filePath)
|
getContentResolver().getFileContent(teamName, memberName, filePath, snippets)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { diffLines } from 'diff';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import { access, readFile } from 'fs/promises';
|
import { access, readFile } from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -28,7 +29,7 @@ interface ContentCacheEntry {
|
||||||
*/
|
*/
|
||||||
export class FileContentResolver {
|
export class FileContentResolver {
|
||||||
private cache = new Map<string, ContentCacheEntry>();
|
private cache = new Map<string, ContentCacheEntry>();
|
||||||
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин (same as ChangeExtractorService)
|
private readonly cacheTtl = 3 * 60 * 1000; // 3 мин (same as ChangeExtractorService)
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logsFinder: TeamMemberLogsFinder,
|
private readonly logsFinder: TeamMemberLogsFinder,
|
||||||
|
|
@ -123,16 +124,37 @@ export class FileContentResolver {
|
||||||
async getFileContent(
|
async getFileContent(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
filePath: string
|
filePath: string,
|
||||||
|
snippets: SnippetDiff[] = []
|
||||||
): Promise<FileChangeWithContent> {
|
): Promise<FileChangeWithContent> {
|
||||||
const resolved = await this.resolveFileContent(teamName, memberName, filePath, []);
|
const resolved = await this.resolveFileContent(teamName, memberName, filePath, snippets);
|
||||||
|
|
||||||
|
// Compute accurate stats from full content diff
|
||||||
|
let linesAdded = 0;
|
||||||
|
let linesRemoved = 0;
|
||||||
|
if (resolved.original !== null && resolved.modified !== null) {
|
||||||
|
const changes = diffLines(resolved.original, resolved.modified);
|
||||||
|
for (const c of changes) {
|
||||||
|
if (c.added) linesAdded += c.count ?? 0;
|
||||||
|
if (c.removed) linesRemoved += c.count ?? 0;
|
||||||
|
}
|
||||||
|
} else if (resolved.original === null && resolved.modified !== null) {
|
||||||
|
// Use diffLines for consistency with ChangeExtractorService.countLines()
|
||||||
|
const changes = diffLines('', resolved.modified);
|
||||||
|
for (const c of changes) {
|
||||||
|
if (c.added) linesAdded += c.count ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewFile = snippets.some((s) => s.type === 'write-new');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filePath,
|
filePath,
|
||||||
relativePath: filePath.split('/').slice(-3).join('/'),
|
relativePath: filePath.split('/').slice(-3).join('/'),
|
||||||
snippets: [],
|
snippets,
|
||||||
linesAdded: 0,
|
linesAdded,
|
||||||
linesRemoved: 0,
|
linesRemoved,
|
||||||
isNewFile: false,
|
isNewFile,
|
||||||
originalFullContent: resolved.original,
|
originalFullContent: resolved.original,
|
||||||
modifiedFullContent: resolved.modified,
|
modifiedFullContent: resolved.modified,
|
||||||
contentSource: resolved.source,
|
contentSource: resolved.source,
|
||||||
|
|
@ -165,12 +187,25 @@ export class FileContentResolver {
|
||||||
file.filePath,
|
file.filePath,
|
||||||
file.snippets
|
file.snippets
|
||||||
);
|
);
|
||||||
|
// Compute accurate stats from full content diff
|
||||||
|
let linesAdded = file.linesAdded;
|
||||||
|
let linesRemoved = file.linesRemoved;
|
||||||
|
if (resolved.original !== null && resolved.modified !== null) {
|
||||||
|
linesAdded = 0;
|
||||||
|
linesRemoved = 0;
|
||||||
|
const changes = diffLines(resolved.original, resolved.modified);
|
||||||
|
for (const c of changes) {
|
||||||
|
if (c.added) linesAdded += c.count ?? 0;
|
||||||
|
if (c.removed) linesRemoved += c.count ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const entry: FileChangeWithContent = {
|
const entry: FileChangeWithContent = {
|
||||||
filePath: file.filePath,
|
filePath: file.filePath,
|
||||||
relativePath: file.relativePath,
|
relativePath: file.relativePath,
|
||||||
snippets: file.snippets,
|
snippets: file.snippets,
|
||||||
linesAdded: file.linesAdded,
|
linesAdded,
|
||||||
linesRemoved: file.linesRemoved,
|
linesRemoved,
|
||||||
isNewFile: file.isNewFile,
|
isNewFile: file.isNewFile,
|
||||||
originalFullContent: resolved.original,
|
originalFullContent: resolved.original,
|
||||||
modifiedFullContent: resolved.modified,
|
modifiedFullContent: resolved.modified,
|
||||||
|
|
@ -352,6 +387,9 @@ export class FileContentResolver {
|
||||||
|
|
||||||
case 'edit':
|
case 'edit':
|
||||||
case 'multi-edit': {
|
case 'multi-edit': {
|
||||||
|
// Guard: empty newString means deletion — can't find position to reverse
|
||||||
|
if (!snippet.newString) return null;
|
||||||
|
|
||||||
if (snippet.replaceAll) {
|
if (snippet.replaceAll) {
|
||||||
// Reverse replaceAll: replace all occurrences of newString -> oldString
|
// Reverse replaceAll: replace all occurrences of newString -> oldString
|
||||||
if (!content.includes(snippet.newString)) {
|
if (!content.includes(snippet.newString)) {
|
||||||
|
|
@ -453,7 +491,7 @@ export class FileContentResolver {
|
||||||
original: result.original,
|
original: result.original,
|
||||||
modified: result.modified,
|
modified: result.modified,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
expiresAt: Date.now() + this.CACHE_TTL,
|
expiresAt: Date.now() + this.cacheTtl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ import {
|
||||||
getTasksBasePath,
|
getTasksBasePath,
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
import {
|
||||||
|
AGENT_BLOCK_CLOSE,
|
||||||
|
AGENT_BLOCK_OPEN,
|
||||||
|
stripAgentBlocks,
|
||||||
|
} from '@shared/constants/agentBlocks';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { execFile, spawn } from 'child_process';
|
import { execFile, spawn } from 'child_process';
|
||||||
|
|
@ -351,7 +355,8 @@ ${AGENT_BLOCK_OPEN}
|
||||||
(internal instructions: commands, script usage, paths, etc.)
|
(internal instructions: commands, script usage, paths, etc.)
|
||||||
${AGENT_BLOCK_CLOSE}
|
${AGENT_BLOCK_CLOSE}
|
||||||
- Put ONLY the internal instructions inside the agent-only block.
|
- Put ONLY the internal instructions inside the agent-only block.
|
||||||
- CRITICAL: Messages to "user" (the human) must NEVER contain agent-only blocks. Write them as plain readable text — the human sees these messages directly in the UI. Agent-only blocks are stripped before display, so a message containing ONLY an agent-only block will appear completely empty.`;
|
- CRITICAL: Messages to "user" (the human) must NEVER contain agent-only blocks. Write them as plain readable text — the human sees these messages directly in the UI. Agent-only blocks are stripped before display, so a message containing ONLY an agent-only block will appear completely empty.
|
||||||
|
- CRITICAL: When processing relayed inbox messages, your text output is shown to the user. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include a brief human-readable summary outside of it (e.g. "Delegated task to carol." or "Acknowledged, no action needed.").`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSystemLocale(): string {
|
function getSystemLocale(): string {
|
||||||
|
|
@ -1466,6 +1471,7 @@ export class TeamProvisioningService {
|
||||||
`You have new inbox messages addressed to you (team lead "${leadName}").`,
|
`You have new inbox messages addressed to you (team lead "${leadName}").`,
|
||||||
`Process them in order (oldest first).`,
|
`Process them in order (oldest first).`,
|
||||||
`If action is required, delegate via task creation or SendMessage, and keep responses minimal.`,
|
`If action is required, delegate via task creation or SendMessage, and keep responses minimal.`,
|
||||||
|
`IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`,
|
||||||
AGENT_BLOCK_OPEN,
|
AGENT_BLOCK_OPEN,
|
||||||
`Internal note: for task assignments, prefer teamctl.js task create --notify (avoid sending a separate SendMessage for the same assignment).`,
|
`Internal note: for task assignments, prefer teamctl.js task create --notify (avoid sending a separate SendMessage for the same assignment).`,
|
||||||
AGENT_BLOCK_CLOSE,
|
AGENT_BLOCK_CLOSE,
|
||||||
|
|
@ -1568,14 +1574,17 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyText) {
|
// Strip agent-only blocks — lead may respond with pure coordination content
|
||||||
|
// that is not meant for the human user.
|
||||||
|
const cleanReply = replyText ? stripAgentBlocks(replyText) : null;
|
||||||
|
if (cleanReply) {
|
||||||
this.pushLiveLeadProcessMessage(teamName, {
|
this.pushLiveLeadProcessMessage(teamName, {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
to: 'user',
|
to: 'user',
|
||||||
text: replyText,
|
text: cleanReply,
|
||||||
timestamp: nowIso(),
|
timestamp: nowIso(),
|
||||||
read: true,
|
read: true,
|
||||||
summary: 'Lead reply',
|
summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply,
|
||||||
messageId: `lead-process-${runId}-${Date.now()}`,
|
messageId: `lead-process-${runId}-${Date.now()}`,
|
||||||
source: 'lead_process',
|
source: 'lead_process',
|
||||||
});
|
});
|
||||||
|
|
@ -1867,11 +1876,13 @@ export class TeamProvisioningService {
|
||||||
capture.resolveOnce(combined);
|
capture.resolveOnce(combined);
|
||||||
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
|
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
|
||||||
// Flush accumulated assistant reply from direct user→lead message
|
// Flush accumulated assistant reply from direct user→lead message
|
||||||
const replyText = run.directReplyParts.join('').trim();
|
const rawReply = run.directReplyParts.join('').trim();
|
||||||
run.directReplyParts = [];
|
run.directReplyParts = [];
|
||||||
const leadName =
|
const leadName =
|
||||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||||
'team-lead';
|
'team-lead';
|
||||||
|
// Strip agent-only blocks — lead may include coordination content not meant for the user
|
||||||
|
const replyText = stripAgentBlocks(rawReply);
|
||||||
if (replyText.length > 0) {
|
if (replyText.length > 0) {
|
||||||
const replyMsg: InboxMessage = {
|
const replyMsg: InboxMessage = {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
|
|
|
||||||
|
|
@ -735,12 +735,18 @@ const electronAPI: ElectronAPI = {
|
||||||
getChangeStats: async (teamName: string, memberName: string) => {
|
getChangeStats: async (teamName: string, memberName: string) => {
|
||||||
return invokeIpcWithResult<ChangeStats>(REVIEW_GET_CHANGE_STATS, teamName, memberName);
|
return invokeIpcWithResult<ChangeStats>(REVIEW_GET_CHANGE_STATS, teamName, memberName);
|
||||||
},
|
},
|
||||||
getFileContent: async (teamName: string, memberName: string | undefined, filePath: string) => {
|
getFileContent: async (
|
||||||
|
teamName: string,
|
||||||
|
memberName: string | undefined,
|
||||||
|
filePath: string,
|
||||||
|
snippets: SnippetDiff[] = []
|
||||||
|
) => {
|
||||||
return invokeIpcWithResult<FileChangeWithContent>(
|
return invokeIpcWithResult<FileChangeWithContent>(
|
||||||
REVIEW_GET_FILE_CONTENT,
|
REVIEW_GET_FILE_CONTENT,
|
||||||
teamName,
|
teamName,
|
||||||
memberName ?? '',
|
memberName ?? '',
|
||||||
filePath
|
filePath,
|
||||||
|
snippets
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
applyDecisions: async (request: ApplyReviewRequest) => {
|
applyDecisions: async (request: ApplyReviewRequest) => {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import type {
|
||||||
SessionMetrics,
|
SessionMetrics,
|
||||||
SessionsByIdsOptions,
|
SessionsByIdsOptions,
|
||||||
SessionsPaginationOptions,
|
SessionsPaginationOptions,
|
||||||
|
SnippetDiff,
|
||||||
SshAPI,
|
SshAPI,
|
||||||
SshConfigHostEntry,
|
SshConfigHostEntry,
|
||||||
SshConnectionConfig,
|
SshConnectionConfig,
|
||||||
|
|
@ -809,7 +810,8 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
getFileContent: async (
|
getFileContent: async (
|
||||||
_teamName: string,
|
_teamName: string,
|
||||||
_memberName: string | undefined,
|
_memberName: string | undefined,
|
||||||
_filePath: string
|
_filePath: string,
|
||||||
|
_snippets: SnippetDiff[] = []
|
||||||
): Promise<never> => {
|
): Promise<never> => {
|
||||||
throw new Error('Review is not available in browser mode');
|
throw new Error('Review is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { TeamTabSectionNav } from './TeamTabSectionNav';
|
||||||
|
|
||||||
import type { Tab } from '@renderer/types/tabs';
|
import type { Tab } from '@renderer/types/tabs';
|
||||||
|
|
||||||
interface SortableTabProps {
|
interface SortableTabProps {
|
||||||
|
|
@ -98,6 +100,8 @@ export const SortableTab = ({
|
||||||
[setNodeRef, setRef, tab.id]
|
[setNodeRef, setRef, tab.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isTeamTab = tab.type === 'team' && tab.teamName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={handleRef}
|
ref={handleRef}
|
||||||
|
|
@ -108,7 +112,11 @@ export const SortableTab = ({
|
||||||
role="tab"
|
role="tab"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
className="group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
|
className={
|
||||||
|
isTeamTab
|
||||||
|
? 'group flex min-w-0 max-w-[200px] shrink-0 cursor-grab flex-col rounded-md'
|
||||||
|
: 'group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5'
|
||||||
|
}
|
||||||
style={style}
|
style={style}
|
||||||
onClick={(e) => onTabClick(tab.id, e)}
|
onClick={(e) => onTabClick(tab.id, e)}
|
||||||
onMouseDown={(e) => onMouseDown(tab.id, e)}
|
onMouseDown={(e) => onMouseDown(tab.id, e)}
|
||||||
|
|
@ -122,30 +130,45 @@ export const SortableTab = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0" />
|
<div className={isTeamTab ? 'flex min-w-0 items-center gap-2 px-3 pb-0.5 pt-1' : 'contents'}>
|
||||||
{tab.fromSearch && (
|
<Icon className="size-4 shrink-0" />
|
||||||
<span title="Opened from search">
|
{tab.fromSearch && (
|
||||||
<Search className="size-3 shrink-0 text-amber-400" />
|
<span title="Opened from search">
|
||||||
</span>
|
<Search className="size-3 shrink-0 text-amber-400" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPinned && (
|
||||||
|
<span title="Pinned session">
|
||||||
|
<Pin className="size-3 shrink-0 text-blue-400" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate text-sm">{tab.label}</span>
|
||||||
|
<button
|
||||||
|
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
style={{ backgroundColor: 'transparent' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose(tab.id);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
title="Close tab"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isTeamTab && (
|
||||||
|
<TeamTabSectionNav
|
||||||
|
teamName={tab.teamName!}
|
||||||
|
onActivate={() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
onTabClick(tab.id, {
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
} as React.MouseEvent);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{isPinned && (
|
|
||||||
<span title="Pinned session">
|
|
||||||
<Pin className="size-3 shrink-0 text-blue-400" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="truncate text-sm">{tab.label}</span>
|
|
||||||
<button
|
|
||||||
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
|
|
||||||
style={{ backgroundColor: 'transparent' }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose(tab.id);
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
title="Close tab"
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
134
src/renderer/components/layout/TeamTabSectionNav.tsx
Normal file
134
src/renderer/components/layout/TeamTabSectionNav.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { ChevronDown, Columns3, History, MessageSquare, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TeamTabSectionNavProps {
|
||||||
|
teamName: string;
|
||||||
|
onActivate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [
|
||||||
|
{ id: 'team', label: 'Team', icon: Users },
|
||||||
|
{ id: 'sessions', label: 'Sessions', icon: History },
|
||||||
|
{ id: 'kanban', label: 'Kanban', icon: Columns3 },
|
||||||
|
{ id: 'messages', label: 'Messages', icon: MessageSquare },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TeamTabSectionNav = ({
|
||||||
|
teamName,
|
||||||
|
onActivate,
|
||||||
|
}: TeamTabSectionNavProps): React.JSX.Element => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
|
||||||
|
|
||||||
|
const handleNavigate = useCallback(
|
||||||
|
(sectionId: string) => {
|
||||||
|
onActivate?.();
|
||||||
|
const el = document.querySelector(
|
||||||
|
`[data-team-name="${CSS.escape(teamName)}"] [data-section-id="${sectionId}"]`
|
||||||
|
);
|
||||||
|
if (el) {
|
||||||
|
el.dispatchEvent(new CustomEvent('team-section-navigate'));
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
[teamName, onActivate]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (buttonRef.current) {
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
setMenuPos({
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
left: rect.left,
|
||||||
|
width: Math.max(rect.width, 120),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const handleDismiss = (e: MouseEvent): void => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const handleEscape = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleDismiss);
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleDismiss);
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full" onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
className="flex h-3.5 w-full items-center justify-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
}}
|
||||||
|
title="Jump to section"
|
||||||
|
>
|
||||||
|
<ChevronDown size={10} />
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
role="menu"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="fixed z-50 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] py-0.5 shadow-lg"
|
||||||
|
style={{ top: menuPos.top, left: menuPos.left, minWidth: menuPos.width }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SECTIONS.map((section) => {
|
||||||
|
const SectionIcon = section.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs transition-colors"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
hoveredId === section.id
|
||||||
|
? 'var(--color-text)'
|
||||||
|
: 'var(--color-text-secondary)',
|
||||||
|
backgroundColor:
|
||||||
|
hoveredId === section.id ? 'var(--color-surface-raised)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredId(section.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleNavigate(section.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SectionIcon size={12} className="shrink-0" />
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
@ -107,14 +108,21 @@ export const SidebarTaskItem = ({
|
||||||
onClick={() => openGlobalTaskDetail(task.teamName, task.id)}
|
onClick={() => openGlobalTaskDetail(task.teamName, task.id)}
|
||||||
>
|
>
|
||||||
{/* Row 1: status + subject */}
|
{/* Row 1: status + subject */}
|
||||||
<div className="flex w-full items-center gap-1.5 overflow-hidden">
|
<div className="flex w-full items-start gap-1.5 overflow-hidden">
|
||||||
<StatusIcon className={`size-3 shrink-0 ${cfg.color}`} />
|
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
|
||||||
<span
|
<Tooltip>
|
||||||
className="truncate text-[13px] font-medium leading-tight"
|
<TooltipTrigger asChild>
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
<span
|
||||||
>
|
className="line-clamp-2 text-[13px] font-medium leading-tight"
|
||||||
{task.subject}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
</span>
|
>
|
||||||
|
{task.subject}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={6}>
|
||||||
|
{task.subject}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className="size-1.5 shrink-0 rounded-full bg-blue-400"
|
className="size-1.5 shrink-0 rounded-full bg-blue-400"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
function scrollAfterExpand(el: HTMLElement): void {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface CollapsibleTeamSectionProps {
|
interface CollapsibleTeamSectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
/** Icon rendered before the title text. */
|
/** Icon rendered before the title text. */
|
||||||
|
|
@ -15,6 +23,8 @@ interface CollapsibleTeamSectionProps {
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
forceOpen?: boolean;
|
forceOpen?: boolean;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
|
/** Stable identifier used for programmatic section navigation. */
|
||||||
|
sectionId?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,13 +37,31 @@ export const CollapsibleTeamSection = ({
|
||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
forceOpen,
|
forceOpen,
|
||||||
action,
|
action,
|
||||||
|
sectionId,
|
||||||
children,
|
children,
|
||||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
const isOpen = forceOpen ? true : open;
|
const isOpen = forceOpen ? true : open;
|
||||||
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
const handleNavigate = useCallback((): void => {
|
||||||
|
setOpen(true);
|
||||||
|
if (sectionRef.current) scrollAfterExpand(sectionRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sectionRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('team-section-navigate', handleNavigate);
|
||||||
|
return () => el.removeEventListener('team-section-navigate', handleNavigate);
|
||||||
|
}, [handleNavigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="min-w-0 overflow-hidden border-b border-[var(--color-border)] pb-3 last:border-b-0">
|
<section
|
||||||
|
ref={sectionRef}
|
||||||
|
data-section-id={sectionId}
|
||||||
|
className="min-w-0 overflow-hidden border-b border-[var(--color-border)] pb-3 last:border-b-0"
|
||||||
|
>
|
||||||
<div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
|
<div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -19,22 +19,27 @@ import { useStore } from '@renderer/store';
|
||||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
|
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bell,
|
Bell,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
Columns3,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
History,
|
History,
|
||||||
|
MessageSquare,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Square,
|
Square,
|
||||||
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Users,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -153,6 +158,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
projects,
|
projects,
|
||||||
|
repositoryGroups,
|
||||||
teams,
|
teams,
|
||||||
selectTeam,
|
selectTeam,
|
||||||
updateKanban,
|
updateKanban,
|
||||||
|
|
@ -189,6 +195,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
loading: s.selectedTeamLoading,
|
loading: s.selectedTeamLoading,
|
||||||
error: s.selectedTeamError,
|
error: s.selectedTeamError,
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
|
repositoryGroups: s.repositoryGroups,
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
selectTeam: s.selectTeam,
|
selectTeam: s.selectTeam,
|
||||||
updateKanban: s.updateKanban,
|
updateKanban: s.updateKanban,
|
||||||
|
|
@ -274,10 +281,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
}, [kanbanFilterQuery, clearKanbanFilter]);
|
}, [kanbanFilterQuery, clearKanbanFilter]);
|
||||||
|
|
||||||
// Load sessions for the team's project
|
// Load sessions for the team's project
|
||||||
const projectId = useMemo(() => {
|
const projectId = useMemo(
|
||||||
if (!data?.config.projectPath) return null;
|
() => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups),
|
||||||
return projects.find((p) => p.path === data.config.projectPath)?.id ?? null;
|
[projects, repositoryGroups, data?.config.projectPath]
|
||||||
}, [projects, data?.config.projectPath]);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
@ -647,7 +654,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
: nameColorSet(data.config.name);
|
: nameColorSet(data.config.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-auto p-4">
|
<div className="size-full overflow-auto p-4" data-team-name={teamName}>
|
||||||
<div
|
<div
|
||||||
className="relative mb-3 overflow-hidden rounded-lg border border-[var(--color-border)] px-4 py-3"
|
className="relative mb-3 overflow-hidden rounded-lg border border-[var(--color-border)] px-4 py-3"
|
||||||
style={
|
style={
|
||||||
|
|
@ -820,7 +827,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
|
sectionId="team"
|
||||||
title="Team"
|
title="Team"
|
||||||
|
icon={<Users size={14} />}
|
||||||
badge={activeMembers.length}
|
badge={activeMembers.length}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
action={
|
action={
|
||||||
|
|
@ -859,7 +868,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
<CollapsibleTeamSection title="Sessions" defaultOpen={false}>
|
<CollapsibleTeamSection
|
||||||
|
sectionId="sessions"
|
||||||
|
title="Sessions"
|
||||||
|
icon={<History size={14} />}
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
<TeamSessionsSection
|
<TeamSessionsSection
|
||||||
sessions={teamSessions}
|
sessions={teamSessions}
|
||||||
sessionsLoading={sessionsLoading}
|
sessionsLoading={sessionsLoading}
|
||||||
|
|
@ -872,7 +886,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
|
sectionId="kanban"
|
||||||
title="Kanban"
|
title="Kanban"
|
||||||
|
icon={<Columns3 size={14} />}
|
||||||
badge={filteredTasks.length}
|
badge={filteredTasks.length}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
forceOpen={kanbanSearch.trim().length > 0}
|
forceOpen={kanbanSearch.trim().length > 0}
|
||||||
|
|
@ -1037,7 +1053,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
|
|
||||||
{(data.processes?.length ?? 0) > 0 && (
|
{(data.processes?.length ?? 0) > 0 && (
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
|
sectionId="processes"
|
||||||
title="CLI Processes"
|
title="CLI Processes"
|
||||||
|
icon={<Terminal size={14} />}
|
||||||
badge={data.processes.filter((p) => !p.stoppedAt).length}
|
badge={data.processes.filter((p) => !p.stoppedAt).length}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
|
|
@ -1046,7 +1064,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
|
sectionId="messages"
|
||||||
title="Messages"
|
title="Messages"
|
||||||
|
icon={<MessageSquare size={14} />}
|
||||||
badge={filteredMessages.length}
|
badge={filteredMessages.length}
|
||||||
secondaryBadge={
|
secondaryBadge={
|
||||||
filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined
|
filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||||
import { formatDistanceToNowStrict } from 'date-fns';
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -36,18 +37,19 @@ export const TeamSessionsSection = ({
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
projectPath,
|
projectPath,
|
||||||
}: TeamSessionsSectionProps): React.JSX.Element => {
|
}: TeamSessionsSectionProps): React.JSX.Element => {
|
||||||
const { openTab, selectSession, projects } = useStore(
|
const { openTab, selectSession, projects, repositoryGroups } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
openTab: s.openTab,
|
openTab: s.openTab,
|
||||||
selectSession: s.selectSession,
|
selectSession: s.selectSession,
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
|
repositoryGroups: s.repositoryGroups,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectId = useMemo(() => {
|
const projectId = useMemo(
|
||||||
if (!projectPath) return null;
|
() => resolveProjectIdByPath(projectPath, projects, repositoryGroups),
|
||||||
return projects.find((p) => p.path === projectPath)?.id ?? null;
|
[projects, repositoryGroups, projectPath]
|
||||||
}, [projects, projectPath]);
|
);
|
||||||
|
|
||||||
// Sort: lead session first, then by most recent
|
// Sort: lead session first, then by most recent
|
||||||
const sortedSessions = useMemo(() => {
|
const sortedSessions = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
|
|
@ -25,6 +25,7 @@ interface ActivityTimelineProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIEWPORT_THRESHOLD = 0.15;
|
const VIEWPORT_THRESHOLD = 0.15;
|
||||||
|
const MESSAGES_PAGE_SIZE = 30;
|
||||||
|
|
||||||
const MessageRowWithObserver = ({
|
const MessageRowWithObserver = ({
|
||||||
message,
|
message,
|
||||||
|
|
@ -110,6 +111,13 @@ export const ActivityTimeline = ({
|
||||||
onMessageVisible,
|
onMessageVisible,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
}: ActivityTimelineProps): React.JSX.Element => {
|
}: ActivityTimelineProps): React.JSX.Element => {
|
||||||
|
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
||||||
|
|
||||||
|
// Track whether the user was seeing ALL messages (no hidden ones).
|
||||||
|
// If so, auto-expand when new messages push count past the limit,
|
||||||
|
// so previously visible messages don't silently disappear.
|
||||||
|
const wasShowingAllRef = useRef(messages.length <= MESSAGES_PAGE_SIZE);
|
||||||
|
|
||||||
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
|
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
|
||||||
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
||||||
if (members) {
|
if (members) {
|
||||||
|
|
@ -140,6 +148,28 @@ export const ActivityTimeline = ({
|
||||||
if (member) onMemberClick?.(member);
|
if (member) onMemberClick?.(member);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hiddenCount = Math.max(0, messages.length - visibleCount);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasShowingAllRef.current && hiddenCount > 0) {
|
||||||
|
queueMicrotask(() => setVisibleCount(messages.length));
|
||||||
|
}
|
||||||
|
wasShowingAllRef.current = hiddenCount === 0;
|
||||||
|
}, [hiddenCount, messages.length]);
|
||||||
|
|
||||||
|
const visibleMessages = useMemo(
|
||||||
|
() => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages),
|
||||||
|
[messages, visibleCount, hiddenCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShowMore = (): void => {
|
||||||
|
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAll = (): void => {
|
||||||
|
setVisibleCount(Infinity);
|
||||||
|
};
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
|
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
|
||||||
|
|
@ -151,12 +181,13 @@ export const ActivityTimeline = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{messages.slice(0, 200).map((message, index) => {
|
{visibleMessages.map((message, index) => {
|
||||||
const info = memberInfo.get(message.from);
|
const info = memberInfo.get(message.from);
|
||||||
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
||||||
const recipientColor =
|
const recipientColor =
|
||||||
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
||||||
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
|
const globalIndex = index;
|
||||||
|
const messageKey = `${message.messageId ?? globalIndex}-${message.timestamp}-${message.from}`;
|
||||||
const isUnread = readState
|
const isUnread = readState
|
||||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||||
: !message.read;
|
: !message.read;
|
||||||
|
|
@ -177,6 +208,25 @@ export const ActivityTimeline = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5">
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]">+{hiddenCount} older</span>
|
||||||
|
<button
|
||||||
|
onClick={handleShowMore}
|
||||||
|
className="rounded px-2 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
Show {Math.min(MESSAGES_PAGE_SIZE, hiddenCount)} more
|
||||||
|
</button>
|
||||||
|
{hiddenCount > MESSAGES_PAGE_SIZE && (
|
||||||
|
<button
|
||||||
|
onClick={handleShowAll}
|
||||||
|
className="rounded px-2 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { api } from '@renderer/api';
|
||||||
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import { Combobox } from '@renderer/components/ui/combobox';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -28,9 +27,10 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { MembersJsonEditor } from './MembersJsonEditor';
|
import { MembersJsonEditor } from './MembersJsonEditor';
|
||||||
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
|
||||||
const TEAM_COLOR_NAMES = [
|
const TEAM_COLOR_NAMES = [
|
||||||
'blue',
|
'blue',
|
||||||
|
|
@ -121,39 +121,6 @@ function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHighlightedText(text: string, query: string): React.JSX.Element {
|
|
||||||
if (!query.trim()) {
|
|
||||||
return <span>{text}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pattern = new RegExp(`(${escapeRegExp(query)})`, 'ig');
|
|
||||||
const parts = text.split(pattern);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{parts.map((part, index) => {
|
|
||||||
const isMatch = part.toLowerCase() === query.toLowerCase();
|
|
||||||
if (!isMatch) {
|
|
||||||
return <span key={`${part}-${index}`}>{part}</span>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<mark
|
|
||||||
key={`${part}-${index}`}
|
|
||||||
// eslint-disable-next-line tailwindcss/no-custom-classname -- Tailwind arbitrary value with CSS variable
|
|
||||||
className="bg-[var(--color-accent)]/25 rounded px-0.5 text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</mark>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
|
function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
|
||||||
return members
|
return members
|
||||||
.map((member) => {
|
.map((member) => {
|
||||||
|
|
@ -845,120 +812,18 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
{launchTeam ? (
|
{launchTeam ? (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div className="space-y-1.5">
|
<ProjectPathSelector
|
||||||
<Label>Project</Label>
|
cwdMode={cwdMode}
|
||||||
<div className="space-y-2">
|
onCwdModeChange={setCwdMode}
|
||||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
selectedProjectPath={selectedProjectPath}
|
||||||
<button
|
onSelectedProjectPathChange={setSelectedProjectPath}
|
||||||
type="button"
|
customCwd={customCwd}
|
||||||
className={cn(
|
onCustomCwdChange={setCustomCwd}
|
||||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
projects={projects}
|
||||||
cwdMode === 'project'
|
projectsLoading={projectsLoading}
|
||||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
projectsError={projectsError}
|
||||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
fieldError={fieldErrors.cwd}
|
||||||
)}
|
/>
|
||||||
onClick={() => setCwdMode('project')}
|
|
||||||
>
|
|
||||||
From project list
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
|
||||||
cwdMode === 'custom'
|
|
||||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
|
||||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
|
||||||
)}
|
|
||||||
onClick={() => setCwdMode('custom')}
|
|
||||||
>
|
|
||||||
Custom path
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cwdMode === 'project' ? (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Combobox
|
|
||||||
options={projects.map((project) => ({
|
|
||||||
value: project.path,
|
|
||||||
label: project.name,
|
|
||||||
description: project.path,
|
|
||||||
}))}
|
|
||||||
value={selectedProjectPath}
|
|
||||||
onValueChange={setSelectedProjectPath}
|
|
||||||
placeholder={
|
|
||||||
projectsLoading ? 'Loading projects...' : 'Select a project...'
|
|
||||||
}
|
|
||||||
searchPlaceholder="Search project by name or path"
|
|
||||||
emptyMessage="Nothing found"
|
|
||||||
disabled={projectsLoading || projects.length === 0}
|
|
||||||
renderOption={(option, isSelected, query) => (
|
|
||||||
<>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'mr-2 size-3.5 shrink-0',
|
|
||||||
isSelected ? 'opacity-100' : 'opacity-0'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate font-medium text-[var(--color-text)]">
|
|
||||||
{renderHighlightedText(option.label, query)}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-[var(--color-text-muted)]">
|
|
||||||
{renderHighlightedText(option.description ?? '', query)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{!selectedProjectPath ? (
|
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
Select a project from the list
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{projectsError ? (
|
|
||||||
<p className="text-[11px] text-red-300">{projectsError}</p>
|
|
||||||
) : null}
|
|
||||||
{!projectsLoading && projects.length === 0 ? (
|
|
||||||
<p className="text-[11px] text-amber-300">
|
|
||||||
No projects found, switch to custom path.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={customCwd}
|
|
||||||
aria-label="Custom working directory"
|
|
||||||
onChange={(event) => setCustomCwd(event.target.value)}
|
|
||||||
placeholder="/absolute/path/to/project"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
void (async () => {
|
|
||||||
const paths = await api.config.selectFolders();
|
|
||||||
if (paths.length > 0) {
|
|
||||||
setCustomCwd(paths[0]);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Browse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
If the directory does not exist, it will be created automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{fieldErrors.cwd ? (
|
|
||||||
<p className="text-[11px] text-red-300">{fieldErrors.cwd}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="team-prompt" className="label-optional">
|
<Label htmlFor="team-prompt" className="label-optional">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Combobox } from '@renderer/components/ui/combobox';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -11,7 +10,6 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,12 +20,13 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { cn } from '@renderer/lib/utils';
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
|
||||||
import type { ActiveTeamRef } from './CreateTeamDialog';
|
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
@ -49,39 +48,6 @@ interface LaunchTeamDialogProps {
|
||||||
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
|
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHighlightedText(text: string, query: string): React.JSX.Element {
|
|
||||||
if (!query.trim()) {
|
|
||||||
return <span>{text}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pattern = new RegExp(`(${escapeRegExp(query)})`, 'ig');
|
|
||||||
const parts = text.split(pattern);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{parts.map((part, index) => {
|
|
||||||
const isMatch = part.toLowerCase() === query.toLowerCase();
|
|
||||||
if (!isMatch) {
|
|
||||||
return <span key={`${part}-${index}`}>{part}</span>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<mark
|
|
||||||
key={`${part}-${index}`}
|
|
||||||
// eslint-disable-next-line tailwindcss/no-custom-classname -- Tailwind arbitrary value with CSS variable
|
|
||||||
className="bg-[var(--color-accent)]/25 rounded px-0.5 text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</mark>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LaunchTeamDialog = ({
|
export const LaunchTeamDialog = ({
|
||||||
open,
|
open,
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -356,110 +322,21 @@ export const LaunchTeamDialog = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<ProjectPathSelector
|
||||||
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label>
|
cwdMode={cwdMode}
|
||||||
<div className="space-y-2">
|
onCwdModeChange={setCwdMode}
|
||||||
<div className="flex items-center gap-1">
|
selectedProjectPath={selectedProjectPath}
|
||||||
<Button
|
onSelectedProjectPathChange={setSelectedProjectPath}
|
||||||
type="button"
|
customCwd={customCwd}
|
||||||
variant={cwdMode === 'project' ? 'default' : 'outline'}
|
onCustomCwdChange={setCustomCwd}
|
||||||
size="sm"
|
projects={projects}
|
||||||
className="h-7 text-xs"
|
projectsLoading={projectsLoading}
|
||||||
onClick={() => setCwdMode('project')}
|
projectsError={projectsError}
|
||||||
>
|
/>
|
||||||
From project list
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={cwdMode === 'custom' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
onClick={() => setCwdMode('custom')}
|
|
||||||
>
|
|
||||||
Custom path
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cwdMode === 'project' ? (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Combobox
|
|
||||||
options={projects.map((project) => ({
|
|
||||||
value: project.path,
|
|
||||||
label: project.name,
|
|
||||||
description: project.path,
|
|
||||||
}))}
|
|
||||||
value={selectedProjectPath}
|
|
||||||
onValueChange={setSelectedProjectPath}
|
|
||||||
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
|
|
||||||
searchPlaceholder="Search project by name or path"
|
|
||||||
emptyMessage="Nothing found"
|
|
||||||
disabled={projectsLoading || projects.length === 0}
|
|
||||||
renderOption={(option, isSelected, query) => (
|
|
||||||
<>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'mr-2 size-3.5 shrink-0',
|
|
||||||
isSelected ? 'opacity-100' : 'opacity-0'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate font-medium text-[var(--color-text)]">
|
|
||||||
{renderHighlightedText(option.label, query)}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-[var(--color-text-muted)]">
|
|
||||||
{renderHighlightedText(option.description ?? '', query)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{!selectedProjectPath ? (
|
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
Select a project from the list
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{projectsError ? (
|
|
||||||
<p className="text-[11px] text-red-300">{projectsError}</p>
|
|
||||||
) : null}
|
|
||||||
{!projectsLoading && projects.length === 0 ? (
|
|
||||||
<p className="text-[11px] text-amber-300">
|
|
||||||
No projects found, switch to custom path.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={customCwd}
|
|
||||||
aria-label="Custom working directory"
|
|
||||||
onChange={(event) => setCustomCwd(event.target.value)}
|
|
||||||
placeholder="/absolute/path/to/project"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
void (async () => {
|
|
||||||
const paths = await api.config.selectFolders();
|
|
||||||
if (paths.length > 0) {
|
|
||||||
setCustomCwd(paths[0]);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Browse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="launch-prompt" className="label-optional text-xs">
|
<Label htmlFor="launch-prompt" className="label-optional">
|
||||||
Prompt (optional)
|
Prompt for team lead (optional)
|
||||||
</Label>
|
</Label>
|
||||||
<MentionableTextarea
|
<MentionableTextarea
|
||||||
id="launch-prompt"
|
id="launch-prompt"
|
||||||
|
|
@ -479,7 +356,7 @@ export const LaunchTeamDialog = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="label-optional text-xs">Model (optional)</Label>
|
<Label className="label-optional">Model (optional)</Label>
|
||||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="Default (account setting)" />
|
<SelectValue placeholder="Default (account setting)" />
|
||||||
|
|
|
||||||
176
src/renderer/components/team/dialogs/ProjectPathSelector.tsx
Normal file
176
src/renderer/components/team/dialogs/ProjectPathSelector.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import { Combobox } from '@renderer/components/ui/combobox';
|
||||||
|
import { Input } from '@renderer/components/ui/input';
|
||||||
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { Project } from '@shared/types';
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHighlightedText(text: string, query: string): React.JSX.Element {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return <span>{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = new RegExp(`(${escapeRegExp(query)})`, 'ig');
|
||||||
|
const parts = text.split(pattern);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
const isMatch = part.toLowerCase() === query.toLowerCase();
|
||||||
|
if (!isMatch) {
|
||||||
|
return <span key={`${part}-${index}`}>{part}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<mark
|
||||||
|
key={`${part}-${index}`}
|
||||||
|
// eslint-disable-next-line tailwindcss/no-custom-classname -- Tailwind arbitrary value with CSS variable
|
||||||
|
className="bg-[var(--color-accent)]/25 rounded px-0.5 text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CwdMode = 'project' | 'custom';
|
||||||
|
|
||||||
|
interface ProjectPathSelectorProps {
|
||||||
|
cwdMode: CwdMode;
|
||||||
|
onCwdModeChange: (mode: CwdMode) => void;
|
||||||
|
selectedProjectPath: string;
|
||||||
|
onSelectedProjectPathChange: (path: string) => void;
|
||||||
|
customCwd: string;
|
||||||
|
onCustomCwdChange: (cwd: string) => void;
|
||||||
|
projects: Project[];
|
||||||
|
projectsLoading: boolean;
|
||||||
|
projectsError: string | null;
|
||||||
|
fieldError?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectPathSelector = ({
|
||||||
|
cwdMode,
|
||||||
|
onCwdModeChange,
|
||||||
|
selectedProjectPath,
|
||||||
|
onSelectedProjectPathChange,
|
||||||
|
customCwd,
|
||||||
|
onCustomCwdChange,
|
||||||
|
projects,
|
||||||
|
projectsLoading,
|
||||||
|
projectsError,
|
||||||
|
fieldError,
|
||||||
|
}: ProjectPathSelectorProps): React.JSX.Element => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Project</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
cwdMode === 'project'
|
||||||
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
)}
|
||||||
|
onClick={() => onCwdModeChange('project')}
|
||||||
|
>
|
||||||
|
From project list
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
cwdMode === 'custom'
|
||||||
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
)}
|
||||||
|
onClick={() => onCwdModeChange('custom')}
|
||||||
|
>
|
||||||
|
Custom path
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cwdMode === 'project' ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Combobox
|
||||||
|
options={projects.map((project) => ({
|
||||||
|
value: project.path,
|
||||||
|
label: project.name,
|
||||||
|
description: project.path,
|
||||||
|
}))}
|
||||||
|
value={selectedProjectPath}
|
||||||
|
onValueChange={onSelectedProjectPathChange}
|
||||||
|
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
|
||||||
|
searchPlaceholder="Search project by name or path"
|
||||||
|
emptyMessage="Nothing found"
|
||||||
|
disabled={projectsLoading || projects.length === 0}
|
||||||
|
renderOption={(option, isSelected, query) => (
|
||||||
|
<>
|
||||||
|
<Check
|
||||||
|
className={cn('mr-2 size-3.5 shrink-0', isSelected ? 'opacity-100' : 'opacity-0')}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium text-[var(--color-text)]">
|
||||||
|
{renderHighlightedText(option.label, query)}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[var(--color-text-muted)]">
|
||||||
|
{renderHighlightedText(option.description ?? '', query)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!selectedProjectPath ? (
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Select a project from the list
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
|
||||||
|
{!projectsLoading && projects.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-amber-300">No projects found, switch to custom path.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={customCwd}
|
||||||
|
aria-label="Custom working directory"
|
||||||
|
onChange={(event) => onCustomCwdChange(event.target.value)}
|
||||||
|
placeholder="/absolute/path/to/project"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
const paths = await api.config.selectFolders();
|
||||||
|
if (paths.length > 0) {
|
||||||
|
onCustomCwdChange(paths[0]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
If the directory does not exist, it will be created automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{fieldError ? <p className="text-[11px] text-red-300">{fieldError}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
@ -109,13 +109,17 @@ export const SendMessageDialog = ({
|
||||||
[members, colorMap]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const canSend = member.trim().length > 0 && textDraft.value.trim().length > 0 && !sending;
|
const canSend =
|
||||||
|
member.trim().length > 0 &&
|
||||||
|
textDraft.value.trim().length > 0 &&
|
||||||
|
summary.trim().length > 0 &&
|
||||||
|
!sending;
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
const rawText = textDraft.value.trim();
|
const rawText = textDraft.value.trim();
|
||||||
const finalText = quote ? buildReplyBlock(quote.from, quote.text, rawText) : rawText;
|
const finalText = quote ? buildReplyBlock(quote.from, quote.text, rawText) : rawText;
|
||||||
onSend(member.trim(), finalText, summary.trim() || undefined);
|
onSend(member.trim(), finalText, summary.trim());
|
||||||
textDraft.clearDraft();
|
textDraft.clearDraft();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -172,18 +176,6 @@ export const SendMessageDialog = ({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="smd-summary" className="label-optional">
|
|
||||||
Summary (optional)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="smd-summary"
|
|
||||||
placeholder="Brief description shown as preview..."
|
|
||||||
value={summary}
|
|
||||||
onChange={(e) => setSummary(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{quote ? (
|
{quote ? (
|
||||||
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
|
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -225,6 +217,20 @@ export const SendMessageDialog = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="smd-summary">Summary</Label>
|
||||||
|
<Input
|
||||||
|
id="smd-summary"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="Brief summary reflecting the message intent"
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Shown as notification preview. Team lead also sees this for peer messages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
|
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,8 @@ export const MessageComposer = ({
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
const autoSummary = trimmed.length > 60 ? trimmed.slice(0, 57) + '...' : trimmed;
|
|
||||||
pendingSendRef.current = true;
|
pendingSendRef.current = true;
|
||||||
onSend(recipient, trimmed, autoSummary, attachments.length > 0 ? attachments : undefined);
|
onSend(recipient, trimmed, trimmed, attachments.length > 0 ? attachments : undefined);
|
||||||
}, [canSend, recipient, trimmed, onSend, attachments]);
|
}, [canSend, recipient, trimmed, onSend, attachments]);
|
||||||
|
|
||||||
// Clear draft only after send completes successfully (sending: true → false, no error)
|
// Clear draft only after send completes successfully (sending: true → false, no error)
|
||||||
|
|
|
||||||
|
|
@ -355,11 +355,41 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await api.review.getFileContent(teamName, memberName, filePath);
|
// Lookup snippets from activeChangeSet so backend can use them for reconstruction
|
||||||
set((s) => ({
|
const activeChangeSet = get().activeChangeSet;
|
||||||
fileContents: { ...s.fileContents, [filePath]: content },
|
const fileEntry = activeChangeSet?.files.find((f) => f.filePath === filePath);
|
||||||
fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false },
|
const snippets = fileEntry?.snippets ?? [];
|
||||||
}));
|
|
||||||
|
const content = await api.review.getFileContent(teamName, memberName, filePath, snippets);
|
||||||
|
set((s) => {
|
||||||
|
const result: Partial<ChangeReviewSlice> = {
|
||||||
|
fileContents: { ...s.fileContents, [filePath]: content },
|
||||||
|
fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update activeChangeSet stats if original was successfully resolved
|
||||||
|
if (
|
||||||
|
content.contentSource !== 'unavailable' &&
|
||||||
|
content.contentSource !== 'disk-current' &&
|
||||||
|
s.activeChangeSet
|
||||||
|
) {
|
||||||
|
const updatedFiles = s.activeChangeSet.files.map((f) =>
|
||||||
|
f.filePath === filePath
|
||||||
|
? { ...f, linesAdded: content.linesAdded, linesRemoved: content.linesRemoved }
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
const totalLinesAdded = updatedFiles.reduce((sum, f) => sum + f.linesAdded, 0);
|
||||||
|
const totalLinesRemoved = updatedFiles.reduce((sum, f) => sum + f.linesRemoved, 0);
|
||||||
|
result.activeChangeSet = {
|
||||||
|
...s.activeChangeSet,
|
||||||
|
files: updatedFiles,
|
||||||
|
totalLinesAdded,
|
||||||
|
totalLinesRemoved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('fetchFileContent error:', error);
|
logger.error('fetchFileContent error:', error);
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
|
|
@ -375,13 +405,10 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
// Stale check: re-fetch changes and compare content fingerprint
|
// Stale check: re-fetch changes and compare content fingerprint
|
||||||
const state = get();
|
const state = get();
|
||||||
const current = state.activeChangeSet;
|
const current = state.activeChangeSet;
|
||||||
const fingerprint = (cs: {
|
// Fingerprint uses file count + file paths only (not line counts)
|
||||||
totalFiles: number;
|
// because line counts may be corrected by lazy-loaded content resolution
|
||||||
totalLinesAdded: number;
|
const fingerprint = (cs: { totalFiles: number; files: { filePath: string }[] }): string =>
|
||||||
totalLinesRemoved: number;
|
`${cs.totalFiles}:${cs.files.map((f) => f.filePath).join(',')}`;
|
||||||
files: { filePath: string }[];
|
|
||||||
}): string =>
|
|
||||||
`${cs.totalFiles}:${cs.totalLinesAdded}:${cs.totalLinesRemoved}:${cs.files.map((f) => f.filePath).join(',')}`;
|
|
||||||
|
|
||||||
if (memberName && current) {
|
if (memberName && current) {
|
||||||
const fresh = await api.review.getAgentChanges(teamName, memberName);
|
const fresh = await api.review.getAgentChanges(teamName, memberName);
|
||||||
|
|
|
||||||
37
src/renderer/utils/projectLookup.ts
Normal file
37
src/renderer/utils/projectLookup.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Project lookup utilities — resolve project IDs from filesystem paths.
|
||||||
|
*
|
||||||
|
* The projects list (`projects`) is only populated when the sidebar is in "flat"
|
||||||
|
* view mode, whereas `repositoryGroups` is populated in "grouped" mode.
|
||||||
|
* This helper checks both sources so team pages can always find the matching
|
||||||
|
* encoded project ID regardless of which data set is currently loaded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Project, RepositoryGroup } from '@renderer/types/data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an encoded project ID from a filesystem path.
|
||||||
|
*
|
||||||
|
* Lookup order:
|
||||||
|
* 1. `projects[]` — flat project list (populated in flat view mode)
|
||||||
|
* 2. `repositoryGroups[].worktrees[]` — worktree entries (populated in grouped view mode)
|
||||||
|
*
|
||||||
|
* @returns The encoded project directory name (e.g. `-Users-belief-dev-project`) or `null`.
|
||||||
|
*/
|
||||||
|
export function resolveProjectIdByPath(
|
||||||
|
projectPath: string | undefined | null,
|
||||||
|
projects: readonly Pick<Project, 'id' | 'path'>[],
|
||||||
|
repositoryGroups: readonly Pick<RepositoryGroup, 'worktrees'>[]
|
||||||
|
): string | null {
|
||||||
|
if (!projectPath) return null;
|
||||||
|
|
||||||
|
const fromProjects = projects.find((p) => p.path === projectPath);
|
||||||
|
if (fromProjects) return fromProjects.id;
|
||||||
|
|
||||||
|
for (const group of repositoryGroups) {
|
||||||
|
const worktree = group.worktrees.find((w) => w.path === projectPath);
|
||||||
|
if (worktree) return worktree.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -452,7 +452,8 @@ export interface ReviewAPI {
|
||||||
getFileContent: (
|
getFileContent: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string | undefined,
|
memberName: string | undefined,
|
||||||
filePath: string
|
filePath: string,
|
||||||
|
snippets?: SnippetDiff[]
|
||||||
) => Promise<FileChangeWithContent>;
|
) => Promise<FileChangeWithContent>;
|
||||||
applyDecisions: (request: ApplyReviewRequest) => Promise<ApplyReviewResult>;
|
applyDecisions: (request: ApplyReviewRequest) => Promise<ApplyReviewResult>;
|
||||||
// Phase 2
|
// Phase 2
|
||||||
|
|
|
||||||
292
test/renderer/utils/projectLookup.test.ts
Normal file
292
test/renderer/utils/projectLookup.test.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||||
|
|
||||||
|
import type { Project, RepositoryGroup } from '@renderer/types/data';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ProjectLike = Pick<Project, 'id' | 'path'>;
|
||||||
|
type RepoGroupLike = Pick<RepositoryGroup, 'worktrees'>;
|
||||||
|
|
||||||
|
const CRYPTO_PROJECT: ProjectLike = {
|
||||||
|
id: '-Users-belief-dev-projects-crypto-research',
|
||||||
|
path: '/Users/belief/dev/projects/crypto_research',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLAUDE_PROJECT: ProjectLike = {
|
||||||
|
id: '-Users-belief-dev-projects-claude-claude-team',
|
||||||
|
path: '/Users/belief/dev/projects/claude/claude_team',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeRepoGroup(worktrees: { id: string; path: string }[]): RepoGroupLike {
|
||||||
|
return {
|
||||||
|
worktrees: worktrees.map((w) => ({
|
||||||
|
...w,
|
||||||
|
name: w.id,
|
||||||
|
gitBranch: 'main',
|
||||||
|
isMainWorktree: true,
|
||||||
|
source: 'standalone' as const,
|
||||||
|
sessions: [],
|
||||||
|
createdAt: 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRYPTO_REPO_GROUP = makeRepoGroup([
|
||||||
|
{
|
||||||
|
id: '-Users-belief-dev-projects-crypto-research',
|
||||||
|
path: '/Users/belief/dev/projects/crypto_research',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CLAUDE_REPO_GROUP = makeRepoGroup([
|
||||||
|
{
|
||||||
|
id: '-Users-belief-dev-projects-claude-claude-team',
|
||||||
|
path: '/Users/belief/dev/projects/claude/claude_team',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MULTI_WORKTREE_GROUP = makeRepoGroup([
|
||||||
|
{
|
||||||
|
id: '-Users-belief-dev-projects-app',
|
||||||
|
path: '/Users/belief/dev/projects/app',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '-Users-belief-dev-projects-app-wt-feature',
|
||||||
|
path: '/Users/belief/dev/projects/app-wt-feature',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('resolveProjectIdByPath', () => {
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Null / undefined / empty input
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('null/undefined/empty projectPath', () => {
|
||||||
|
it('returns null for undefined projectPath', () => {
|
||||||
|
expect(resolveProjectIdByPath(undefined, [CRYPTO_PROJECT], [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for null projectPath', () => {
|
||||||
|
expect(resolveProjectIdByPath(null, [CRYPTO_PROJECT], [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty string projectPath', () => {
|
||||||
|
expect(resolveProjectIdByPath('', [CRYPTO_PROJECT], [])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Lookup from projects (flat view mode)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('lookup from projects (flat mode)', () => {
|
||||||
|
it('finds project by exact path match', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/crypto_research',
|
||||||
|
[CRYPTO_PROJECT, CLAUDE_PROJECT],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
).toBe('-Users-belief-dev-projects-crypto-research');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when path not in projects', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath('/Users/belief/dev/projects/unknown', [CRYPTO_PROJECT], [])
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when projects list is empty', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath('/Users/belief/dev/projects/crypto_research', [], [])
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not do substring matching', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/crypto_research/subdir',
|
||||||
|
[CRYPTO_PROJECT],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not do prefix matching', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath('/Users/belief/dev/projects/crypto', [CRYPTO_PROJECT], [])
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Lookup from repositoryGroups (grouped view mode)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('lookup from repositoryGroups (grouped mode)', () => {
|
||||||
|
it('finds project in worktrees when projects is empty', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/crypto_research',
|
||||||
|
[],
|
||||||
|
[CRYPTO_REPO_GROUP]
|
||||||
|
)
|
||||||
|
).toBe('-Users-belief-dev-projects-crypto-research');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds project across multiple repo groups', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/claude/claude_team',
|
||||||
|
[],
|
||||||
|
[CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP]
|
||||||
|
)
|
||||||
|
).toBe('-Users-belief-dev-projects-claude-claude-team');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds correct worktree in multi-worktree group', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/app-wt-feature',
|
||||||
|
[],
|
||||||
|
[MULTI_WORKTREE_GROUP]
|
||||||
|
)
|
||||||
|
).toBe('-Users-belief-dev-projects-app-wt-feature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when path not in any worktree', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath('/Users/belief/dev/projects/unknown', [], [CRYPTO_REPO_GROUP])
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when repositoryGroups is empty', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath('/Users/belief/dev/projects/crypto_research', [], [])
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Priority: projects takes precedence over repositoryGroups
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('priority order', () => {
|
||||||
|
it('prefers projects match over repositoryGroups match', () => {
|
||||||
|
const projectWithDifferentId: ProjectLike = {
|
||||||
|
id: 'flat-mode-id',
|
||||||
|
path: '/Users/belief/dev/projects/crypto_research',
|
||||||
|
};
|
||||||
|
|
||||||
|
const repoGroupWithDifferentId = makeRepoGroup([
|
||||||
|
{
|
||||||
|
id: 'grouped-mode-id',
|
||||||
|
path: '/Users/belief/dev/projects/crypto_research',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/crypto_research',
|
||||||
|
[projectWithDifferentId],
|
||||||
|
[repoGroupWithDifferentId]
|
||||||
|
)
|
||||||
|
).toBe('flat-mode-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to repositoryGroups when projects has no match', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/crypto_research',
|
||||||
|
[CLAUDE_PROJECT], // different project, no match
|
||||||
|
[CRYPTO_REPO_GROUP]
|
||||||
|
)
|
||||||
|
).toBe('-Users-belief-dev-projects-crypto-research');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Both sources populated (e.g. user switched view modes)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('both sources populated', () => {
|
||||||
|
it('resolves from projects even when same data in groups', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/crypto_research',
|
||||||
|
[CRYPTO_PROJECT],
|
||||||
|
[CRYPTO_REPO_GROUP]
|
||||||
|
)
|
||||||
|
).toBe('-Users-belief-dev-projects-crypto-research');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves path only in groups when projects has different entries', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/claude/claude_team',
|
||||||
|
[CRYPTO_PROJECT],
|
||||||
|
[CLAUDE_REPO_GROUP]
|
||||||
|
)
|
||||||
|
).toBe('-Users-belief-dev-projects-claude-claude-team');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Edge cases: path format variations
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('path format edge cases', () => {
|
||||||
|
it('does not normalize trailing slashes — exact match required', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/crypto_research/',
|
||||||
|
[CRYPTO_PROJECT],
|
||||||
|
[CRYPTO_REPO_GROUP]
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-sensitive', () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/Crypto_Research',
|
||||||
|
[CRYPTO_PROJECT],
|
||||||
|
[CRYPTO_REPO_GROUP]
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Windows-style paths if stored that way', () => {
|
||||||
|
const winProject: ProjectLike = {
|
||||||
|
id: 'C--Users-name-project',
|
||||||
|
path: 'C:\\Users\\name\\project',
|
||||||
|
};
|
||||||
|
expect(resolveProjectIdByPath('C:\\Users\\name\\project', [winProject], [])).toBe(
|
||||||
|
'C--Users-name-project'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Regression: the original bug scenario
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('regression: grouped view mode with no flat projects', () => {
|
||||||
|
it('resolves team projectPath when only repositoryGroups is populated', () => {
|
||||||
|
// This is the exact scenario that caused "Project not found":
|
||||||
|
// viewMode=grouped → fetchRepositoryGroups() is called, fetchProjects() is NOT
|
||||||
|
// → projects=[] but repositoryGroups has the data
|
||||||
|
const emptyProjects: ProjectLike[] = [];
|
||||||
|
const populatedGroups: RepoGroupLike[] = [CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveProjectIdByPath(
|
||||||
|
'/Users/belief/dev/projects/crypto_research',
|
||||||
|
emptyProjects,
|
||||||
|
populatedGroups
|
||||||
|
)
|
||||||
|
).toBe('-Users-belief-dev-projects-crypto-research');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue