diff --git a/resources/pricing.json b/resources/pricing.json index 9687baaa..077d4681 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -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", diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 8f6cf2d5..3d5369d6 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -241,10 +241,11 @@ async function handleGetFileContent( _event: IpcMainInvokeEvent, teamName: string, memberName: string, - filePath: string + filePath: string, + snippets: SnippetDiff[] = [] ): Promise> { return wrapReviewHandler('getFileContent', () => - getContentResolver().getFileContent(teamName, memberName, filePath) + getContentResolver().getFileContent(teamName, memberName, filePath, snippets) ); } diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index 52d6bb24..5aad565b 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -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(); - 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 { - 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, }); } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f947502c..0cdf0c49 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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, diff --git a/src/preload/index.ts b/src/preload/index.ts index 0d1e0822..7dba7904 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -735,12 +735,18 @@ const electronAPI: ElectronAPI = { getChangeStats: async (teamName: string, memberName: string) => { return invokeIpcWithResult(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( REVIEW_GET_FILE_CONTENT, teamName, memberName ?? '', - filePath + filePath, + snippets ); }, applyDecisions: async (request: ApplyReviewRequest) => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index c3c728ce..69ef71e3 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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 => { throw new Error('Review is not available in browser mode'); }, diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index c645bb28..1bc46266 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -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 (
onTabClick(tab.id, e)} onMouseDown={(e) => onMouseDown(tab.id, e)} @@ -122,30 +130,45 @@ export const SortableTab = ({ } }} > - - {tab.fromSearch && ( - - - +
+ + {tab.fromSearch && ( + + + + )} + {isPinned && ( + + + + )} + {tab.label} + +
+ {isTeamTab && ( + { + setIsHovered(false); + onTabClick(tab.id, { + metaKey: false, + ctrlKey: false, + shiftKey: false, + } as React.MouseEvent); + }} + /> )} - {isPinned && ( - - - - )} - {tab.label} -
); }; diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx new file mode 100644 index 00000000..17e33035 --- /dev/null +++ b/src/renderer/components/layout/TeamTabSectionNav.tsx @@ -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(null); + const buttonRef = useRef(null); + const menuRef = useRef(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 ( +
e.stopPropagation()}> + + {open && + createPortal( +
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 ( + + ); + })} +
, + document.body + )} +
+ ); +}; diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index e76e6451..d3fdce0c 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -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 */} -
- - - {task.subject} - +
+ + + + + {task.subject} + + + + {task.subject} + + {unreadCount > 0 && ( { + 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(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 ( -
+
+ {hiddenCount > MESSAGES_PAGE_SIZE && ( + + )} +
+ )}
); }; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 6eeb6529..6a8d65fa 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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 { }; } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function renderHighlightedText(text: string, query: string): React.JSX.Element { - if (!query.trim()) { - return {text}; - } - - const pattern = new RegExp(`(${escapeRegExp(query)})`, 'ig'); - const parts = text.split(pattern); - - return ( - - {parts.map((part, index) => { - const isMatch = part.toLowerCase() === query.toLowerCase(); - if (!isMatch) { - return {part}; - } - return ( - - {part} - - ); - })} - - ); -} - function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] { return members .map((member) => { @@ -845,120 +812,18 @@ export const CreateTeamDialog = ({ {launchTeam ? (
-
- -
-
- - -
- - {cwdMode === '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) => ( - <> - -
-

- {renderHighlightedText(option.label, query)} -

-

- {renderHighlightedText(option.description ?? '', query)} -

-
- - )} - /> - {!selectedProjectPath ? ( -

- Select a project from the list -

- ) : null} - {projectsError ? ( -

{projectsError}

- ) : null} - {!projectsLoading && projects.length === 0 ? ( -

- No projects found, switch to custom path. -

- ) : null} -
- ) : ( -
-
- setCustomCwd(event.target.value)} - placeholder="/absolute/path/to/project" - /> - -
-

- If the directory does not exist, it will be created automatically. -

-
- )} -
- {fieldErrors.cwd ? ( -

{fieldErrors.cwd}

- ) : null} -
+
+); diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index a0754b06..8edb8a9a 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -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 = ({
-
- - setSummary(e.target.value)} - /> -
- {quote ? (
@@ -225,6 +217,20 @@ export const SendMessageDialog = ({ />
+
+ + setSummary(e.target.value)} + /> +

+ Shown as notification preview. Team lead also sees this for peer messages. +

+
+ {sendError ?

{sendError}

: null}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 68e6a32c..4dcdc5cd 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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) diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 3eadb5b9..958955e9 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -355,11 +355,41 @@ export const createChangeReviewSlice: StateCreator ({ - 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 = { + 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 - `${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); diff --git a/src/renderer/utils/projectLookup.ts b/src/renderer/utils/projectLookup.ts new file mode 100644 index 00000000..e641024c --- /dev/null +++ b/src/renderer/utils/projectLookup.ts @@ -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[], + repositoryGroups: readonly Pick[] +): 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; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 94546184..15d04fd5 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -452,7 +452,8 @@ export interface ReviewAPI { getFileContent: ( teamName: string, memberName: string | undefined, - filePath: string + filePath: string, + snippets?: SnippetDiff[] ) => Promise; applyDecisions: (request: ApplyReviewRequest) => Promise; // Phase 2 diff --git a/test/renderer/utils/projectLookup.test.ts b/test/renderer/utils/projectLookup.test.ts new file mode 100644 index 00000000..c02e1057 --- /dev/null +++ b/test/renderer/utils/projectLookup.test.ts @@ -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; +type RepoGroupLike = Pick; + +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'); + }); + }); +});