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,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
|
|
@ -1855,100 +1824,11 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346
|
||||
},
|
||||
"fast/claude-opus-4-6": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
"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
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"provider_specific_entry": {
|
||||
"us": 1.1,
|
||||
"fast": 6
|
||||
}
|
||||
},
|
||||
"claude-opus-4-6-20260205": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -1979,69 +1859,11 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346
|
||||
},
|
||||
"fast/claude-opus-4-6-20260205": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
"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
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"provider_specific_entry": {
|
||||
"us": 1.1,
|
||||
"fast": 6
|
||||
}
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
"deprecation_date": "2026-05-14",
|
||||
|
|
|
|||
|
|
@ -241,10 +241,11 @@ async function handleGetFileContent(
|
|||
_event: IpcMainInvokeEvent,
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
filePath: string
|
||||
filePath: string,
|
||||
snippets: SnippetDiff[] = []
|
||||
): Promise<IpcResult<FileChangeWithContent>> {
|
||||
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 { diffLines } from 'diff';
|
||||
import { createReadStream } from 'fs';
|
||||
import { access, readFile } from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
|
@ -28,7 +29,7 @@ interface ContentCacheEntry {
|
|||
*/
|
||||
export class FileContentResolver {
|
||||
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(
|
||||
private readonly logsFinder: TeamMemberLogsFinder,
|
||||
|
|
@ -123,16 +124,37 @@ export class FileContentResolver {
|
|||
async getFileContent(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
filePath: string
|
||||
filePath: string,
|
||||
snippets: SnippetDiff[] = []
|
||||
): 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 {
|
||||
filePath,
|
||||
relativePath: filePath.split('/').slice(-3).join('/'),
|
||||
snippets: [],
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
isNewFile: false,
|
||||
snippets,
|
||||
linesAdded,
|
||||
linesRemoved,
|
||||
isNewFile,
|
||||
originalFullContent: resolved.original,
|
||||
modifiedFullContent: resolved.modified,
|
||||
contentSource: resolved.source,
|
||||
|
|
@ -165,12 +187,25 @@ export class FileContentResolver {
|
|||
file.filePath,
|
||||
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 = {
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
snippets: file.snippets,
|
||||
linesAdded: file.linesAdded,
|
||||
linesRemoved: file.linesRemoved,
|
||||
linesAdded,
|
||||
linesRemoved,
|
||||
isNewFile: file.isNewFile,
|
||||
originalFullContent: resolved.original,
|
||||
modifiedFullContent: resolved.modified,
|
||||
|
|
@ -352,6 +387,9 @@ export class FileContentResolver {
|
|||
|
||||
case 'edit':
|
||||
case 'multi-edit': {
|
||||
// Guard: empty newString means deletion — can't find position to reverse
|
||||
if (!snippet.newString) return null;
|
||||
|
||||
if (snippet.replaceAll) {
|
||||
// Reverse replaceAll: replace all occurrences of newString -> oldString
|
||||
if (!content.includes(snippet.newString)) {
|
||||
|
|
@ -453,7 +491,7 @@ export class FileContentResolver {
|
|||
original: result.original,
|
||||
modified: result.modified,
|
||||
source: result.source,
|
||||
expiresAt: Date.now() + this.CACHE_TTL,
|
||||
expiresAt: Date.now() + this.cacheTtl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import {
|
|||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
} 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 { createLogger } from '@shared/utils/logger';
|
||||
import { execFile, spawn } from 'child_process';
|
||||
|
|
@ -351,7 +355,8 @@ ${AGENT_BLOCK_OPEN}
|
|||
(internal instructions: commands, script usage, paths, etc.)
|
||||
${AGENT_BLOCK_CLOSE}
|
||||
- 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 {
|
||||
|
|
@ -1466,6 +1471,7 @@ export class TeamProvisioningService {
|
|||
`You have new inbox messages addressed to you (team lead "${leadName}").`,
|
||||
`Process them in order (oldest first).`,
|
||||
`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,
|
||||
`Internal note: for task assignments, prefer teamctl.js task create --notify (avoid sending a separate SendMessage for the same assignment).`,
|
||||
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, {
|
||||
from: leadName,
|
||||
to: 'user',
|
||||
text: replyText,
|
||||
text: cleanReply,
|
||||
timestamp: nowIso(),
|
||||
read: true,
|
||||
summary: 'Lead reply',
|
||||
summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply,
|
||||
messageId: `lead-process-${runId}-${Date.now()}`,
|
||||
source: 'lead_process',
|
||||
});
|
||||
|
|
@ -1867,11 +1876,13 @@ export class TeamProvisioningService {
|
|||
capture.resolveOnce(combined);
|
||||
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
|
||||
// Flush accumulated assistant reply from direct user→lead message
|
||||
const replyText = run.directReplyParts.join('').trim();
|
||||
const rawReply = run.directReplyParts.join('').trim();
|
||||
run.directReplyParts = [];
|
||||
const leadName =
|
||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||
'team-lead';
|
||||
// Strip agent-only blocks — lead may include coordination content not meant for the user
|
||||
const replyText = stripAgentBlocks(rawReply);
|
||||
if (replyText.length > 0) {
|
||||
const replyMsg: InboxMessage = {
|
||||
from: leadName,
|
||||
|
|
|
|||
|
|
@ -735,12 +735,18 @@ const electronAPI: ElectronAPI = {
|
|||
getChangeStats: async (teamName: string, memberName: string) => {
|
||||
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>(
|
||||
REVIEW_GET_FILE_CONTENT,
|
||||
teamName,
|
||||
memberName ?? '',
|
||||
filePath
|
||||
filePath,
|
||||
snippets
|
||||
);
|
||||
},
|
||||
applyDecisions: async (request: ApplyReviewRequest) => {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import type {
|
|||
SessionMetrics,
|
||||
SessionsByIdsOptions,
|
||||
SessionsPaginationOptions,
|
||||
SnippetDiff,
|
||||
SshAPI,
|
||||
SshConfigHostEntry,
|
||||
SshConnectionConfig,
|
||||
|
|
@ -809,7 +810,8 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getFileContent: async (
|
||||
_teamName: string,
|
||||
_memberName: string | undefined,
|
||||
_filePath: string
|
||||
_filePath: string,
|
||||
_snippets: SnippetDiff[] = []
|
||||
): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { TeamTabSectionNav } from './TeamTabSectionNav';
|
||||
|
||||
import type { Tab } from '@renderer/types/tabs';
|
||||
|
||||
interface SortableTabProps {
|
||||
|
|
@ -98,6 +100,8 @@ export const SortableTab = ({
|
|||
[setNodeRef, setRef, tab.id]
|
||||
);
|
||||
|
||||
const isTeamTab = tab.type === 'team' && tab.teamName;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={handleRef}
|
||||
|
|
@ -108,7 +112,11 @@ export const SortableTab = ({
|
|||
role="tab"
|
||||
tabIndex={0}
|
||||
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}
|
||||
onClick={(e) => onTabClick(tab.id, e)}
|
||||
onMouseDown={(e) => onMouseDown(tab.id, e)}
|
||||
|
|
@ -122,30 +130,45 @@ export const SortableTab = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{tab.fromSearch && (
|
||||
<span title="Opened from search">
|
||||
<Search className="size-3 shrink-0 text-amber-400" />
|
||||
</span>
|
||||
<div className={isTeamTab ? 'flex min-w-0 items-center gap-2 px-3 pb-0.5 pt-1' : 'contents'}>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{tab.fromSearch && (
|
||||
<span title="Opened from search">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
|
@ -107,14 +108,21 @@ export const SidebarTaskItem = ({
|
|||
onClick={() => openGlobalTaskDetail(task.teamName, task.id)}
|
||||
>
|
||||
{/* Row 1: status + subject */}
|
||||
<div className="flex w-full items-center gap-1.5 overflow-hidden">
|
||||
<StatusIcon className={`size-3 shrink-0 ${cfg.color}`} />
|
||||
<span
|
||||
className="truncate text-[13px] font-medium leading-tight"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{task.subject}
|
||||
</span>
|
||||
<div className="flex w-full items-start gap-1.5 overflow-hidden">
|
||||
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="line-clamp-2 text-[13px] font-medium leading-tight"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{task.subject}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{task.subject}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
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 { ChevronRight } from 'lucide-react';
|
||||
|
||||
function scrollAfterExpand(el: HTMLElement): void {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface CollapsibleTeamSectionProps {
|
||||
title: string;
|
||||
/** Icon rendered before the title text. */
|
||||
|
|
@ -15,6 +23,8 @@ interface CollapsibleTeamSectionProps {
|
|||
defaultOpen?: boolean;
|
||||
forceOpen?: boolean;
|
||||
action?: React.ReactNode;
|
||||
/** Stable identifier used for programmatic section navigation. */
|
||||
sectionId?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -27,13 +37,31 @@ export const CollapsibleTeamSection = ({
|
|||
defaultOpen = true,
|
||||
forceOpen,
|
||||
action,
|
||||
sectionId,
|
||||
children,
|
||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
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 (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -19,22 +19,27 @@ import { useStore } from '@renderer/store';
|
|||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckCheck,
|
||||
Columns3,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
History,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Square,
|
||||
Terminal,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -153,6 +158,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
loading,
|
||||
error,
|
||||
projects,
|
||||
repositoryGroups,
|
||||
teams,
|
||||
selectTeam,
|
||||
updateKanban,
|
||||
|
|
@ -189,6 +195,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
loading: s.selectedTeamLoading,
|
||||
error: s.selectedTeamError,
|
||||
projects: s.projects,
|
||||
repositoryGroups: s.repositoryGroups,
|
||||
teams: s.teams,
|
||||
selectTeam: s.selectTeam,
|
||||
updateKanban: s.updateKanban,
|
||||
|
|
@ -274,10 +281,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
}, [kanbanFilterQuery, clearKanbanFilter]);
|
||||
|
||||
// Load sessions for the team's project
|
||||
const projectId = useMemo(() => {
|
||||
if (!data?.config.projectPath) return null;
|
||||
return projects.find((p) => p.path === data.config.projectPath)?.id ?? null;
|
||||
}, [projects, data?.config.projectPath]);
|
||||
const projectId = useMemo(
|
||||
() => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups),
|
||||
[projects, repositoryGroups, data?.config.projectPath]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
|
@ -647,7 +654,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
: nameColorSet(data.config.name);
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-auto p-4">
|
||||
<div className="size-full overflow-auto p-4" data-team-name={teamName}>
|
||||
<div
|
||||
className="relative mb-3 overflow-hidden rounded-lg border border-[var(--color-border)] px-4 py-3"
|
||||
style={
|
||||
|
|
@ -820,7 +827,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
) : null}
|
||||
|
||||
<CollapsibleTeamSection
|
||||
sectionId="team"
|
||||
title="Team"
|
||||
icon={<Users size={14} />}
|
||||
badge={activeMembers.length}
|
||||
defaultOpen
|
||||
action={
|
||||
|
|
@ -859,7 +868,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
<CollapsibleTeamSection title="Sessions" defaultOpen={false}>
|
||||
<CollapsibleTeamSection
|
||||
sectionId="sessions"
|
||||
title="Sessions"
|
||||
icon={<History size={14} />}
|
||||
defaultOpen={false}
|
||||
>
|
||||
<TeamSessionsSection
|
||||
sessions={teamSessions}
|
||||
sessionsLoading={sessionsLoading}
|
||||
|
|
@ -872,7 +886,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
</CollapsibleTeamSection>
|
||||
|
||||
<CollapsibleTeamSection
|
||||
sectionId="kanban"
|
||||
title="Kanban"
|
||||
icon={<Columns3 size={14} />}
|
||||
badge={filteredTasks.length}
|
||||
defaultOpen
|
||||
forceOpen={kanbanSearch.trim().length > 0}
|
||||
|
|
@ -1037,7 +1053,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
|
||||
{(data.processes?.length ?? 0) > 0 && (
|
||||
<CollapsibleTeamSection
|
||||
sectionId="processes"
|
||||
title="CLI Processes"
|
||||
icon={<Terminal size={14} />}
|
||||
badge={data.processes.filter((p) => !p.stoppedAt).length}
|
||||
defaultOpen
|
||||
>
|
||||
|
|
@ -1046,7 +1064,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
)}
|
||||
|
||||
<CollapsibleTeamSection
|
||||
sectionId="messages"
|
||||
title="Messages"
|
||||
icon={<MessageSquare size={14} />}
|
||||
badge={filteredMessages.length}
|
||||
secondaryBadge={
|
||||
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 { useStore } from '@renderer/store';
|
||||
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import {
|
||||
AlertCircle,
|
||||
|
|
@ -36,18 +37,19 @@ export const TeamSessionsSection = ({
|
|||
onSelectSession,
|
||||
projectPath,
|
||||
}: TeamSessionsSectionProps): React.JSX.Element => {
|
||||
const { openTab, selectSession, projects } = useStore(
|
||||
const { openTab, selectSession, projects, repositoryGroups } = useStore(
|
||||
useShallow((s) => ({
|
||||
openTab: s.openTab,
|
||||
selectSession: s.selectSession,
|
||||
projects: s.projects,
|
||||
repositoryGroups: s.repositoryGroups,
|
||||
}))
|
||||
);
|
||||
|
||||
const projectId = useMemo(() => {
|
||||
if (!projectPath) return null;
|
||||
return projects.find((p) => p.path === projectPath)?.id ?? null;
|
||||
}, [projects, projectPath]);
|
||||
const projectId = useMemo(
|
||||
() => resolveProjectIdByPath(projectPath, projects, repositoryGroups),
|
||||
[projects, repositoryGroups, projectPath]
|
||||
);
|
||||
|
||||
// Sort: lead session first, then by most recent
|
||||
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';
|
||||
|
||||
|
|
@ -25,6 +25,7 @@ interface ActivityTimelineProps {
|
|||
}
|
||||
|
||||
const VIEWPORT_THRESHOLD = 0.15;
|
||||
const MESSAGES_PAGE_SIZE = 30;
|
||||
|
||||
const MessageRowWithObserver = ({
|
||||
message,
|
||||
|
|
@ -110,6 +111,13 @@ export const ActivityTimeline = ({
|
|||
onMessageVisible,
|
||||
onTaskIdClick,
|
||||
}: 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 memberInfo = new Map<string, { role?: string; color?: string }>();
|
||||
if (members) {
|
||||
|
|
@ -140,6 +148,28 @@ export const ActivityTimeline = ({
|
|||
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) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="space-y-1">
|
||||
{messages.slice(0, 200).map((message, index) => {
|
||||
{visibleMessages.map((message, index) => {
|
||||
const info = memberInfo.get(message.from);
|
||||
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
||||
const recipientColor =
|
||||
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
|
||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||
: !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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { api } from '@renderer/api';
|
|||
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Combobox } from '@renderer/components/ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -28,9 +27,10 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
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 { ProjectPathSelector } from './ProjectPathSelector';
|
||||
|
||||
const TEAM_COLOR_NAMES = [
|
||||
'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'] {
|
||||
return members
|
||||
.map((member) => {
|
||||
|
|
@ -845,120 +812,18 @@ export const CreateTeamDialog = ({
|
|||
|
||||
{launchTeam ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
<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={() => 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>
|
||||
<ProjectPathSelector
|
||||
cwdMode={cwdMode}
|
||||
onCwdModeChange={setCwdMode}
|
||||
selectedProjectPath={selectedProjectPath}
|
||||
onSelectedProjectPathChange={setSelectedProjectPath}
|
||||
customCwd={customCwd}
|
||||
onCustomCwdChange={setCustomCwd}
|
||||
projects={projects}
|
||||
projectsLoading={projectsLoading}
|
||||
projectsError={projectsError}
|
||||
fieldError={fieldErrors.cwd}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="team-prompt" className="label-optional">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Combobox } from '@renderer/components/ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -11,7 +10,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import {
|
||||
|
|
@ -22,12 +20,13 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
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 { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
|
@ -49,39 +48,6 @@ interface LaunchTeamDialogProps {
|
|||
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 = ({
|
||||
open,
|
||||
teamName,
|
||||
|
|
@ -356,110 +322,21 @@ export const LaunchTeamDialog = ({
|
|||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={cwdMode === 'project' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setCwdMode('project')}
|
||||
>
|
||||
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>
|
||||
<ProjectPathSelector
|
||||
cwdMode={cwdMode}
|
||||
onCwdModeChange={setCwdMode}
|
||||
selectedProjectPath={selectedProjectPath}
|
||||
onSelectedProjectPathChange={setSelectedProjectPath}
|
||||
customCwd={customCwd}
|
||||
onCustomCwdChange={setCustomCwd}
|
||||
projects={projects}
|
||||
projectsLoading={projectsLoading}
|
||||
projectsError={projectsError}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="launch-prompt" className="label-optional text-xs">
|
||||
Prompt (optional)
|
||||
<Label htmlFor="launch-prompt" className="label-optional">
|
||||
Prompt for team lead (optional)
|
||||
</Label>
|
||||
<MentionableTextarea
|
||||
id="launch-prompt"
|
||||
|
|
@ -479,7 +356,7 @@ export const LaunchTeamDialog = ({
|
|||
</div>
|
||||
|
||||
<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}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<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]
|
||||
);
|
||||
|
||||
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 => {
|
||||
if (!canSend) return;
|
||||
const rawText = textDraft.value.trim();
|
||||
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();
|
||||
};
|
||||
|
||||
|
|
@ -172,18 +176,6 @@ export const SendMessageDialog = ({
|
|||
</Select>
|
||||
</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 ? (
|
||||
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
|
||||
<Tooltip>
|
||||
|
|
@ -225,6 +217,20 @@ export const SendMessageDialog = ({
|
|||
/>
|
||||
</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}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -90,9 +90,8 @@ export const MessageComposer = ({
|
|||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!canSend) return;
|
||||
const autoSummary = trimmed.length > 60 ? trimmed.slice(0, 57) + '...' : trimmed;
|
||||
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]);
|
||||
|
||||
// Clear draft only after send completes successfully (sending: true → false, no error)
|
||||
|
|
|
|||
|
|
@ -355,11 +355,41 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
}));
|
||||
|
||||
try {
|
||||
const content = await api.review.getFileContent(teamName, memberName, filePath);
|
||||
set((s) => ({
|
||||
fileContents: { ...s.fileContents, [filePath]: content },
|
||||
fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false },
|
||||
}));
|
||||
// Lookup snippets from activeChangeSet so backend can use them for reconstruction
|
||||
const activeChangeSet = get().activeChangeSet;
|
||||
const fileEntry = activeChangeSet?.files.find((f) => f.filePath === filePath);
|
||||
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) {
|
||||
logger.error('fetchFileContent error:', error);
|
||||
set((s) => ({
|
||||
|
|
@ -375,13 +405,10 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
// Stale check: re-fetch changes and compare content fingerprint
|
||||
const state = get();
|
||||
const current = state.activeChangeSet;
|
||||
const fingerprint = (cs: {
|
||||
totalFiles: number;
|
||||
totalLinesAdded: number;
|
||||
totalLinesRemoved: number;
|
||||
files: { filePath: string }[];
|
||||
}): string =>
|
||||
`${cs.totalFiles}:${cs.totalLinesAdded}:${cs.totalLinesRemoved}:${cs.files.map((f) => f.filePath).join(',')}`;
|
||||
// Fingerprint uses file count + file paths only (not line counts)
|
||||
// because line counts may be corrected by lazy-loaded content resolution
|
||||
const fingerprint = (cs: { totalFiles: number; files: { filePath: string }[] }): string =>
|
||||
`${cs.totalFiles}:${cs.files.map((f) => f.filePath).join(',')}`;
|
||||
|
||||
if (memberName && current) {
|
||||
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: (
|
||||
teamName: string,
|
||||
memberName: string | undefined,
|
||||
filePath: string
|
||||
filePath: string,
|
||||
snippets?: SnippetDiff[]
|
||||
) => Promise<FileChangeWithContent>;
|
||||
applyDecisions: (request: ApplyReviewRequest) => Promise<ApplyReviewResult>;
|
||||
// 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