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:
iliya 2026-02-26 14:30:09 +02:00
parent 50efe267e4
commit 321673ff6d
22 changed files with 1000 additions and 575 deletions

View file

@ -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",

View file

@ -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)
);
}

View file

@ -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,
});
}
}

View file

@ -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,

View file

@ -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) => {

View file

@ -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');
},

View file

@ -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>
);
};

View 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>
);
};

View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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(() => {

View file

@ -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>
);
};

View file

@ -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">

View file

@ -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)" />

View 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>
);

View file

@ -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>

View file

@ -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)

View file

@ -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);

View 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;
}

View file

@ -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

View 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');
});
});
});