Merge pull request #12 from 777genius/improvements

Improvements
This commit is contained in:
Илия 2026-03-02 22:40:09 +02:00 committed by GitHub
commit d946531871
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
154 changed files with 12669 additions and 1569 deletions

View file

@ -19,6 +19,37 @@
<br />
## What is this
A new approach to task management with AI agents.
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
- **Live process dashboard** — see which agents are running processes in dedicated sections (frontend, backend, etc.) and open URLs directly in the browser
- **Stay in control** — send a direct message to any agent or drop a comment on a task whenever you want to clarify something or add new work
<details>
<summary><strong>More features</strong></summary>
<br />
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
- **Smart task-to-log matching** — automatically links Claude session logs to specific tasks based on status change timestamps, even when a task moves back and forth between states
- **Zero-setup onboarding** — built-in Claude Code installation and authentication, ready to go out of the box
- **Built-in code editor** — edit project files with Git support and other essential features without leaving the app
- **Branch strategy control** — choose via prompt whether all agents work on a single branch or each gets its own git worktree
- **Team member stats** — global performance statistics for every member of the team
- **Attach code context** — reference files or code snippets in your messages, just like in Cursor
- **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur
</details>
---
<!--
<p align="center">
<video src="https://github.com/user-attachments/assets/2b420b2c-c4af-4d10-a679-c83269f8ee99">

View file

@ -110,6 +110,7 @@
"cmdk": "1.0.4",
"date-fns": "^3.6.0",
"diff": "^8.0.3",
"dompurify": "^3.3.1",
"electron-updater": "^6.7.3",
"fastify": "^5.7.4",
"highlight.js": "^11.11.1",
@ -117,12 +118,14 @@
"isbinaryfile": "^6.0.0",
"lucide-react": "^0.562.0",
"mdast-util-to-hast": "^13.2.1",
"mermaid": "^11.12.3",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"simple-git": "^3.32.3",

File diff suppressed because it is too large Load diff

View file

@ -42,3 +42,6 @@ export const CLAUDE_WORKTREES_DIR = '.claude-worktrees';
/** ccswitch worktrees directory */
export const CCSWITCH_DIR = '.ccswitch';
/** Claude Code CLI worktrees directory (.claude/worktrees/) */
export const CLAUDE_CODE_DIR = '.claude';

View file

@ -17,6 +17,8 @@
* - POST /api/config/triggers/:triggerId/test - Test trigger
* - POST /api/config/pin-session - Pin session
* - POST /api/config/unpin-session - Unpin session
* - POST /api/config/add-custom-project-path - Add custom project path
* - POST /api/config/remove-custom-project-path - Remove custom project path
* - POST /api/config/select-folders - No-op in browser
* - POST /api/config/open-in-editor - No-op in browser
*/
@ -470,6 +472,44 @@ export function registerConfigRoutes(app: FastifyInstance): void {
}
);
// Add custom project path
app.post<{ Body: { projectPath: string } }>(
'/api/config/add-custom-project-path',
async (request): Promise<ConfigResult> => {
try {
const { projectPath } = request.body;
if (!projectPath || typeof projectPath !== 'string') {
return { success: false, error: 'Project path is required and must be a string' };
}
configManager.addCustomProjectPath(projectPath);
return { success: true };
} catch (error) {
logger.error('Error in POST /api/config/add-custom-project-path:', error);
return { success: false, error: getErrorMessage(error) };
}
}
);
// Remove custom project path
app.post<{ Body: { projectPath: string } }>(
'/api/config/remove-custom-project-path',
async (request): Promise<ConfigResult> => {
try {
const { projectPath } = request.body;
if (!projectPath || typeof projectPath !== 'string') {
return { success: false, error: 'Project path is required and must be a string' };
}
configManager.removeCustomProjectPath(projectPath);
return { success: true };
} catch (error) {
logger.error('Error in POST /api/config/remove-custom-project-path:', error);
return { success: false, error: getErrorMessage(error) };
}
}
);
// Select folders - no-op in browser mode
app.post('/api/config/select-folders', async (): Promise<ConfigResult<string[]>> => {
return { success: true, data: [] };

View file

@ -44,6 +44,7 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { showTeamNativeNotification } from './ipc/teams';
import { HttpServer } from './services/infrastructure/HttpServer';
import { TeamInboxReader } from './services/team/TeamInboxReader';
import { getAppIconPath } from './utils/appIcon';
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
import {
CliInstallerService,
@ -125,19 +126,89 @@ async function resolveTeamDisplayName(teamName: string): Promise<string> {
return resolved;
}
/**
* Inbox message types that are internal coordination noise not useful as OS notifications.
* Matches renderer-side NOISE_TYPES in agentMessageFormatting.ts.
*/
const INBOX_NOISE_TYPES = new Set([
'idle_notification',
'shutdown_approved',
'teammate_terminated',
'shutdown_request',
]);
/**
* Parses an inbox message text that may be serialized JSON.
* Returns null if not valid JSON or not an object.
*/
function parseInboxJson(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed.startsWith('{')) return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// not JSON — plain text message
}
return null;
}
/** Returns true if the inbox message text is a noise type that should not trigger an OS notification. */
function isInboxNoiseMessage(text: string): boolean {
const parsed = parseInboxJson(text);
if (!parsed) return false;
return typeof parsed.type === 'string' && INBOX_NOISE_TYPES.has(parsed.type);
}
/**
* Extracts human-readable summary and body from an inbox message.
* Handles both plain text and serialized JSON ({"type":"message","content":"...","summary":"..."}).
*/
function extractNotificationContent(text: string): { summary: string; body: string } {
const parsed = parseInboxJson(text);
if (!parsed) return { summary: text.slice(0, 80), body: text };
const content = typeof parsed.content === 'string' ? parsed.content : null;
const summary = typeof parsed.summary === 'string' ? parsed.summary : null;
const message = typeof parsed.message === 'string' ? parsed.message : null;
const bestBody = content || message || summary || text;
const bestSummary =
summary || (content ? content.slice(0, 80) : null) || message || text.slice(0, 80);
return { summary: bestSummary, body: bestBody };
}
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
// Check global toggle
const config = configManager.getConfig();
if (!config.notifications.enabled) return;
// detail is like "inboxes/carol.json" — extract member name
const match = /^inboxes\/(.+)\.json$/.exec(detail);
if (!match) return;
const memberName = match[1];
// Determine inbox type and check per-inbox toggle
const leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null;
const isLeadInbox = leadName !== null && memberName === leadName;
const isUserInbox = memberName === 'user';
if (isLeadInbox && !config.notifications.notifyOnLeadInbox) return;
if (isUserInbox && !config.notifications.notifyOnUserInbox) return;
if (!isLeadInbox && !isUserInbox) return;
const key = `${teamName}:${memberName}`;
try {
const messages = await teamInboxReader.getMessagesFor(teamName, memberName);
const isFirstLoad = !inboxMessageCounts.has(key);
const prevCount = inboxMessageCounts.get(key) ?? 0;
if (prevCount === 0) {
// First load — seed count, don't notify
if (isFirstLoad) {
// First load — seed count, don't notify for pre-existing messages
inboxMessageCounts.set(key, messages.length);
return;
}
@ -154,18 +225,19 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
const teamDisplayName = await resolveTeamDisplayName(teamName);
for (const msg of newMessages) {
// Only notify for messages addressed to the human user
if (msg.to !== 'user') continue;
// Skip messages sent from our own UI
if (msg.source && suppressedSources.has(msg.source)) continue;
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
if (isInboxNoiseMessage(msg.text)) continue;
const fromLabel = msg.from || 'Unknown';
const summary = msg.summary || msg.text.slice(0, 60);
const extracted = extractNotificationContent(msg.text);
const summary = msg.summary || extracted.summary;
showTeamNativeNotification({
title: teamDisplayName,
subtitle: `${fromLabel}: ${summary}`,
body: msg.text,
body: extracted.body,
});
}
} catch (error) {
@ -173,24 +245,6 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
}
}
// Window icon path for non-mac platforms.
const getWindowIconPath = (): string | undefined => {
const isDev = process.env.NODE_ENV === 'development';
const candidates = isDev
? [join(process.cwd(), 'resources/icon.png')]
: [
join(process.resourcesPath, 'resources/icon.png'),
join(__dirname, '../../resources/icon.png'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return undefined;
};
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled promise rejection in main process:', reason);
});
@ -349,21 +403,25 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// Show native OS notification for live lead process replies.
// These don't go through inbox files — they're held in-memory by TeamProvisioningService.
if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') {
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
// Only notify for messages addressed to the human user
if (latest?.to === 'user') {
const fromLabel = latest.from || 'team-lead';
const summary = latest.summary || latest.text.slice(0, 60);
void resolveTeamDisplayName(teamName)
.then((displayName) => {
showTeamNativeNotification({
title: displayName,
subtitle: `${fromLabel}: ${summary}`,
body: latest.text,
});
})
.catch(() => undefined);
const cfg = configManager.getConfig();
if (cfg.notifications.enabled && cfg.notifications.notifyOnUserInbox) {
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
// Only notify for messages addressed to the human user, skip noise
if (latest?.to === 'user' && !isInboxNoiseMessage(latest.text)) {
const fromLabel = latest.from || 'team-lead';
const extracted = extractNotificationContent(latest.text);
const summary = latest.summary || extracted.summary;
void resolveTeamDisplayName(teamName)
.then((displayName) => {
showTeamNativeNotification({
title: displayName,
subtitle: `${fromLabel}: ${summary}`,
body: extracted.body,
});
})
.catch(() => undefined);
}
}
}
} catch {
@ -690,7 +748,7 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
*/
function createWindow(): void {
const isMac = process.platform === 'darwin';
const iconPath = isMac ? undefined : getWindowIconPath();
const iconPath = isMac ? undefined : getAppIconPath();
const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar;
mainWindow = new BrowserWindow({
width: DEFAULT_WINDOW_WIDTH,

View file

@ -114,6 +114,10 @@ export function registerConfigHandlers(ipcMain: IpcMain): void {
ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo);
ipcMain.handle('config:findWslClaudeRoots', handleFindWslClaudeRoots);
// Custom project path handlers
ipcMain.handle('config:addCustomProjectPath', handleAddCustomProjectPath);
ipcMain.handle('config:removeCustomProjectPath', handleRemoveCustomProjectPath);
// Editor handlers
ipcMain.handle('config:openInEditor', handleOpenInEditor);
@ -624,6 +628,46 @@ async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<IpcResult
}
}
/**
* Handler for 'config:addCustomProjectPath' - Persists a custom project path.
*/
async function handleAddCustomProjectPath(
_event: IpcMainInvokeEvent,
projectPath: string
): Promise<IpcResult> {
try {
if (!projectPath || typeof projectPath !== 'string') {
return { success: false, error: 'Project path is required and must be a string' };
}
configManager.addCustomProjectPath(projectPath);
return { success: true };
} catch (error) {
logger.error('Error in config:addCustomProjectPath:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:removeCustomProjectPath' - Removes a custom project path.
*/
async function handleRemoveCustomProjectPath(
_event: IpcMainInvokeEvent,
projectPath: string
): Promise<IpcResult> {
try {
if (!projectPath || typeof projectPath !== 'string') {
return { success: false, error: 'Project path is required and must be a string' };
}
configManager.removeCustomProjectPath(projectPath);
return { success: true };
} catch (error) {
logger.error('Error in config:removeCustomProjectPath:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
* Allows users to select one or more folders for trigger project scope.
@ -1091,6 +1135,8 @@ export function removeConfigHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('config:unhideSession');
ipcMain.removeHandler('config:hideSessions');
ipcMain.removeHandler('config:unhideSessions');
ipcMain.removeHandler('config:addCustomProjectPath');
ipcMain.removeHandler('config:removeCustomProjectPath');
ipcMain.removeHandler('config:selectFolders');
ipcMain.removeHandler('config:selectClaudeRootFolder');
ipcMain.removeHandler('config:getClaudeRootInfo');

View file

@ -17,11 +17,16 @@ import {
EDITOR_LIST_FILES,
EDITOR_MOVE_FILE,
EDITOR_OPEN,
EDITOR_READ_BINARY_PREVIEW,
EDITOR_READ_DIR,
EDITOR_READ_FILE,
EDITOR_RENAME_FILE,
EDITOR_SEARCH_IN_FILES,
EDITOR_SET_WATCHED_DIRS,
EDITOR_SET_WATCHED_FILES,
EDITOR_WATCH_DIR,
EDITOR_WRITE_FILE,
PROJECT_LIST_FILES,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
@ -40,6 +45,7 @@ import {
import { createIpcWrapper } from './ipcWrapper';
import type {
BinaryPreviewResult,
CreateDirResponse,
CreateFileResponse,
DeleteFileResponse,
@ -244,6 +250,20 @@ async function handleEditorMoveFile(
});
}
/**
* Rename a file or directory in place.
*/
async function handleEditorRenameFile(
_event: IpcMainInvokeEvent,
sourcePath: string,
newName: string
): Promise<IpcResult<MoveFileResponse>> {
return wrapHandler('renameFile', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
return projectFileService.renameFile(activeProjectRoot, sourcePath, newName);
});
}
/**
* Search in files (literal string search, SEC-8 timeout).
*/
@ -282,6 +302,24 @@ async function handleEditorListFiles(): Promise<IpcResult<QuickOpenFile[]>> {
});
}
/**
* List project files by explicit path (for @file mentions).
* Independent of editor state works without editor:open.
*/
async function handleProjectListFiles(
_event: IpcMainInvokeEvent,
projectPath: string
): Promise<IpcResult<QuickOpenFile[]>> {
return wrapHandler('project:listFiles', async () => {
if (typeof projectPath !== 'string' || projectPath.length === 0) {
throw new Error('projectPath is required');
}
const normalized = path.resolve(projectPath);
await fs.access(normalized);
return fileSearchService.listFiles(normalized);
});
}
/**
* Get git status for current project (cached 5s).
*/
@ -292,6 +330,19 @@ async function handleEditorGitStatus(): Promise<IpcResult<GitStatusResult>> {
});
}
/**
* Read binary file as base64 for inline preview.
*/
async function handleEditorReadBinaryPreview(
_event: IpcMainInvokeEvent,
filePath: string
): Promise<IpcResult<BinaryPreviewResult>> {
return wrapHandler('readBinaryPreview', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
return projectFileService.readBinaryPreview(activeProjectRoot, filePath);
});
}
/**
* Enable/disable file watcher for current project.
*/
@ -304,8 +355,13 @@ async function handleEditorWatchDir(
if (enable) {
editorFileWatcher.start(activeProjectRoot, (event) => {
// Invalidate git cache on file changes
gitStatusService.invalidateCache();
// Structural changes (create/delete): immediate invalidation.
// Content changes: debounced (500ms) to coalesce rapid saves/builds.
if (event.type === 'create' || event.type === 'delete') {
gitStatusService.invalidateCache();
} else {
gitStatusService.invalidateCacheDebounced();
}
// Forward event to renderer
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
@ -318,6 +374,32 @@ async function handleEditorWatchDir(
});
}
/**
* Update watched file list (open tabs).
*/
async function handleEditorSetWatchedFiles(
_event: IpcMainInvokeEvent,
filePaths: string[]
): Promise<IpcResult<void>> {
return wrapHandler('setWatchedFiles', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
editorFileWatcher.setWatchedFiles(Array.isArray(filePaths) ? filePaths : []);
});
}
/**
* Update watched directory list (shallow, depth=0).
*/
async function handleEditorSetWatchedDirs(
_event: IpcMainInvokeEvent,
dirPaths: string[]
): Promise<IpcResult<void>> {
return wrapHandler('setWatchedDirs', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
editorFileWatcher.setWatchedDirs(Array.isArray(dirPaths) ? dirPaths : []);
});
}
// =============================================================================
// Registration
// =============================================================================
@ -344,10 +426,15 @@ export function registerEditorHandlers(ipcMain: IpcMain): void {
ipcMain.handle(EDITOR_CREATE_DIR, handleEditorCreateDir);
ipcMain.handle(EDITOR_DELETE_FILE, handleEditorDeleteFile);
ipcMain.handle(EDITOR_MOVE_FILE, handleEditorMoveFile);
ipcMain.handle(EDITOR_RENAME_FILE, handleEditorRenameFile);
ipcMain.handle(EDITOR_SEARCH_IN_FILES, handleEditorSearchInFiles);
ipcMain.handle(EDITOR_LIST_FILES, handleEditorListFiles);
ipcMain.handle(EDITOR_READ_BINARY_PREVIEW, handleEditorReadBinaryPreview);
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles);
ipcMain.handle(EDITOR_SET_WATCHED_DIRS, handleEditorSetWatchedDirs);
ipcMain.handle(PROJECT_LIST_FILES, handleProjectListFiles);
}
export function removeEditorHandlers(ipcMain: IpcMain): void {
@ -360,10 +447,15 @@ export function removeEditorHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(EDITOR_CREATE_DIR);
ipcMain.removeHandler(EDITOR_DELETE_FILE);
ipcMain.removeHandler(EDITOR_MOVE_FILE);
ipcMain.removeHandler(EDITOR_RENAME_FILE);
ipcMain.removeHandler(EDITOR_SEARCH_IN_FILES);
ipcMain.removeHandler(EDITOR_LIST_FILES);
ipcMain.removeHandler(EDITOR_READ_BINARY_PREVIEW);
ipcMain.removeHandler(EDITOR_GIT_STATUS);
ipcMain.removeHandler(EDITOR_WATCH_DIR);
ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES);
ipcMain.removeHandler(EDITOR_SET_WATCHED_DIRS);
ipcMain.removeHandler(PROJECT_LIST_FILES);
}
/**

View file

@ -12,7 +12,7 @@ const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TEAM_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const TASK_ID_PATTERN = /^\d{1,10}$/;
const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;

View file

@ -221,7 +221,44 @@ async function handleApplyDecisions(
if (!request || !Array.isArray(request.decisions)) {
return { success: false, error: 'Invalid request: decisions array required' };
}
return wrapReviewHandler('applyDecisions', () => getApplier().applyReviewDecisions(request));
return wrapReviewHandler('applyDecisions', async () => {
// Build file contents map for the applier. Prefer renderer-provided context
// (snippets + full contents), falling back to resolver when missing.
const fileContents = new Map<string, FileChangeWithContent>();
const memberName = request.memberName ?? '';
for (const d of request.decisions) {
const snippets = d.snippets ?? [];
// If renderer provided full contents, use them directly.
if (d.originalFullContent !== undefined || d.modifiedFullContent !== undefined) {
fileContents.set(d.filePath, {
filePath: d.filePath,
relativePath: d.filePath.split('/').slice(-3).join('/'),
snippets,
linesAdded: 0,
linesRemoved: 0,
isNewFile: d.isNewFile ?? snippets.some((s) => s.type === 'write-new'),
originalFullContent: d.originalFullContent ?? null,
modifiedFullContent: d.modifiedFullContent ?? null,
// Source is informational only; "unavailable" avoids lying.
contentSource: 'unavailable',
});
continue;
}
// Fallback: resolve in main process (best-effort; task mode may not have memberName).
const resolved = await getContentResolver().getFileContent(
request.teamName,
memberName,
d.filePath,
snippets
);
fileContents.set(d.filePath, resolved);
}
return getApplier().applyReviewDecisions(request, fileContents);
});
}
async function handleGetFileContent(
@ -241,12 +278,14 @@ async function handleGetFileContent(
async function handleSaveEditedFile(
_event: IpcMainInvokeEvent,
filePath: string,
content: string
content: string,
projectPath?: string
): Promise<IpcResult<{ success: boolean }>> {
if (!filePath || typeof content !== 'string') {
return { success: false, error: 'Invalid parameters' };
}
const pathCheck = validateFilePath(filePath, null);
const resolvedProjectPath = projectPath && typeof projectPath === 'string' ? projectPath : null;
const pathCheck = validateFilePath(filePath, resolvedProjectPath);
if (!pathCheck.valid) {
logger.error(`saveEditedFile blocked: ${String(pathCheck.error)} (path: ${String(filePath)})`);
return { success: false, error: `Path validation failed: ${String(pathCheck.error)}` };
@ -284,6 +323,7 @@ async function handleLoadDecisions(
IpcResult<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null>
> {
return wrapReviewHandler('loadDecisions', () => reviewDecisionStore.load(teamName, scopeKey));
@ -294,10 +334,15 @@ async function handleSaveDecisions(
teamName: string,
scopeKey: string,
hunkDecisions: Record<string, HunkDecision>,
fileDecisions: Record<string, HunkDecision>
fileDecisions: Record<string, HunkDecision>,
hunkContextHashesByFile: Record<string, Record<number, string>> | null = null
): Promise<IpcResult<void>> {
return wrapReviewHandler('saveDecisions', () =>
reviewDecisionStore.save(teamName, scopeKey, { hunkDecisions, fileDecisions })
reviewDecisionStore.save(teamName, scopeKey, {
hunkDecisions,
fileDecisions,
hunkContextHashesByFile: hunkContextHashesByFile ?? undefined,
})
);
}

View file

@ -1,8 +1,10 @@
import { randomUUID } from 'node:crypto';
import { getAppIconPath } from '@main/utils/appIcon';
import {
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT,
TEAM_ADD_TASK_RELATIONSHIP,
TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING,
TEAM_CREATE,
@ -28,6 +30,7 @@ import {
TEAM_PROVISIONING_PROGRESS,
TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REMOVE_TASK_RELATIONSHIP,
TEAM_REQUEST_REVIEW,
TEAM_RESTORE,
TEAM_RESTORE_TASK,
@ -215,6 +218,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification);
ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification);
ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship);
ipcMain.handle(TEAM_REMOVE_TASK_RELATIONSHIP, handleRemoveTaskRelationship);
logger.info('Team handlers registered');
}
@ -261,6 +266,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION);
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP);
ipcMain.removeHandler(TEAM_REMOVE_TASK_RELATIONSHIP);
}
function getTeamDataService(): TeamDataService {
@ -1306,6 +1313,14 @@ async function handleCreateConfig(
if (payload.color !== undefined && typeof payload.color !== 'string') {
return { success: false, error: 'color must be a string' };
}
if (payload.cwd !== undefined) {
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
return { success: false, error: 'cwd must be a non-empty string if provided' };
}
if (!path.isAbsolute(payload.cwd.trim())) {
return { success: false, error: 'cwd must be an absolute path' };
}
}
const seenNames = new Set<string>();
const members: TeamCreateConfigRequest['members'] = [];
@ -1337,6 +1352,7 @@ async function handleCreateConfig(
description: payload.description?.trim() || undefined,
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
members,
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
})
);
}
@ -1370,7 +1386,12 @@ async function handleGetLogsForTask(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
options?: { owner?: string; status?: string }
options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
}
): Promise<IpcResult<MemberLogSummary[]>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
@ -1385,6 +1406,17 @@ async function handleGetLogsForTask(
? {
owner: typeof options.owner === 'string' ? options.owner : undefined,
status: typeof options.status === 'string' ? options.status : undefined,
since: typeof options.since === 'string' ? options.since : undefined,
intervals: Array.isArray(options.intervals)
? (options.intervals as unknown[]).filter(
(i): i is { startedAt: string; completedAt?: string } =>
Boolean(i) &&
typeof i === 'object' &&
typeof (i as Record<string, unknown>).startedAt === 'string' &&
((i as Record<string, unknown>).completedAt === undefined ||
typeof (i as Record<string, unknown>).completedAt === 'string')
)
: undefined,
}
: undefined;
return wrapTeamHandler('getLogsForTask', () =>
@ -1714,22 +1746,31 @@ export function showTeamNativeNotification(opts: {
body: string;
}): void {
const config = ConfigManager.getInstance().getConfig();
if (!config.notifications.enabled) return;
if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) return;
if (!config.notifications.enabled) {
logger.debug('[native-notification] skipped: notifications disabled');
return;
}
if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) {
logger.debug('[native-notification] skipped: snoozed');
return;
}
if (
typeof Notification === 'undefined' ||
typeof Notification.isSupported !== 'function' ||
!Notification.isSupported()
) {
logger.warn('[native-notification] skipped: Notification not supported on this platform');
return;
}
const iconPath = getAppIconPath();
const notification = new Notification({
title: opts.title,
subtitle: opts.subtitle,
body: opts.body.slice(0, 300),
sound: config.notifications.soundEnabled ? 'default' : undefined,
...(iconPath ? { icon: iconPath } : {}),
});
notification.on('click', () => {
@ -1741,6 +1782,14 @@ export function showTeamNativeNotification(opts: {
}
});
notification.on('show', () => {
logger.debug(`[native-notification] shown: "${opts.title}" — ${opts.subtitle ?? ''}`);
});
notification.on('failed', (_, error) => {
logger.warn(`[native-notification] failed: ${error}`);
});
notification.show();
}
@ -1763,3 +1812,66 @@ async function handleAddTaskComment(
getTeamDataService().addTaskComment(vTeam.value!, vTask.value!, text.trim())
);
}
const VALID_RELATIONSHIP_TYPES = ['blockedBy', 'blocks', 'related'] as const;
type RelationshipType = (typeof VALID_RELATIONSHIP_TYPES)[number];
async function handleAddTaskRelationship(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
targetId: unknown,
type: unknown
): Promise<IpcResult<void>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
const vTask = validateTaskId(taskId);
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
const vTarget = validateTaskId(targetId);
if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' };
if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) {
return {
success: false,
error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`,
};
}
return wrapTeamHandler('addTaskRelationship', () =>
getTeamDataService().addTaskRelationship(
vTeam.value!,
vTask.value!,
vTarget.value!,
type as RelationshipType
)
);
}
async function handleRemoveTaskRelationship(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
targetId: unknown,
type: unknown
): Promise<IpcResult<void>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
const vTask = validateTaskId(taskId);
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
const vTarget = validateTaskId(targetId);
if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' };
if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) {
return {
success: false,
error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`,
};
}
return wrapTeamHandler('removeTaskRelationship', () =>
getTeamDataService().removeTaskRelationship(
vTeam.value!,
vTask.value!,
vTarget.value!,
type as RelationshipType
)
);
}

View file

@ -196,10 +196,6 @@ export class ProjectScanner {
// 1. Scan all projects using existing logic
const projects = await this.scan();
if (projects.length === 0) {
return [];
}
// 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
// Git identity resolution is bypassed to avoid blocking I/O on startup.
// Each project becomes a single-worktree group.
@ -223,6 +219,40 @@ export class ProjectScanner {
totalSessions: project.sessions.length,
}));
// 3. Merge custom project paths from config (persisted "Select Folder" picks)
const { configManager } = await import('../infrastructure/ConfigManager');
const customPaths = configManager.getCustomProjectPaths();
const existingPaths = new Set(groups.flatMap((g) => g.worktrees.map((w) => w.path)));
for (const customPath of customPaths) {
if (existingPaths.has(customPath)) {
continue; // Already discovered by scanner — skip
}
const encodedId = customPath.replace(/[/\\]/g, '-');
const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath;
const now = Date.now();
groups.push({
id: encodedId,
identity: null,
worktrees: [
{
id: encodedId,
path: customPath,
name: folderName,
isMainWorktree: true,
source: 'unknown' as const,
sessions: [],
createdAt: now,
},
],
name: folderName,
mostRecentSession: undefined,
totalSessions: 0,
});
}
// Sort by most recent activity (same order as the full git-aware version)
groups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
@ -309,7 +339,8 @@ export class ProjectScanner {
// Group sessions by cwd
const cwdGroups = new Map<string, SessionInfo[]>();
const baseName = extractProjectName(encodedName);
const firstCwd = sessionInfos.find((s) => s.cwd)?.cwd ?? undefined;
const baseName = extractProjectName(encodedName, firstCwd);
const decodedFallback = baseName; // Used when cwd is null
for (const info of sessionInfos) {
@ -341,11 +372,15 @@ export class ProjectScanner {
sessionPaths,
});
// Derive name from resolved path — more reliable than decodePath for
// paths containing dashes (e.g. "test-project" encodes lossily).
const resolvedName = path.basename(actualPath) || baseName;
return [
{
id: encodedName,
path: actualPath,
name: baseName,
name: resolvedName,
sessions: allSessionIds,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
@ -362,6 +397,8 @@ export class ProjectScanner {
(shortest, cwd) => (cwd.length <= shortest.length ? cwd : shortest),
cwdKeys[0] ?? ''
);
// Derive root name from actual cwd path (more reliable than decodePath)
const rootName = path.basename(rootCwd) || baseName;
for (const [cwdKey, sessions] of cwdGroups) {
const isDecodedFallback = cwdKey.startsWith('__decoded__');
@ -387,14 +424,14 @@ export class ProjectScanner {
}
}
// Build display name
// Build display name from actual cwd paths
let displayName: string;
if (!actualCwd || actualCwd === rootCwd) {
displayName = baseName;
displayName = rootName;
} else {
// Use last segment of cwd for disambiguation
const lastSegment = path.basename(actualCwd);
displayName = `${baseName} (${lastSegment})`;
displayName = `${rootName} (${lastSegment})`;
}
projects.push({

View file

@ -22,11 +22,8 @@ const log = createLogger('EditorFileWatcher');
// Constants
// =============================================================================
/** Directories to ignore (regex for chokidar's `ignored` option) */
const IGNORED_PATTERN =
/(node_modules|\.git|dist|__pycache__|\.cache|\.next|\.venv|\.tox|vendor|\.DS_Store)/;
const MAX_DEPTH = 20;
const STARTUP_IGNORE_CHANGE_MS = 3000;
const MAX_EMITTED_EVENTS_PER_FLUSH = 300;
// =============================================================================
// Service
@ -34,32 +31,80 @@ const MAX_DEPTH = 20;
export class EditorFileWatcher {
private watcher: FSWatcher | null = null;
private dirWatcher: FSWatcher | null = null;
private projectRoot: string | null = null;
private pendingEvents = new Map<string, EditorFileChangeEvent['type']>();
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private onChangeCallback: ((event: EditorFileChangeEvent) => void) | null = null;
// Higher debounce = fewer IPC events during large bursts (checkout/build/format).
private readonly DEBOUNCE_MS = 350;
private ignoreChangeUntilMs = 0;
private watchedFilesKey = '';
private watchedDirsKey = '';
/**
* Start watching a project directory.
* Idempotent: stops any existing watcher first.
* Initialize watcher context for a project root.
*
* Performance: does NOT watch the entire project directory.
* Use setWatchedFiles() to watch only open files (tabs).
*/
start(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void): void {
this.stop();
this.projectRoot = projectRoot;
this.ignoreChangeUntilMs = Date.now() + STARTUP_IGNORE_CHANGE_MS;
this.watchedFilesKey = '';
this.watchedDirsKey = '';
log.info('Starting file watcher for:', projectRoot);
log.info('Starting file watcher (open files only) for:', projectRoot);
this.onChangeCallback = onChange;
}
this.watcher = watch(projectRoot, {
ignored: IGNORED_PATTERN,
/**
* Update list of watched file paths (open tabs).
* Rebuilds chokidar watcher when the set changes.
*/
setWatchedFiles(filePaths: string[]): void {
if (!this.projectRoot) {
return; // Watcher not initialized yet — will sync when start() is called
}
const normalized = filePaths
.filter((p): p is string => typeof p === 'string' && p.length > 0)
.filter((p) => isPathWithinRoot(p, this.projectRoot!));
normalized.sort((a, b) => a.localeCompare(b));
const key = normalized.join('\n');
if (key === this.watchedFilesKey) return;
this.watchedFilesKey = key;
// Close existing watcher first (if any)
if (this.watcher) {
void this.watcher.close();
this.watcher = null;
}
if (normalized.length === 0) {
return;
}
// Build a new watcher for the given file set.
// disableGlobbing prevents chokidar from treating file names as patterns.
this.watcher = watch(normalized, {
ignoreInitial: true,
ignorePermissionErrors: true,
followSymlinks: false,
depth: MAX_DEPTH,
});
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
// SEC-2: validate path is within project root before sending to renderer
if (!isPathWithinRoot(filePath, projectRoot)) {
if (type === 'change' && Date.now() < this.ignoreChangeUntilMs) {
return;
}
if (!isPathWithinRoot(filePath, this.projectRoot!)) {
log.warn('Watcher event outside project root, ignoring:', filePath);
return;
}
onChange({ type, path: filePath });
this.pendingEvents.set(filePath, type);
this.scheduleFlush();
};
this.watcher.on('change', (p) => emitSafe('change', p));
@ -71,18 +116,122 @@ export class EditorFileWatcher {
});
}
/**
* Update list of watched directory paths (shallow: depth=0).
* Watches only immediate children changes (create/delete/rename) in those folders.
*/
setWatchedDirs(dirPaths: string[]): void {
if (!this.projectRoot) {
return; // Watcher not initialized yet — will sync when start() is called
}
const normalized = dirPaths
.filter((p): p is string => typeof p === 'string' && p.length > 0)
.filter((p) => isPathWithinRoot(p, this.projectRoot!));
normalized.sort((a, b) => a.localeCompare(b));
const key = normalized.join('\n');
if (key === this.watchedDirsKey) return;
this.watchedDirsKey = key;
if (this.dirWatcher) {
void this.dirWatcher.close();
this.dirWatcher = null;
}
if (normalized.length === 0) {
return;
}
this.dirWatcher = watch(normalized, {
ignoreInitial: true,
ignorePermissionErrors: true,
followSymlinks: false,
depth: 0,
});
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
if (!isPathWithinRoot(filePath, this.projectRoot!)) {
log.warn('Watcher event outside project root, ignoring:', filePath);
return;
}
this.pendingEvents.set(filePath, type);
this.scheduleFlush();
};
// For directories, we only care about structural changes.
this.dirWatcher.on('add', (p) => emitSafe('create', p));
this.dirWatcher.on('unlink', (p) => emitSafe('delete', p));
this.dirWatcher.on('addDir', (p) => emitSafe('create', p));
this.dirWatcher.on('unlinkDir', (p) => emitSafe('delete', p));
this.dirWatcher.on('error', (error) => {
log.error('Dir watcher error:', error);
});
}
/**
* Stop watching. Safe to call multiple times.
*/
stop(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
this.pendingEvents.clear();
this.onChangeCallback = null;
this.ignoreChangeUntilMs = 0;
this.watchedFilesKey = '';
this.watchedDirsKey = '';
if (this.watcher) {
log.info('Stopping file watcher');
void this.watcher.close();
this.watcher = null;
}
if (this.dirWatcher) {
log.info('Stopping directory watcher');
void this.dirWatcher.close();
this.dirWatcher = null;
}
this.projectRoot = null;
}
/**
* Flush pending events debounced to aggregate rapid FS changes
* (e.g. git checkout, bulk format). Fires once after 150ms of quiet.
*/
private scheduleFlush(): void {
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
const events = new Map(this.pendingEvents);
this.pendingEvents.clear();
if (!this.onChangeCallback) return;
// Cap emitted events per flush to protect renderer from floods.
// Prefer create/delete events over change events.
let emitted = 0;
if (events.size > MAX_EMITTED_EVENTS_PER_FLUSH) {
log.warn(
`Watcher burst: ${events.size} events pending, capping to ${MAX_EMITTED_EVENTS_PER_FLUSH}`
);
}
const emit = (type: EditorFileChangeEvent['type']): void => {
for (const [filePath, t] of events) {
if (t !== type) continue;
this.onChangeCallback?.({ type: t, path: filePath });
emitted++;
if (emitted >= MAX_EMITTED_EVENTS_PER_FLUSH) return;
}
};
emit('delete');
if (emitted < MAX_EMITTED_EVENTS_PER_FLUSH) emit('create');
if (emitted < MAX_EMITTED_EVENTS_PER_FLUSH) emit('change');
}, this.DEBOUNCE_MS);
}
/**
* Whether the watcher is currently active.
*/

View file

@ -147,6 +147,7 @@ export class FileSearchService {
}
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
const subdirs: string[] = [];
for (const entry of sorted) {
if (signal?.aborted || files.length >= MAX_FILES) break;
@ -158,7 +159,7 @@ export class FileSearchService {
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
await this.collectFilePaths(projectRoot, fullPath, files, signal);
subdirs.push(fullPath);
} else if (entry.isFile()) {
if (IGNORED_FILES.has(entry.name)) continue;
const relativePath = fullPath.startsWith(projectRoot)
@ -167,6 +168,14 @@ export class FileSearchService {
files.push({ path: fullPath, name: entry.name, relativePath });
}
}
// Parallel subdirectory traversal (batched)
const DIR_CONCURRENCY = 10;
for (let i = 0; i < subdirs.length; i += DIR_CONCURRENCY) {
if (signal?.aborted || files.length >= MAX_FILES) break;
const batch = subdirs.slice(i, i + DIR_CONCURRENCY);
await Promise.all(batch.map((dir) => this.collectFilePaths(projectRoot, dir, files, signal)));
}
}
/**
@ -194,8 +203,11 @@ export class FileSearchService {
return a.name.localeCompare(b.name);
});
const candidates: string[] = [];
const subdirs: string[] = [];
for (const entry of sorted) {
if (signal?.aborted || files.length >= MAX_FILES) break;
if (signal?.aborted) break;
const fullPath = path.join(dirPath, entry.name);
@ -207,32 +219,48 @@ export class FileSearchService {
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
await this.collectFiles(projectRoot, fullPath, files, signal);
subdirs.push(fullPath);
} else if (entry.isFile()) {
if (IGNORED_FILES.has(entry.name)) continue;
// Skip files > 1MB
try {
const stat = await fs.stat(fullPath);
if (stat.size > MAX_FILE_SIZE) continue;
} catch {
continue;
}
// Skip binary files (quick check via first 512 bytes)
try {
if (await isBinaryFile(fullPath)) continue;
} catch {
continue;
}
files.push(fullPath);
candidates.push(fullPath);
}
}
// Parallel stat + binary check (batched by 20 for I/O concurrency)
const CHECK_CONCURRENCY = 20;
for (let i = 0; i < candidates.length; i += CHECK_CONCURRENCY) {
if (signal?.aborted || files.length >= MAX_FILES) break;
const batch = candidates.slice(i, i + CHECK_CONCURRENCY);
const results = await Promise.all(
batch.map(async (fp) => {
try {
const stat = await fs.stat(fp);
if (stat.size > MAX_FILE_SIZE) return null;
if (await isBinaryFile(fp)) return null;
return fp;
} catch {
return null;
}
})
);
for (const fp of results) {
if (fp && files.length < MAX_FILES) files.push(fp);
}
}
// Parallel subdirectory traversal (batched)
const DIR_CONCURRENCY = 10;
for (let i = 0; i < subdirs.length; i += DIR_CONCURRENCY) {
if (signal?.aborted || files.length >= MAX_FILES) break;
const batch = subdirs.slice(i, i + DIR_CONCURRENCY);
await Promise.all(batch.map((dir) => this.collectFiles(projectRoot, dir, files, signal)));
}
}
/**
* Search a single file for literal string matches.
* Uses indexOf on the full content instead of split() avoids 50k+ string allocations
* for a 1MB file. Running line counter keeps O(n) total regardless of match count.
*/
private async searchFile(
filePath: string,
@ -242,29 +270,38 @@ export class FileSearchService {
): Promise<SearchMatch[]> {
if (signal?.aborted) return [];
const content = await fs.readFile(filePath, 'utf8');
const lines = content.split('\n');
const raw = await fs.readFile(filePath, 'utf8');
const content = caseSensitive ? raw : raw.toLowerCase();
const matches: SearchMatch[] = [];
for (let i = 0; i < lines.length; i++) {
let searchFrom = 0;
let line = 1;
let lastCountedPos = 0;
while (true) {
if (signal?.aborted) break;
const idx = content.indexOf(query, searchFrom);
if (idx === -1) break;
const line = lines[i];
const searchLine = caseSensitive ? line : line.toLowerCase();
let startIndex = 0;
while (true) {
const idx = searchLine.indexOf(query, startIndex);
if (idx === -1) break;
matches.push({
line: i + 1,
column: idx,
lineContent: line.trim(),
});
startIndex = idx + query.length;
// Running line counter — count \n from lastCountedPos to idx
for (let i = lastCountedPos; i < idx; i++) {
if (content.charCodeAt(i) === 10) line++;
}
lastCountedPos = idx;
// Extract line content from raw text
let lineStart = raw.lastIndexOf('\n', idx);
lineStart = lineStart === -1 ? 0 : lineStart + 1;
let lineEnd = raw.indexOf('\n', idx);
if (lineEnd === -1) lineEnd = raw.length;
matches.push({
line,
column: idx - lineStart,
lineContent: raw.slice(lineStart, lineEnd).trim(),
});
searchFrom = idx + query.length;
}
return matches;

View file

@ -20,6 +20,8 @@ const log = createLogger('GitStatusService');
const GIT_TIMEOUT_MS = 10_000;
const CACHE_TTL_MS = 5_000;
const CHANGE_INVALIDATION_DEBOUNCE_MS = 500;
const GIT_STATUS_ARGS: string[] = ['--untracked-files=no'];
// =============================================================================
// Service
@ -28,10 +30,13 @@ const CACHE_TTL_MS = 5_000;
export class GitStatusService {
private git: SimpleGit | null = null;
private projectRoot: string | null = null;
/** Set to true when we confirm the project is not a git repo — skip all future git calls. */
private notAGitRepo = false;
// Cache
private cachedResult: GitStatusResult | null = null;
private cacheTimestamp = 0;
private changeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Initialize service for a project root.
@ -39,6 +44,7 @@ export class GitStatusService {
*/
init(projectRoot: string): void {
this.projectRoot = projectRoot;
this.notAGitRepo = false;
this.git = simpleGit({
baseDir: projectRoot,
timeout: { block: GIT_TIMEOUT_MS },
@ -50,64 +56,91 @@ export class GitStatusService {
* Reset service state.
*/
destroy(): void {
this.clearDebounceTimer();
this.git = null;
this.projectRoot = null;
this.notAGitRepo = false;
this.cachedResult = null;
this.cacheTimestamp = 0;
}
/**
* Invalidate cached status (e.g. on file watcher event).
* Immediate cache invalidation for structural changes (create/delete).
* Also cancels any pending debounced invalidation.
*/
invalidateCache(): void {
this.clearDebounceTimer();
this.cachedResult = null;
this.cacheTimestamp = 0;
}
/**
* Debounced cache invalidation for content changes.
* Coalesces rapid file saves (typing, format-on-save, build output)
* into a single invalidation after the burst settles.
*/
invalidateCacheDebounced(): void {
this.clearDebounceTimer();
this.changeDebounceTimer = setTimeout(() => {
this.changeDebounceTimer = null;
this.cachedResult = null;
this.cacheTimestamp = 0;
}, CHANGE_INVALIDATION_DEBOUNCE_MS);
}
/**
* Get git status for the current project.
* Returns cached result if within TTL.
*/
async getStatus(): Promise<GitStatusResult> {
if (!this.git || !this.projectRoot) {
return { files: [], isGitRepo: false, branch: null };
const notGitResult: GitStatusResult = { files: [], isGitRepo: false, branch: null };
if (!this.git || !this.projectRoot || this.notAGitRepo) {
return notGitResult;
}
// Flush pending debounced invalidation — when data is actually requested,
// stale cache must not be served even if the debounce hasn't settled yet.
if (this.changeDebounceTimer) {
this.invalidateCache();
}
// Return cached if fresh
if (this.cachedResult && Date.now() - this.cacheTimestamp < CACHE_TTL_MS) {
log.info('[perf] gitStatus: cache hit');
return this.cachedResult;
}
try {
// Check if it's a git repo first
const isRepo = await this.isGitRepo();
if (!isRepo) {
const result: GitStatusResult = { files: [], isGitRepo: false, branch: null };
this.setCacheResult(result);
return result;
}
const statusResult = await this.git.status();
const t0 = performance.now();
// `--untracked-files=no` is a major performance win for large repos with many untracked files.
// Most editors treat untracked as a separate concern; showing them can overwhelm the UI.
const statusResult = await this.git.status(GIT_STATUS_ARGS);
const gitMs = performance.now() - t0;
const files = mapStatusResult(statusResult);
const branch = statusResult.current ?? null;
log.info(
`[perf] gitStatus: git=${gitMs.toFixed(1)}ms, files=${files.length}, branch=${branch ?? 'detached'}, untracked=off`
);
const result: GitStatusResult = { files, isGitRepo: true, branch };
this.setCacheResult(result);
return result;
} catch (error) {
log.error('Failed to get git status:', error);
// Graceful degradation: return empty non-repo result
return { files: [], isGitRepo: false, branch: null };
}
}
const message = error instanceof Error ? error.message : String(error);
private async isGitRepo(): Promise<boolean> {
if (!this.git) return false;
try {
await this.git.revparse(['--is-inside-work-tree']);
return true;
} catch {
return false;
if (message.includes('not a git repository')) {
// Expected condition — project is not a git repo. Stop all future git calls
// until init() is called again with a different project.
log.info('Project is not a git repository, disabling git status');
this.notAGitRepo = true;
return notGitResult;
}
log.error('Failed to get git status:', error);
// Transient error — cache negative result to avoid hammering git, but allow retry after TTL
this.setCacheResult(notGitResult);
return notGitResult;
}
}
@ -115,6 +148,13 @@ export class GitStatusService {
this.cachedResult = result;
this.cacheTimestamp = Date.now();
}
private clearDebounceTimer(): void {
if (this.changeDebounceTimer) {
clearTimeout(this.changeDebounceTimer);
this.changeDebounceTimer = null;
}
}
}
// =============================================================================

View file

@ -23,6 +23,7 @@ import { isBinaryFile } from 'isbinaryfile';
import * as path from 'path';
import type {
BinaryPreviewResult,
CreateDirResponse,
CreateFileResponse,
DeleteFileResponse,
@ -43,6 +44,32 @@ const MAX_WRITE_SIZE = 2 * 1024 * 1024; // 2 MB
const MAX_DIR_ENTRIES = 500;
const PREVIEW_LINE_COUNT = 100;
/**
* Extract the first N lines from text using indexOf O(1) allocations vs split().
* For a 5MB file with 100k lines, avoids creating 100k string objects.
*/
function sliceFirstNLines(text: string, n: number): string {
let pos = 0;
for (let i = 0; i < n; i++) {
const next = text.indexOf('\n', pos);
if (next === -1) return text;
pos = next + 1;
}
return text.slice(0, pos > 0 ? pos - 1 : 0);
}
const PREVIEW_MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
};
const MAX_PREVIEW_SIZE = 10 * 1024 * 1024; // 10 MB
const IGNORED_DIRS = new Set([
'.git',
'node_modules',
@ -77,6 +104,7 @@ export class ProjectFileService {
dirPath: string,
maxEntries: number = MAX_DIR_ENTRIES
): Promise<ReadDirResult> {
const t0 = performance.now();
const normalizedDir = path.resolve(dirPath);
// Containment check (allow sensitive files to be listed with flag)
@ -90,51 +118,58 @@ export class ProjectFileService {
}
const dirents = await fs.readdir(normalizedDir, { withFileTypes: true });
const entries: FileTreeEntry[] = [];
let truncated = false;
// Phase 1: classify entries without I/O (instant)
const pendingEntries: {
dirent: { name: string };
entryPath: string;
type: 'file' | 'directory' | 'symlink';
}[] = [];
for (const dirent of dirents) {
// Ignore well-known noise
if (dirent.isDirectory() && IGNORED_DIRS.has(dirent.name)) continue;
if (dirent.isFile() && IGNORED_FILES.has(dirent.name)) continue;
const entryPath = path.join(normalizedDir, dirent.name);
if (!isPathWithinRoot(entryPath, projectRoot)) continue;
if (isGitInternalPath(entryPath)) continue;
// Symlink handling: resolve and re-check containment
if (dirent.isSymbolicLink()) {
try {
const realPath = await fs.realpath(entryPath);
if (!isPathWithinAllowedDirectories(realPath, projectRoot)) {
continue; // Silently skip symlinks that escape project root (SEC-2)
}
const realStat = await fs.stat(realPath);
const entry = this.buildEntry(
dirent.name,
entryPath,
realStat.isDirectory() ? 'directory' : 'file',
realStat.isFile() ? realStat.size : undefined
);
entries.push(entry);
} catch {
// Broken symlink — skip silently
continue;
}
pendingEntries.push({ dirent, entryPath, type: 'symlink' });
} else if (dirent.isDirectory()) {
entries.push(this.buildEntry(dirent.name, entryPath, 'directory'));
pendingEntries.push({ dirent, entryPath, type: 'directory' });
} else if (dirent.isFile()) {
try {
const fileStat = await fs.stat(entryPath);
entries.push(this.buildEntry(dirent.name, entryPath, 'file', fileStat.size));
} catch {
// Can't stat — include without size
entries.push(this.buildEntry(dirent.name, entryPath, 'file'));
}
pendingEntries.push({ dirent, entryPath, type: 'file' });
}
// Skip other types (block devices, sockets, etc.)
if (entries.length >= maxEntries) {
truncated = true;
break;
if (pendingEntries.length >= maxEntries) break;
}
// Phase 2: resolve entries in parallel (I/O-bound)
const STAT_CONCURRENCY = 50;
const entries: FileTreeEntry[] = [];
for (let i = 0; i < pendingEntries.length; i += STAT_CONCURRENCY) {
const batch = pendingEntries.slice(i, i + STAT_CONCURRENCY);
const resolved = await Promise.all(
batch.map(async ({ dirent, entryPath, type }) => {
if (type === 'directory') {
return this.buildEntry(dirent.name, entryPath, 'directory');
}
if (type === 'symlink') {
return this.resolveSymlinkEntry(dirent.name, entryPath, projectRoot);
}
// file — stat for size
try {
const fileStat = await fs.stat(entryPath);
return this.buildEntry(dirent.name, entryPath, 'file', fileStat.size);
} catch {
return this.buildEntry(dirent.name, entryPath, 'file');
}
})
);
for (const entry of resolved) {
if (entry) entries.push(entry);
}
}
@ -144,7 +179,14 @@ export class ProjectFileService {
return a.name.localeCompare(b.name);
});
return { entries, truncated };
const totalMs = performance.now() - t0;
if (totalMs > 50) {
log.info(
`[perf] readDir: ${totalMs.toFixed(1)}ms, entries=${entries.length}, dirents=${dirents.length}, dir=${path.basename(normalizedDir)}`
);
}
return { entries, truncated: pendingEntries.length >= maxEntries };
}
/**
@ -209,7 +251,7 @@ export class ProjectFileService {
// 8. Tiered response
const isPreview = stats.size > MAX_FILE_SIZE_FULL;
const content = isPreview ? raw.split('\n').slice(0, PREVIEW_LINE_COUNT).join('\n') : raw;
const content = isPreview ? sliceFirstNLines(raw, PREVIEW_LINE_COUNT) : raw;
return {
content,
@ -505,7 +547,7 @@ export class ProjectFileService {
const newPath = path.join(normalizedDest, path.basename(normalizedSrc));
// 8. Prevent parent → child move (moving dir into itself)
if (normalizedDest.startsWith(normalizedSrc + '/') || normalizedDest === normalizedSrc) {
if (normalizedDest.startsWith(normalizedSrc + path.sep) || normalizedDest === normalizedSrc) {
throw new Error('Cannot move a directory into itself');
}
@ -529,8 +571,8 @@ export class ProjectFileService {
await fs.rename(normalizedSrc, newPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
const stat = await fs.lstat(normalizedSrc);
if (stat.isDirectory()) {
// Reuse srcStat from step 5 — no need for another fs.lstat
if (isDirectory) {
await fs.cp(normalizedSrc, newPath, { recursive: true });
} else {
await fs.copyFile(normalizedSrc, newPath);
@ -545,10 +587,152 @@ export class ProjectFileService {
return { newPath, isDirectory };
}
/**
* Rename a file or directory in place (same parent directory).
*/
async renameFile(
projectRoot: string,
sourcePath: string,
newName: string
): Promise<MoveFileResponse> {
// 1. Validate new name
const nameValidation = validateFileName(newName);
if (!nameValidation.valid) {
throw new Error(nameValidation.error);
}
// 2. Validate source path
const srcValidation = validateFilePath(sourcePath, projectRoot);
if (!srcValidation.valid) {
throw new Error(srcValidation.error);
}
const normalizedSrc = srcValidation.normalizedPath!;
// 3. Project containment
if (!isPathWithinRoot(normalizedSrc, projectRoot)) {
throw new Error('Source path is outside project root');
}
// 4. Block .git/ paths
if (isGitInternalPath(normalizedSrc)) {
throw new Error('Cannot rename files in .git/ directory');
}
// 5. Verify source exists
const srcStat = await fs.lstat(normalizedSrc);
const isDirectory = srcStat.isDirectory();
// 6. Build new path (same parent, new name)
const parentDir = path.dirname(normalizedSrc);
const newPath = path.join(parentDir, newName);
// 7. Check new path doesn't already exist
try {
await fs.access(newPath);
throw new Error('A file or folder with that name already exists');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err;
}
}
// 8. Block sensitive destination
if (matchesSensitivePattern(newPath)) {
throw new Error('Cannot rename to a sensitive file name');
}
// 9. Perform rename
await fs.rename(normalizedSrc, newPath);
log.info('File renamed:', normalizedSrc, '→', newPath);
return { newPath, isDirectory };
}
/**
* Read a binary file as base64 for inline preview (images, etc.).
*
* Security:
* - validateFilePath for traversal + sensitive check (SEC-1)
* - Device path blocking (SEC-4)
* - lstat + isFile check (SEC-4)
* - Size limit (10MB)
* - Post-read TOCTOU realpath verify (SEC-3)
*/
async readBinaryPreview(projectRoot: string, filePath: string): Promise<BinaryPreviewResult> {
// 1. Path validation
const validation = validateFilePath(filePath, projectRoot);
if (!validation.valid) {
throw new Error(validation.error);
}
const normalizedPath = validation.normalizedPath!;
// 2. Device path block
if (isDevicePath(normalizedPath)) {
throw new Error('Cannot read device files');
}
// 3. File type check
const stats = await fs.lstat(normalizedPath);
if (!stats.isFile()) {
throw new Error('Not a regular file');
}
// 4. Size check
if (stats.size > MAX_PREVIEW_SIZE) {
throw new Error(
`File too large for preview (${(stats.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.`
);
}
// 5. MIME type from extension
const ext = path.extname(normalizedPath).toLowerCase();
const mimeType = PREVIEW_MIME_MAP[ext];
if (!mimeType) {
throw new Error(`Unsupported preview format: ${ext}`);
}
// 6. Read file as Buffer → base64
const buffer = await fs.readFile(normalizedPath);
// 7. Post-read TOCTOU verify
const realPath = await fs.realpath(normalizedPath);
const postValidation = validateFilePath(realPath, projectRoot);
if (!postValidation.valid) {
throw new Error('Path changed during read (TOCTOU)');
}
return {
base64: buffer.toString('base64'),
mimeType,
size: stats.size,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private async resolveSymlinkEntry(
name: string,
entryPath: string,
projectRoot: string
): Promise<FileTreeEntry | null> {
try {
const realPath = await fs.realpath(entryPath);
if (!isPathWithinAllowedDirectories(realPath, projectRoot)) return null;
const realStat = await fs.stat(realPath);
return this.buildEntry(
name,
entryPath,
realStat.isDirectory() ? 'directory' : 'file',
realStat.isFile() ? realStat.size : undefined
);
} catch {
return null; // broken symlink
}
}
private buildEntry(
name: string,
entryPath: string,

View file

@ -58,7 +58,10 @@ const MAX_REDIRECTS = 5;
const HTTP_CONNECT_TIMEOUT_MS = 15_000;
/** Overall timeout for getStatus() to prevent UI hanging indefinitely (ms) */
const GET_STATUS_TIMEOUT_MS = 25_000;
const GET_STATUS_TIMEOUT_MS = 30_000;
/** Overall timeout for the auth status check (covers both attempts + retry delay) (ms) */
const AUTH_TOTAL_TIMEOUT_MS = 15_000;
/** Max retries for EBUSY (antivirus scanning the new binary) */
const EBUSY_MAX_RETRIES = 3;
@ -66,6 +69,12 @@ const EBUSY_MAX_RETRIES = 3;
/** Delay between EBUSY retries (multiplied by attempt number) */
const EBUSY_RETRY_DELAY_MS = 2000;
/** Max retries for auth status check (covers stale locks after Ctrl+C) */
const AUTH_STATUS_MAX_RETRIES = 2;
/** Delay before retrying auth status check (ms) — gives previous process time to clean up */
const AUTH_STATUS_RETRY_DELAY_MS = 1500;
/**
* Build env for child processes with correct HOME.
* On Windows with non-ASCII usernames, process.env may have a broken HOME/USERPROFILE.
@ -263,6 +272,8 @@ export class CliInstallerService {
* Gathers CLI status information, mutating the provided result object.
* Split from getStatus() to enable overall timeout via Promise.race
* on timeout, getStatus() returns whatever fields were populated so far.
*
* Flow: binary resolve --version (sequential) Promise.all([auth, GCS]) (parallel)
*/
private async gatherStatus(ref: { current: CliInstallationStatus }): Promise<void> {
const r = ref.current;
@ -284,31 +295,84 @@ export class CliInstallerService {
logger.warn('Failed to get CLI version:', getErrorMessage(err));
}
// Check auth status
try {
const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], {
timeout: VERSION_TIMEOUT_MS,
env: buildChildEnv(),
});
const auth = JSON.parse(authStdout.trim()) as { loggedIn?: boolean; authMethod?: string };
r.authLoggedIn = auth.loggedIn === true;
r.authMethod = auth.authMethod ?? null;
logger.info(`Auth status: loggedIn=${r.authLoggedIn}, method=${r.authMethod ?? 'null'}`);
} catch (err) {
logger.warn('Failed to check auth status:', getErrorMessage(err));
r.authLoggedIn = false;
}
// Auth and GCS version check are independent — run in parallel.
// Both mutate `r` directly so partial results survive the outer timeout.
await Promise.all([this.checkAuthStatus(binaryPath, r), this.fetchLatestVersion(r)]);
} else {
// No binary — still check latest version for "install" prompt
await this.fetchLatestVersion(r);
}
}
/**
* Check auth status with retry covers stale lock files after Ctrl+C interruption.
* Wrapped in its own timeout to prevent slow auth from blocking the overall status.
* Mutates `r` directly so results survive even if the outer Promise.all hasn't resolved.
*/
private async checkAuthStatus(binaryPath: string, result: CliInstallationStatus): Promise<void> {
const doCheck = async (): Promise<void> => {
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
try {
const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], {
timeout: VERSION_TIMEOUT_MS,
env: buildChildEnv(),
});
const auth = JSON.parse(authStdout.trim()) as {
loggedIn?: boolean;
authMethod?: string;
};
result.authLoggedIn = auth.loggedIn === true; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
result.authMethod = auth.authMethod ?? null; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
logger.info(
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` +
(authAttempt > 1 ? ` (attempt ${authAttempt})` : '')
);
return;
} catch (err) {
if (authAttempt < AUTH_STATUS_MAX_RETRIES) {
logger.warn(
`Auth status check failed (attempt ${authAttempt}/${AUTH_STATUS_MAX_RETRIES}), ` +
`retrying in ${AUTH_STATUS_RETRY_DELAY_MS}ms: ${getErrorMessage(err)}`
);
await new Promise((resolve) => setTimeout(resolve, AUTH_STATUS_RETRY_DELAY_MS));
} else {
logger.warn(
`Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${getErrorMessage(err)}`
);
result.authLoggedIn = false; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
}
}
}
};
// Own timeout so slow auth doesn't eat the overall getStatus budget
await Promise.race([
doCheck(),
new Promise<void>((resolve) =>
setTimeout(() => {
logger.warn(`Auth status check timed out after ${AUTH_TOTAL_TIMEOUT_MS}ms`);
resolve();
}, AUTH_TOTAL_TIMEOUT_MS)
),
]);
}
/**
* Fetch latest CLI version from GCS and update the result object.
*/
private async fetchLatestVersion(result: CliInstallationStatus): Promise<void> {
try {
const latestRaw = await fetchText(`${GCS_BASE}/latest`);
r.latestVersion = normalizeVersion(latestRaw);
logger.info(`Latest CLI version: "${latestRaw.trim()}" → normalized: "${r.latestVersion}"`);
result.latestVersion = normalizeVersion(latestRaw); // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
logger.info(
`Latest CLI version: "${latestRaw.trim()}" → normalized: "${result.latestVersion}"`
);
if (r.installedVersion && r.latestVersion) {
r.updateAvailable = isVersionOlder(r.installedVersion, r.latestVersion);
if (result.installedVersion && result.latestVersion) {
result.updateAvailable = isVersionOlder(result.installedVersion, result.latestVersion); // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
logger.info(
`Update available: ${r.updateAvailable} (${r.installedVersion}${r.latestVersion})`
`Update available: ${result.updateAvailable} (${result.installedVersion}${result.latestVersion})`
);
}
} catch (err) {

View file

@ -40,6 +40,12 @@ export interface NotificationConfig {
snoozeMinutes: number; // Default snooze duration
/** Whether to include errors from subagent sessions */
includeSubagentErrors: boolean;
/** Whether to show native OS notifications when teammates send messages to the team lead */
notifyOnLeadInbox: boolean;
/** Whether to show native OS notifications when teammates send messages to you (the user) */
notifyOnUserInbox: boolean;
/** Whether to show native OS notifications when a task needs user clarification */
notifyOnClarifications: boolean;
/** Notification triggers - define when to generate notifications */
triggers: NotificationTrigger[];
}
@ -184,6 +190,8 @@ export interface GeneralConfig {
agentLanguage: string;
autoExpandAIGroups: boolean;
useNativeTitleBar: boolean;
/** Paths manually added via "Select Folder" that persist across app restarts */
customProjectPaths: string[];
}
export interface DisplayConfig {
@ -243,6 +251,9 @@ const DEFAULT_CONFIG: AppConfig = {
snoozedUntil: null,
snoozeMinutes: 30,
includeSubagentErrors: true,
notifyOnLeadInbox: false,
notifyOnUserInbox: true,
notifyOnClarifications: true,
triggers: DEFAULT_TRIGGERS,
},
general: {
@ -254,6 +265,7 @@ const DEFAULT_CONFIG: AppConfig = {
agentLanguage: 'system',
autoExpandAIGroups: false,
useNativeTitleBar: false,
customProjectPaths: [],
},
display: {
showTimestamps: true,
@ -407,6 +419,7 @@ export class ConfigManager {
private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {
const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);
const loadedTriggers = loadedNotifications.triggers ?? [];
const mergedGeneral: GeneralConfig = {
...DEFAULT_CONFIG.general,
...(loaded.general ?? {}),
@ -839,6 +852,58 @@ export class ConfigManager {
this.saveConfig();
}
// ===========================================================================
// Custom Project Path Management
// ===========================================================================
/**
* Adds a custom project path (from "Select Folder" dialog).
* Persisted across app restarts.
* @param projectPath - Absolute filesystem path to the project
*/
addCustomProjectPath(projectPath: string): void {
if (!projectPath || projectPath.trim().length === 0) {
return;
}
const normalized = path.normalize(projectPath.trim());
if (!path.isAbsolute(normalized)) {
return;
}
if (this.config.general.customProjectPaths.includes(normalized)) {
return;
}
this.config.general.customProjectPaths.push(normalized);
this.saveConfig();
logger.info(`Custom project path added: ${normalized}`);
}
/**
* Removes a custom project path.
* @param projectPath - The path to remove
*/
removeCustomProjectPath(projectPath: string): void {
const normalized = path.normalize(projectPath.trim());
const index = this.config.general.customProjectPaths.indexOf(normalized);
if (index === -1) {
return;
}
this.config.general.customProjectPaths.splice(index, 1);
this.saveConfig();
logger.info(`Custom project path removed: ${normalized}`);
}
/**
* Gets all custom project paths.
* @returns Array of absolute filesystem paths
*/
getCustomProjectPaths(): string[] {
return [...this.config.general.customProjectPaths];
}
// ===========================================================================
// SSH Profile Management
// ===========================================================================

View file

@ -12,6 +12,7 @@
* - Emit IPC events to renderer: notification:new, notification:updated
*/
import { getAppIconPath } from '@main/utils/appIcon';
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { type BrowserWindow, Notification } from 'electron';
@ -394,11 +395,13 @@ export class NotificationManager extends EventEmitter {
const config = this.configManager.getConfig();
const iconPath = getAppIconPath();
const notification = new Notification({
title: 'Claude Code Error',
subtitle: error.context.projectName,
body: error.message.slice(0, 200),
sound: config.notifications.soundEnabled ? 'default' : undefined,
...(iconPath ? { icon: iconPath } : {}),
});
notification.on('click', () => {

View file

@ -18,6 +18,7 @@
import {
AUTO_CLAUDE_DIR,
CCSWITCH_DIR,
CLAUDE_CODE_DIR,
CLAUDE_WORKTREES_DIR,
CONDUCTOR_DIR,
CURSOR_DIR,
@ -242,6 +243,15 @@ class GitIdentityResolver {
return parts[claudeWorktreesIdx + 1];
}
// Pattern 6b: /.claude/worktrees/{name} (Claude Code CLI)
const claudeCodeDirIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeDirIdx >= 0 && parts[claudeCodeDirIdx + 1] === WORKTREES_DIR) {
// Repo is the directory containing .claude
if (claudeCodeDirIdx > 0) {
return parts[claudeCodeDirIdx - 1];
}
}
// Pattern 7: /.ccswitch/worktrees/{repo}/{name}
const ccswitchIdx = parts.indexOf(CCSWITCH_DIR);
if (ccswitchIdx >= 0 && parts[ccswitchIdx + 1] === WORKTREES_DIR) {
@ -282,6 +292,11 @@ class GitIdentityResolver {
if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
// Pattern: .claude/worktrees/{name} (Claude Code CLI worktrees)
const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) {
return true;
}
if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) {
// Subpaths in conductor/workspaces are worktrees
const conductorIdx = parts.indexOf(CONDUCTOR_DIR);
@ -569,6 +584,15 @@ class GitIdentityResolver {
return 'ccswitch';
}
// Pattern: claude-code (Claude Code CLI)
// /Users/.../.claude/worktrees/{name}
{
const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) {
return 'claude-code';
}
}
// Check if it's a standard git repo (only if filesystem exists)
// For deleted repos, we'll return 'git' as fallback since we can't verify
if (await fileExists(path.join(projectPath, '.git'))) {
@ -668,6 +692,19 @@ class GitIdentityResolver {
break;
}
case 'claude-code': {
// Pattern: .claude/worktrees/{name}
// Display: {name} (e.g., "editor-feature")
const claudeCodeDirIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeDirIdx >= 0) {
const worktreesIdx = parts.indexOf(WORKTREES_DIR, claudeCodeDirIdx);
if (worktreesIdx >= 0 && parts[worktreesIdx + 1]) {
return parts[worktreesIdx + 1];
}
}
break;
}
case 'git':
// Standard git worktree - use branch or path-based name
if (isMainWorktree) {

View file

@ -1,3 +1,4 @@
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import { structuredPatch } from 'diff';
import type { SnippetDiff } from '@shared/types';
@ -36,6 +37,7 @@ export class HunkSnippetMatcher {
if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue;
const hunk = patch.hunks[hunkIdx];
const snippetSet = new Set<number>();
const strongMatches = new Set<number>();
// Reconstruct old/new side of hunk INCLUDING context lines.
// Context lines (` ` prefix) are critical — without them, snippets whose
@ -55,9 +57,18 @@ export class HunkSnippetMatcher {
if (this.hasContentOverlap(snippet, oldSideContent, newSideContent)) {
snippetSet.add(sIdx);
}
// Strong match: contextHash matches the hunk's contextual fingerprint.
// This reduces false positives when repeated patterns exist in a file.
if (snippet.contextHash) {
const h = computeDiffContextHash(oldSideContent, newSideContent);
if (h === snippet.contextHash) {
strongMatches.add(sIdx);
}
}
}
mapping.set(hunkIdx, snippetSet);
mapping.set(hunkIdx, strongMatches.size > 0 ? strongMatches : snippetSet);
}
return mapping;
@ -134,17 +145,20 @@ export class HunkSnippetMatcher {
if (!snippet.newString && !snippet.oldString) return false;
if (snippet.type === 'write-new' || snippet.type === 'write-update') {
// For Write: snippet.newString is the full file content — check if hunk's new side is within it
if (snippet.newString && hunkNewSide) {
return snippet.newString.includes(hunkNewSide);
}
// Full-file writes are intentionally excluded from localized hunk↔snippet matching.
// They are handled by whole-file reject logic or hunk-level inverse patch.
return false;
}
// For Edit/MultiEdit: check if snippet falls within hunk's file range
const matchesOld = snippet.oldString ? hunkOldSide.includes(snippet.oldString) : false;
const matchesNew = snippet.newString ? hunkNewSide.includes(snippet.newString) : false;
const hasOld = snippet.oldString.length > 0;
const hasNew = snippet.newString.length > 0;
const matchesOld = hasOld ? hunkOldSide.includes(snippet.oldString) : false;
const matchesNew = hasNew ? hunkNewSide.includes(snippet.newString) : false;
return matchesOld || matchesNew;
// Prefer stricter matching when both sides exist to avoid over-matching.
if (hasOld && hasNew) return matchesOld && matchesNew;
if (hasOld) return matchesOld;
return matchesNew;
}
}

View file

@ -1,6 +1,7 @@
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import { createLogger } from '@shared/utils/logger';
import { applyPatch, structuredPatch } from 'diff';
import { readFile, writeFile } from 'fs/promises';
import { readFile, unlink, writeFile } from 'fs/promises';
import { diff3Merge } from 'node-diff3';
import { HunkSnippetMatcher } from './HunkSnippetMatcher';
@ -247,6 +248,68 @@ export class ReviewApplierService {
const original = fileContent.originalFullContent;
const modified = fileContent.modifiedFullContent;
const rejectedHunkIndices = Object.entries(decision.hunkDecisions)
.filter(([, d]) => d === 'rejected')
.map(([idx]) => parseInt(idx, 10));
const allHunksRejected =
Object.keys(decision.hunkDecisions).length > 0 &&
Object.values(decision.hunkDecisions).every((d) => d === 'rejected');
const hasWriteNewSnippet = fileContent.snippets.some((s) => s.type === 'write-new');
// Special case: rejecting an entirely new file should remove it from disk.
// IMPORTANT: Do NOT delete on partial reject — users may want to keep parts of the new file.
const shouldDeleteNewFile =
fileContent.isNewFile &&
hasWriteNewSnippet &&
original === '' &&
(decision.fileDecision === 'rejected' || allHunksRejected);
if (shouldDeleteNewFile) {
// If we have an expected modified baseline, guard against deleting a user-modified file.
if (modified !== null) {
const conflict = await this.checkConflict(decision.filePath, modified);
if (conflict.hasConflict) {
conflicts++;
errors.push({
filePath: decision.filePath,
error:
'File was modified since review was computed; refusing to delete new file automatically.',
});
continue;
}
} else {
// No baseline — safest behavior is to only treat "already missing" as success.
try {
await readFile(decision.filePath, 'utf8');
} catch {
applied++;
continue;
}
errors.push({
filePath: decision.filePath,
error: 'Cannot delete new file: expected modified content is unavailable.',
});
continue;
}
try {
await unlink(decision.filePath);
applied++;
} catch (err) {
const msg = String(err);
if (msg.includes('ENOENT')) {
applied++;
} else {
errors.push({
filePath: decision.filePath,
error: `Failed to delete new file: ${msg}`,
});
}
}
continue;
}
if (original === null || modified === null) {
errors.push({
filePath: decision.filePath,
@ -275,21 +338,27 @@ export class ReviewApplierService {
}
} else {
// Partial reject — only specific hunks
const rejectedHunkIndices = Object.entries(decision.hunkDecisions)
.filter(([, d]) => d === 'rejected')
.map(([idx]) => parseInt(idx, 10));
if (rejectedHunkIndices.length === 0) {
skipped++;
continue;
}
const mappedRejected =
decision.hunkContextHashes && Object.keys(decision.hunkContextHashes).length > 0
? mapRejectedHunkIndicesByHash(
original,
modified,
rejectedHunkIndices,
decision.hunkContextHashes
)
: rejectedHunkIndices;
const result = await this.rejectHunks(
request.teamName,
decision.filePath,
original,
modified,
rejectedHunkIndices,
mappedRejected,
fileContent.snippets
);
@ -336,7 +405,12 @@ export class ReviewApplierService {
hunkIndices: number[],
snippets: SnippetDiff[]
): RejectResult | null {
const validSnippets = snippets.filter((s) => !s.isError);
// Safety: never use full-file Write snippets for snippet-level rejection.
// They are not localized, and matching a single hunk to a full-file write
// can incorrectly delete/overwrite large parts of the file.
const validSnippets = snippets.filter(
(s) => !s.isError && s.type !== 'write-new' && s.type !== 'write-update'
);
if (validSnippets.length === 0) return null;
// Pass pre-filtered snippets — matcher returns indices relative to this array
@ -347,6 +421,15 @@ export class ReviewApplierService {
validSnippets
);
// Safety: if any requested hunk maps ambiguously, do NOT attempt snippet-level replacement.
// Fall back to hunk-level inverse patch which is positional and safer.
for (const hunkIdx of hunkIndices) {
const set = hunkToSnippets.get(hunkIdx);
if (set?.size !== 1) {
return null;
}
}
// Collect all unique snippet indices to reject
const snippetIndices = new Set<number>();
for (const indices of hunkToSnippets.values()) {
@ -450,6 +533,55 @@ export class ReviewApplierService {
}
}
function buildHunkHashIndexMap(original: string, modified: string): Map<string, number[]> {
const patch = structuredPatch('file', 'file', original, modified);
const hunks = patch.hunks ?? [];
const map = new Map<string, number[]>();
for (let i = 0; i < hunks.length; i++) {
const hunk = hunks[i];
const oldSideContent = hunk.lines
.filter((l) => !l.startsWith('+'))
.map((l) => l.slice(1))
.join('\n');
const newSideContent = hunk.lines
.filter((l) => !l.startsWith('-'))
.map((l) => l.slice(1))
.join('\n');
const hash = computeDiffContextHash(oldSideContent, newSideContent);
const arr = map.get(hash);
if (arr) arr.push(i);
else map.set(hash, [i]);
}
return map;
}
function mapRejectedHunkIndicesByHash(
original: string,
modified: string,
rejectedIndices: number[],
hunkContextHashes: Record<number, string>
): number[] {
const hashMap = buildHunkHashIndexMap(original, modified);
const out = new Set<number>();
for (const idx of rejectedIndices) {
const hash = hunkContextHashes[idx];
if (!hash) {
out.add(idx);
continue;
}
const candidates = hashMap.get(hash);
if (candidates?.length === 1) {
out.add(candidates[0]);
} else {
// Ambiguous or missing — fall back to index to preserve prior behavior.
out.add(idx);
}
}
return [...out].sort((a, b) => a - b);
}
// ── Module-level helpers ──
/**

View file

@ -12,6 +12,8 @@ const logger = createLogger('ReviewDecisionStore');
export interface ReviewDecisionsData {
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
/** filePath -> (hunkIndex -> contextHash) */
hunkContextHashesByFile?: Record<string, Record<number, string>>;
updatedAt: string;
}
@ -30,6 +32,7 @@ export class ReviewDecisionStore {
): Promise<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null> {
const filePath = this.getFilePath(teamName, scopeKey);
@ -62,8 +65,12 @@ export class ReviewDecisionStore {
data.hunkDecisions && typeof data.hunkDecisions === 'object' ? data.hunkDecisions : {};
const fileDecisions: Record<string, HunkDecision> =
data.fileDecisions && typeof data.fileDecisions === 'object' ? data.fileDecisions : {};
const hunkContextHashesByFile: Record<string, Record<number, string>> | undefined =
data.hunkContextHashesByFile && typeof data.hunkContextHashesByFile === 'object'
? data.hunkContextHashesByFile
: undefined;
return { hunkDecisions, fileDecisions };
return { hunkDecisions, fileDecisions, hunkContextHashesByFile };
}
async save(
@ -72,12 +79,14 @@ export class ReviewDecisionStore {
data: {
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
}
): Promise<void> {
try {
const payload: ReviewDecisionsData = {
hunkDecisions: data.hunkDecisions,
fileDecisions: data.fileDecisions,
hunkContextHashesByFile: data.hunkContextHashesByFile,
updatedAt: new Date().toISOString(),
};
await atomicWriteAsync(

View file

@ -9,7 +9,6 @@ import { version as APP_VERSION } from '../../../../package.json';
import { atomicWriteAsync } from './atomicWrite';
const TOOL_FILE_NAME = 'teamctl.js';
const TOOL_VERSION = 11;
function buildTeamCtlScript(version: string): string {
const script = String.raw`#!/usr/bin/env node
@ -196,10 +195,37 @@ function writeTask(taskPath, task) {
return verify;
}
function applyWorkIntervalsForStatusTransition(task, prevStatus, nextStatus, now) {
var wasInProgress = prevStatus === 'in_progress';
var isInProgress = nextStatus === 'in_progress';
var intervals = Array.isArray(task.workIntervals) ? task.workIntervals.slice() : [];
var last = intervals.length ? intervals[intervals.length - 1] : null;
if (!wasInProgress && isInProgress) {
if (!last || typeof last.completedAt === 'string') {
intervals.push({ startedAt: now });
}
} else if (wasInProgress && !isInProgress) {
// Close the most recent open interval (if any).
for (var i = intervals.length - 1; i >= 0; i--) {
if (intervals[i] && typeof intervals[i].startedAt === 'string' && !intervals[i].completedAt) {
intervals[i].completedAt = now;
break;
}
}
}
if (intervals.length > 0) task.workIntervals = intervals;
else delete task.workIntervals;
}
function setTaskStatus(paths, taskId, status) {
const normalized = normalizeStatus(status);
if (!normalized) die('Invalid status: ' + String(status));
const { taskPath, task } = readTask(paths, taskId);
var prev = task.status;
var now = nowIso();
applyWorkIntervalsForStatusTransition(task, prev, normalized, now);
task.status = normalized;
writeTask(taskPath, task);
}
@ -245,6 +271,7 @@ function addTaskComment(paths, taskId, flags) {
id: commentId,
author: from,
text: text,
type: 'regular',
createdAt: nowIso(),
};
task.comments = existing.concat([comment]);
@ -306,6 +333,103 @@ function updateHighwatermark(paths, taskId) {
atomicWrite(hwmPath, String(taskId));
}
function parseIdList(value) {
if (!value || value === true) return [];
var ids = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
for (var k = 0; k < ids.length; k++) {
if (!/^\d+$/.test(ids[k])) die('Invalid task ID in list: ' + ids[k]);
}
return ids;
}
function taskExists(paths, taskId) {
try {
fs.accessSync(path.join(paths.tasksDir, String(taskId) + '.json'), fs.constants.F_OK);
return true;
} catch (e) { return false; }
}
function readTaskObject(paths, taskId) {
var taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
var t = readJson(taskPath, null);
if (!t) die('Task not found: #' + taskId);
return { task: t, taskPath: taskPath };
}
function wouldCreateBlockCycle(paths, sourceId, targetId) {
var visited = {};
var stack = [String(targetId)];
while (stack.length > 0) {
var current = stack.pop();
if (current === String(sourceId)) return true;
if (visited[current]) continue;
visited[current] = true;
try {
var t = readJson(path.join(paths.tasksDir, current + '.json'), null);
if (t && Array.isArray(t.blockedBy)) {
for (var i = 0; i < t.blockedBy.length; i++) stack.push(String(t.blockedBy[i]));
}
} catch (e) { /* skip */ }
}
return false;
}
function linkTasks(paths, taskId, targetId, type) {
var id = String(taskId), target = String(targetId);
if (id === target) die('Cannot link a task to itself');
if (!taskExists(paths, id)) die('Task not found: #' + id);
if (!taskExists(paths, target)) die('Task not found: #' + target);
if (type === 'blocked-by') {
if (wouldCreateBlockCycle(paths, id, target))
die('Circular dependency: #' + target + ' already depends on #' + id);
var refA = readTaskObject(paths, id);
var bb = Array.isArray(refA.task.blockedBy) ? refA.task.blockedBy : [];
if (!bb.includes(target)) { refA.task.blockedBy = bb.concat([target]); atomicWrite(refA.taskPath, JSON.stringify(refA.task, null, 2)); }
var refB = readTaskObject(paths, target);
var bl = Array.isArray(refB.task.blocks) ? refB.task.blocks : [];
if (!bl.includes(id)) { refB.task.blocks = bl.concat([id]); atomicWrite(refB.taskPath, JSON.stringify(refB.task, null, 2)); }
} else if (type === 'blocks') {
linkTasks(paths, target, id, 'blocked-by');
return;
} else if (type === 'related') {
var rA = readTaskObject(paths, id);
var relA = Array.isArray(rA.task.related) ? rA.task.related : [];
if (!relA.includes(target)) { rA.task.related = relA.concat([target]); atomicWrite(rA.taskPath, JSON.stringify(rA.task, null, 2)); }
var rB = readTaskObject(paths, target);
var relB = Array.isArray(rB.task.related) ? rB.task.related : [];
if (!relB.includes(id)) { rB.task.related = relB.concat([id]); atomicWrite(rB.taskPath, JSON.stringify(rB.task, null, 2)); }
}
}
function unlinkTasks(paths, taskId, targetId, type) {
var id = String(taskId), target = String(targetId);
if (!taskExists(paths, id)) die('Task not found: #' + id);
if (type === 'blocked-by') {
var refA = readTaskObject(paths, id);
refA.task.blockedBy = (Array.isArray(refA.task.blockedBy) ? refA.task.blockedBy : []).filter(function(x) { return x !== target; });
atomicWrite(refA.taskPath, JSON.stringify(refA.task, null, 2));
if (taskExists(paths, target)) {
var refB = readTaskObject(paths, target);
refB.task.blocks = (Array.isArray(refB.task.blocks) ? refB.task.blocks : []).filter(function(x) { return x !== id; });
atomicWrite(refB.taskPath, JSON.stringify(refB.task, null, 2));
}
} else if (type === 'blocks') {
unlinkTasks(paths, target, id, 'blocked-by');
return;
} else if (type === 'related') {
var rA = readTaskObject(paths, id);
rA.task.related = (Array.isArray(rA.task.related) ? rA.task.related : []).filter(function(x) { return x !== target; });
atomicWrite(rA.taskPath, JSON.stringify(rA.task, null, 2));
if (taskExists(paths, target)) {
var rB = readTaskObject(paths, target);
rB.task.related = (Array.isArray(rB.task.related) ? rB.task.related : []).filter(function(x) { return x !== id; });
atomicWrite(rB.taskPath, JSON.stringify(rB.task, null, 2));
}
}
}
function createTask(paths, flags) {
const subject = typeof flags.subject === 'string' ? flags.subject.trim() : '';
if (!subject) die('Missing --subject');
@ -317,7 +441,6 @@ function createTask(paths, flags) {
: '';
const owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined;
const explicitStatus = typeof flags.status === 'string' ? flags.status : '';
const status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending');
const activeForm =
typeof flags.activeForm === 'string'
? flags.activeForm
@ -325,6 +448,18 @@ function createTask(paths, flags) {
? flags['active-form']
: undefined;
var blockedByIds = parseIdList(flags['blocked-by']);
var relatedIds = parseIdList(flags.related);
// Default status rule:
// - explicit --status always wins
// - tasks with dependencies should start as pending, even if assigned (owner)
// - otherwise, assigned tasks default to in_progress, unassigned to pending
const status =
normalizeStatus(explicitStatus) ||
(blockedByIds.length > 0 ? 'pending' : owner ? 'in_progress' : 'pending');
for (var v = 0; v < blockedByIds.length; v++) { if (!taskExists(paths, blockedByIds[v])) die('Blocked-by task not found: #' + blockedByIds[v]); }
for (var w = 0; w < relatedIds.length; w++) { if (!taskExists(paths, relatedIds[w])) die('Related task not found: #' + relatedIds[w]); }
ensureDir(paths.tasksDir);
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
let nextId;
@ -333,6 +468,7 @@ function createTask(paths, flags) {
while (true) {
nextId = getNextTaskId(paths);
taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
var createdAt = nowIso();
task = {
id: nextId,
subject,
@ -341,8 +477,11 @@ function createTask(paths, flags) {
owner,
createdBy: from,
status,
createdAt: createdAt,
workIntervals: status === 'in_progress' ? [{ startedAt: createdAt }] : undefined,
blocks: [],
blockedBy: [],
blockedBy: blockedByIds,
related: relatedIds.length > 0 ? relatedIds : undefined,
};
try {
const fd = fs.openSync(taskPath, 'wx');
@ -357,6 +496,20 @@ function createTask(paths, flags) {
}
}
updateHighwatermark(paths, nextId);
// Set reverse links for blockedBy (target.blocks += nextId)
for (var bi = 0; bi < blockedByIds.length; bi++) {
var dep = readTaskObject(paths, blockedByIds[bi]);
var depBl = Array.isArray(dep.task.blocks) ? dep.task.blocks : [];
if (!depBl.includes(nextId)) { dep.task.blocks = depBl.concat([nextId]); atomicWrite(dep.taskPath, JSON.stringify(dep.task, null, 2)); }
}
// Set reverse links for related (bidirectional)
for (var ri = 0; ri < relatedIds.length; ri++) {
var rel = readTaskObject(paths, relatedIds[ri]);
var relL = Array.isArray(rel.task.related) ? rel.task.related : [];
if (!relL.includes(nextId)) { rel.task.related = relL.concat([nextId]); atomicWrite(rel.taskPath, JSON.stringify(rel.task, null, 2)); }
}
return task;
}
@ -447,18 +600,32 @@ function sendInboxMessage(paths, teamName, flags) {
function reviewApprove(paths, teamName, taskId, flags) {
setKanbanColumn(paths, teamName, taskId, 'approved');
const notify = flags.notify === true || flags['notify-owner'] === true;
if (!notify) return;
const { task } = readTask(paths, taskId);
if (!task.owner) return;
const { taskPath, task } = readTask(paths, taskId);
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
const text = note
// Record review comment in task.comments
var existing = Array.isArray(task.comments) ? task.comments : [];
var reviewCommentId = crypto.randomUUID
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
task.comments = existing.concat([{
id: reviewCommentId,
author: from,
text: note || 'Approved',
type: 'review_approved',
createdAt: nowIso(),
}]);
writeTask(taskPath, task);
const notify = flags.notify === true || flags['notify-owner'] === true;
if (!notify || !task.owner) return;
const inboxText = note
? 'Task #' + String(taskId) + ' approved.\n\n' + note
: 'Task #' + String(taskId) + ' approved.';
sendInboxMessage(paths, teamName, {
to: task.owner,
text,
text: inboxText,
summary: 'Approved #' + String(taskId),
from,
});
@ -469,12 +636,29 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
const { taskPath, task } = readTask(paths, taskId);
if (!task.owner) die('No owner found for task ' + String(taskId));
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
clearKanban(paths, teamName, taskId);
var now = nowIso();
applyWorkIntervalsForStatusTransition(task, task.status, 'in_progress', now);
task.status = 'in_progress';
// Record review comment in task.comments
var existing = Array.isArray(task.comments) ? task.comments : [];
var reviewCommentId = crypto.randomUUID
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
task.comments = existing.concat([{
id: reviewCommentId,
author: from,
text: comment || 'Reviewer requested changes.',
type: 'review_request',
createdAt: now,
}]);
writeTask(taskPath, task);
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const text =
const inboxText =
'Task #' +
String(taskId) +
' needs fixes.\n\n' +
@ -483,7 +667,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
'Please fix and mark it as completed when ready.';
sendInboxMessage(paths, teamName, {
to: task.owner,
text,
text: inboxText,
summary: 'Fix request for #' + String(taskId),
from,
});
@ -639,6 +823,9 @@ function taskBriefing(paths, teamName, flags) {
if (t.description && t.description !== t.subject) {
parts.push(' Description: ' + t.description.slice(0, 500));
}
if (t.blocks && t.blocks.length > 0) {
parts.push(' Blocks: ' + t.blocks.map(function(id) { return '#' + id; }).join(', '));
}
if (t.blockedBy && t.blockedBy.length > 0) {
parts.push(' Blocked by: ' + t.blockedBy.map(function(id) { return '#' + id; }).join(', '));
}
@ -704,7 +891,13 @@ function printHelp() {
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
' node teamctl.js task complete <id> [--team <team>]',
' node teamctl.js task start <id> [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--blocked-by 2,3] [--related 5] [--status ...] [--notify --from "member"] [--team <team>]',
' node teamctl.js task link <id> --blocked-by <targetId> [--team <team>]',
' node teamctl.js task link <id> --blocks <targetId> [--team <team>]',
' node teamctl.js task link <id> --related <targetId> [--team <team>]',
' node teamctl.js task unlink <id> --blocked-by <targetId> [--team <team>]',
' node teamctl.js task unlink <id> --blocks <targetId> [--team <team>]',
' node teamctl.js task unlink <id> --related <targetId> [--team <team>]',
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--team <team>]',
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
@ -877,6 +1070,30 @@ async function main() {
taskBriefing(paths, teamName, args.flags);
return;
}
if (action === 'link') {
var linkId = rest[0] || args.flags.id;
if (!linkId) die('Usage: task link <id> --blocked-by|--blocks|--related <targetId>');
var linkBbF = args.flags['blocked-by'], linkBlF = args.flags.blocks, linkRelF = args.flags.related;
var linkCnt = (linkBbF ? 1 : 0) + (linkBlF ? 1 : 0) + (linkRelF ? 1 : 0);
if (linkCnt !== 1) die('Specify exactly one: --blocked-by, --blocks, or --related');
var linkTp = linkBbF ? 'blocked-by' : linkBlF ? 'blocks' : 'related';
var linkTv = linkBbF || linkBlF || linkRelF;
linkTasks(paths, String(linkId), String(linkTv), linkTp);
process.stdout.write('OK task #' + linkId + ' ' + linkTp + ' #' + linkTv + '\n');
return;
}
if (action === 'unlink') {
var unlinkId = rest[0] || args.flags.id;
if (!unlinkId) die('Usage: task unlink <id> --blocked-by|--blocks|--related <targetId>');
var unlinkBbF = args.flags['blocked-by'], unlinkBlF = args.flags.blocks, unlinkRelF = args.flags.related;
var unlinkCnt = (unlinkBbF ? 1 : 0) + (unlinkBlF ? 1 : 0) + (unlinkRelF ? 1 : 0);
if (unlinkCnt !== 1) die('Specify exactly one: --blocked-by, --blocks, or --related');
var unlinkTp = unlinkBbF ? 'blocked-by' : unlinkBlF ? 'blocks' : 'related';
var unlinkTv = unlinkBbF || unlinkBlF || unlinkRelF;
unlinkTasks(paths, String(unlinkId), String(unlinkTv), unlinkTp);
process.stdout.write('OK task #' + unlinkId + ' unlinked ' + unlinkTp + ' #' + unlinkTv + '\n');
return;
}
die('Unknown task action: ' + String(action));
}

View file

@ -146,14 +146,16 @@ export class TeamConfigReader {
return null;
}
// Case-insensitive dedup: key is lowercase name, value keeps the original casing
const memberMap = new Map<string, TeamSummaryMember>();
const addMember = (m: TeamMember): void => {
const mergeMember = (m: TeamMember): void => {
const name = m.name?.trim();
if (!name) return;
const existing = memberMap.get(name);
memberMap.set(name, {
name,
const key = name.toLowerCase();
const existing = memberMap.get(key);
memberMap.set(key, {
name: existing?.name ?? name,
role: m.role?.trim() || existing?.role,
color: m.color?.trim() || existing?.color,
});
@ -162,11 +164,24 @@ export class TeamConfigReader {
if (config && Array.isArray(config.members)) {
for (const member of config.members) {
if (member && typeof member.name === 'string') {
addMember(member);
mergeMember(member);
}
}
}
// Also read members.meta.json — UI-created teams store members there,
// and CLI-created teams may have additional members added via the UI.
try {
const metaMembers = await this.membersMetaStore.getMembers(teamName);
for (const member of metaMembers) {
if (!member.removedAt) {
mergeMember(member);
}
}
} catch {
// best-effort — don't fail listing if meta file is broken
}
const members = Array.from(memberMap.values());
const summary: TeamSummary = {
teamName,

View file

@ -783,6 +783,24 @@ export class TeamDataService {
await this.taskWriter.setNeedsClarification(teamName, taskId, value);
}
async addTaskRelationship(
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> {
await this.taskWriter.addRelationship(teamName, taskId, targetId, type);
}
async removeTaskRelationship(
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> {
await this.taskWriter.removeRelationship(teamName, taskId, targetId, type);
}
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
const comment = await this.taskWriter.addComment(teamName, taskId, text);
@ -938,11 +956,15 @@ export class TeamDataService {
await fs.promises.mkdir(tasksDir, { recursive: true });
const joinedAt = Date.now();
const config = {
const config: Record<string, unknown> = {
name: request.displayName?.trim() || request.teamName,
description: request.description?.trim() || undefined,
color: request.color?.trim() || undefined,
};
if (request.cwd?.trim()) {
config.projectPath = request.cwd.trim();
config.projectPathHistory = [request.cwd.trim()];
}
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
await this.membersMetaStore.writeMembers(

View file

@ -110,7 +110,12 @@ export class TeamMemberLogsFinder {
async findLogsForTask(
teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
}
): Promise<MemberLogSummary[]> {
const discovery = await this.discoverProjectSessions(teamName);
if (!discovery) return [];
@ -171,6 +176,56 @@ export class TeamMemberLogsFinder {
options.owner.trim().length > 0;
if (includeOwnerSessions) {
const ownerLogs = await this.findMemberLogs(teamName, options.owner!.trim());
const TASK_LOG_INTERVAL_GRACE_MS = 10_000;
const fallbackRecentMs = 30 * 60_000; // if caller doesn't supply intervals/since, avoid pulling in old owner history
const now = Date.now();
const normalizedIntervals = Array.isArray(options?.intervals)
? options.intervals
.map((i) => {
const startMs = Date.parse(i.startedAt);
const endMsRaw =
typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN;
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
return Number.isFinite(startMs) ? { startMs, endMs } : null;
})
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
: [];
// Back-compat: single since timestamp -> treat as open interval.
const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN;
const sinceMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null;
const effectiveIntervals =
normalizedIntervals.length > 0
? normalizedIntervals
: sinceMs != null
? [{ startMs: sinceMs, endMs: null }]
: [];
const overlapsAnyInterval = (logStartMs: number, logEndMs: number): boolean => {
for (const it of effectiveIntervals) {
const start = it.startMs - TASK_LOG_INTERVAL_GRACE_MS;
const end = (it.endMs ?? now) + TASK_LOG_INTERVAL_GRACE_MS;
if (logStartMs <= end && logEndMs >= start) return true;
}
return false;
};
const filteredOwnerLogs = ownerLogs.filter((log) => {
if (log.isOngoing) return true;
const startMs = new Date(log.startTime).getTime();
if (!Number.isFinite(startMs)) return false;
const durationMs =
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
const endMs = startMs + durationMs;
if (effectiveIntervals.length > 0) {
return overlapsAnyInterval(startMs, endMs);
}
return startMs >= now - fallbackRecentMs;
});
const seen = new Set<string>();
for (const log of results) {
const key =
@ -179,7 +234,7 @@ export class TeamMemberLogsFinder {
: `lead:${log.sessionId}`;
seen.add(key);
}
for (const log of ownerLogs) {
for (const log of filteredOwnerLogs) {
const key =
log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
@ -409,12 +464,17 @@ export class TeamMemberLogsFinder {
const patterns: RegExp[] = [
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
new RegExp(`#${escaped}\\b`),
];
if (numericTaskId) {
patterns.push(
new RegExp(`"task_id"\\s*:\\s*${numericTaskId}\\b`),
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`)
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`),
// Support teamctl command lines (may appear in tool output).
// Example: node ".../teamctl.js" --team "t" task start 10
new RegExp(
`\\bteamctl(?:\\.js)?\\b.{0,250}\\b(?:task|review)\\b.{0,250}\\b${numericTaskId}\\b`,
'i'
)
);
}
try {

View file

@ -59,6 +59,8 @@ const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const SHELL_ENV_TIMEOUT_MS = 12000;
const CLI_PREPARE_TIMEOUT_MS = 10000;
const PREFLIGHT_TIMEOUT_MS = 30000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
const KEYCHAIN_TIMEOUT_MS = 5000;
const FS_MONITOR_POLL_MS = 2000;
const TASK_WAIT_FALLBACK_MS = 15_000;
@ -144,6 +146,18 @@ interface ProvisioningRun {
detectedSessionId: string | null;
/** Lead process activity: 'active' during turn processing, 'idle' waiting for input, 'offline' after exit. */
leadActivityState: LeadActivityState;
/** Whether an auth failure retry was already attempted for this run. */
authFailureRetried: boolean;
/** Set to true while auth-failure respawn is in progress to prevent duplicate handling. */
authRetryInProgress: boolean;
/** Saved spawn context for auth-failure respawn. */
spawnContext: {
claudePath: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
prompt: string;
} | null;
}
type LeadActivityState = 'active' | 'idle' | 'offline';
@ -336,6 +350,9 @@ function buildTaskStatusProtocol(teamName: string): string {
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "<your-name>"
d) Do NOT set clarification to "user" yourself only the team lead escalates to the user.
11. DEPENDENCY AWARENESS:
When your task has blockedBy dependencies, check if they are completed before starting.
When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed.
Failure to follow this protocol means the task board will show incorrect status.`);
}
@ -358,11 +375,31 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
`Internal task board tooling (teamctl.js):`,
`- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).`,
``,
`Parallelization guideline (IMPORTANT):`,
`- If a task is genuinely parallelizable, split it into multiple smaller tasks owned by different members.`,
` - Prefer splitting by independent deliverables (e.g. frontend/backend, API/UI, parsing/rendering, tests/docs) rather than arbitrary slices.`,
` - Use --blocked-by only when one piece truly cannot start without another; otherwise link with --related.`,
` - Do NOT split when work is inherently sequential, requires one person to keep consistent context, or the overhead would exceed the benefit.`,
` - When splitting, make each task have a clear completion criterion and a single accountable owner.`,
``,
`Task board operations — use teamctl.js via Bash:`,
`- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"`,
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
`- Create with deps (blocked work MUST be pending): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --status pending --owner "<member>" --notify --from "${leadName}"`,
`- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --blocked-by <targetId>`,
`- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --related <targetId>`,
`- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink <id> --blocked-by <targetId>`,
``,
`Dependency guidelines:`,
`- Use --blocked-by when a task cannot start until another is done.`,
`- If you set --blocked-by, create the task in pending (use --status pending). Do NOT put blocked tasks into in_progress.`,
`- Use --related to link related work (e.g. frontend + backend) without blocking.`,
`- Review tasks: Prefer NOT creating a separate "review task". Reviews apply to the work task (#X) via: review approve/request-changes #X.`,
` - If you must create a separate review reminder/assignment task, keep it pending and link it to #X with --related (and optionally --blocked-by #X if it truly cannot start yet).`,
` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`,
`- Avoid over-specifying. Only add dependencies when execution order matters.`,
``,
`Notification policy:`,
`- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`,
@ -431,7 +468,10 @@ function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string
const lines = activeTasks.map((t) => {
const desc = t.description ? `${t.description.slice(0, 120)}` : '';
return ` - #${t.id} [${t.status}] ${t.subject}${desc}`;
const deps = t.blockedBy?.length
? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]`
: '';
return ` - #${t.id} [${t.status}] ${t.subject}${deps}${desc}`;
});
return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`;
}
@ -446,7 +486,10 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
const lines = active.map((t) => {
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
const desc = t.description ? `${t.description.slice(0, 120)}` : '';
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${desc}`;
const deps = t.blockedBy?.length
? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]`
: '';
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${deps}${desc}`;
});
return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`;
}
@ -521,6 +564,15 @@ ${processRegistration}
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked create tasks on the team board.
- Prefer fewer, broader tasks over many micro-tasks.
- Avoid duplicate notifications for the same assignment.
- When tasks have natural ordering (e.g. setup implementation testing), use --blocked-by.
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
- Review guidance:
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
- Use --related to connect it to #X (non-blocking link).
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
- There is no automatic status transition when dependencies resolve the owner must explicitly start it (task start / set-status in_progress) when ready.
- Use --related to connect tasks working on the same feature without blocking.
4) After all steps, output a short summary.
@ -872,6 +924,231 @@ export class TeamProvisioningService {
);
}
/**
* Detects auth failure keywords in stderr/stdout during provisioning.
* On first detection: kills process, waits, and respawns automatically.
* On second detection (after retry): fails fast with a clear error.
*/
private handleAuthFailureInOutput(run: ProvisioningRun, text: string, source: string): void {
if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return;
if (!this.isAuthFailureWarning(text)) return;
if (!run.authFailureRetried) {
logger.warn(
`[${run.teamName}] Auth failure detected in ${source} during provisioning — ` +
`will kill process and retry after ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms`
);
run.authRetryInProgress = true;
void this.respawnAfterAuthFailure(run);
} else {
logger.error(`[${run.teamName}] Auth failure detected in ${source} after retry — giving up`);
run.processKilled = true;
killProcessTree(run.child);
const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', {
error:
'Claude CLI is not authenticated. Run `claude` in a terminal to complete login, ' +
'or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN and try again.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
}
}
/**
* Kills the current process, waits for lock release, and respawns with saved context.
* Reattaches all stream listeners and resends the prompt.
*/
private async respawnAfterAuthFailure(run: ProvisioningRun): Promise<void> {
const ctx = run.spawnContext;
if (!ctx) {
logger.error(`[${run.teamName}] Cannot respawn — no spawn context saved`);
run.authRetryInProgress = false;
return;
}
// Tear down current process without full cleanupRun (keep run alive)
if (run.timeoutHandle) {
clearTimeout(run.timeoutHandle);
run.timeoutHandle = null;
}
this.stopFilesystemMonitor(run);
if (run.child) {
run.child.stdout?.removeAllListeners('data');
run.child.stderr?.removeAllListeners('data');
run.child.removeAllListeners('error');
run.child.removeAllListeners('exit');
killProcessTree(run.child);
run.child = null;
}
// Reset buffers for fresh attempt
run.stdoutBuffer = '';
run.stderrBuffer = '';
run.authFailureRetried = true;
updateProgress(run, 'spawning', 'Auth failed — retrying after short delay');
run.onProgress(run.progress);
await sleep(PREFLIGHT_AUTH_RETRY_DELAY_MS);
if (run.cancelRequested) {
run.authRetryInProgress = false;
return;
}
// Respawn
let child: ReturnType<typeof spawn>;
try {
child = spawnCli(ctx.claudePath, ctx.args, {
cwd: ctx.cwd,
env: { ...ctx.env },
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
run.authRetryInProgress = false;
const progress = updateProgress(run, 'failed', 'Failed to respawn Claude CLI', {
error: error instanceof Error ? error.message : String(error),
});
run.onProgress(progress);
this.cleanupRun(run);
return;
}
logger.info(
`[${run.teamName}] Respawned CLI process after auth failure (pid=${child.pid ?? '?'})`
);
run.child = child;
run.authRetryInProgress = false;
updateProgress(run, 'spawning', 'CLI respawned — sending prompt', {
pid: child.pid ?? undefined,
});
run.onProgress(run.progress);
// Resend prompt
if (child.stdin?.writable) {
const message = JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: ctx.prompt }],
},
});
child.stdin.write(message + '\n');
}
// Reattach stdout handler
this.attachStdoutHandler(run);
// Reattach stderr handler
this.attachStderrHandler(run);
// Restart filesystem monitor for createTeam (launch skips it)
if (!run.isLaunch) {
this.startFilesystemMonitor(run, run.request);
} else {
updateProgress(run, 'monitoring', 'CLI running — reconnecting with teammates');
run.onProgress(run.progress);
}
// Restart timeout
run.timeoutHandle = setTimeout(() => {
if (!run.processKilled && !run.provisioningComplete) {
run.processKilled = true;
run.finalizingByTimeout = true;
void (async () => {
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
run.child?.stdin?.end();
killProcessTree(run.child);
if (readyOnTimeout) return;
const hint = run.isLaunch ? ' (launch)' : '';
const progress = updateProgress(run, 'failed', `Timed out waiting for CLI${hint}`, {
error: `Timed out waiting for CLI${hint}.`,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
})();
}
}, RUN_TIMEOUT_MS);
child.once('error', (error) => {
const hint = run.isLaunch ? ' (launch)' : '';
const progress = updateProgress(run, 'failed', `Failed to start Claude CLI${hint}`, {
error: error.message,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
});
child.once('exit', (code) => {
void this.handleProcessExit(run, code);
});
}
/** Attaches the stdout stream-json parser to the current child process. */
private attachStdoutHandler(run: ProvisioningRun): void {
const child = run.child;
if (!child?.stdout) return;
let stdoutLineBuf = '';
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
run.stdoutBuffer += text;
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT);
}
// Parse stream-json lines (newline-delimited JSON)
stdoutLineBuf += text;
const lines = stdoutLineBuf.split('\n');
stdoutLineBuf = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed) as Record<string, unknown>;
this.handleStreamJsonMessage(run, msg);
} catch {
// Not valid JSON — check for auth failure in raw text output
this.handleAuthFailureInOutput(run, trimmed, 'stdout');
}
}
const currentTs = Date.now();
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
run.lastLogProgressAt = currentTs;
emitLogsProgress(run);
}
});
}
/** Attaches the stderr handler with auth failure detection. */
private attachStderrHandler(run: ProvisioningRun): void {
const child = run.child;
if (!child?.stderr) return;
child.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
run.stderrBuffer += text;
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT);
}
// Detect auth failure early instead of waiting for 5-minute timeout
this.handleAuthFailureInOutput(run, text, 'stderr');
const currentTs = Date.now();
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
run.lastLogProgressAt = currentTs;
emitLogsProgress(run);
}
});
}
async createTeam(
request: TeamCreateRequest,
onProgress: (progress: TeamProvisioningProgress) => void
@ -924,6 +1201,9 @@ export class TeamProvisioningService {
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
authFailureRetried: false,
authRetryInProgress: false,
spawnContext: null,
progress: {
runId,
teamName: request.teamName,
@ -948,30 +1228,25 @@ export class TeamProvisioningService {
'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.'
);
}
const spawnArgs = [
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
'--setting-sources',
'user,project,local',
'--disallowedTools',
'TeamDelete,TodoWrite',
'--dangerously-skip-permissions',
...(request.model ? ['--model', request.model] : []),
];
try {
child = spawnCli(
claudePath,
[
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
'--setting-sources',
'user,project,local',
'--disallowedTools',
'TeamDelete,TodoWrite',
'--dangerously-skip-permissions',
...(request.model ? ['--model', request.model] : []),
],
{
cwd: request.cwd,
env: {
...shellEnv,
},
stdio: ['pipe', 'pipe', 'pipe'],
}
);
child = spawnCli(claudePath, spawnArgs, {
cwd: request.cwd,
env: { ...shellEnv },
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);
@ -981,6 +1256,13 @@ export class TeamProvisioningService {
updateProgress(run, 'spawning', 'Starting Claude CLI process', { pid: child.pid ?? undefined });
run.onProgress(run.progress);
run.child = child;
run.spawnContext = {
claudePath,
args: spawnArgs,
cwd: request.cwd,
env: { ...shellEnv },
prompt,
};
// Send provisioning prompt as first stream-json message (SDKUserMessage format)
if (child.stdin?.writable) {
@ -994,51 +1276,8 @@ export class TeamProvisioningService {
child.stdin.write(message + '\n');
}
if (child.stdout) {
let stdoutLineBuf = '';
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
run.stdoutBuffer += text;
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT);
}
// Parse stream-json lines (newline-delimited JSON)
stdoutLineBuf += text;
const lines = stdoutLineBuf.split('\n');
stdoutLineBuf = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed) as Record<string, unknown>;
this.handleStreamJsonMessage(run, msg);
} catch {
// Not valid JSON — raw text output, ignore
}
}
const currentTs = Date.now();
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
run.lastLogProgressAt = currentTs;
emitLogsProgress(run);
}
});
}
if (child.stderr) {
child.stderr.on('data', (chunk: Buffer) => {
run.stderrBuffer += chunk.toString('utf8');
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT);
}
const currentTs = Date.now();
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
run.lastLogProgressAt = currentTs;
emitLogsProgress(run);
}
});
}
this.attachStdoutHandler(run);
this.attachStderrHandler(run);
// Filesystem-based progress monitor: actively polls team files instead
// of relying on stdout (which only arrives at the end in text mode).
@ -1126,23 +1365,35 @@ export class TeamProvisioningService {
configParsed.leadSessionId.trim().length > 0
) {
const candidateId = configParsed.leadSessionId.trim();
const projectPath =
const storedProjectPath =
typeof configParsed.projectPath === 'string' &&
configParsed.projectPath.trim().length > 0
? configParsed.projectPath.trim()
: request.cwd;
const projectId = encodePath(projectPath);
const baseDir = extractBaseDir(projectId);
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
if (await this.pathExists(jsonlPath)) {
previousSessionId = candidateId;
: null;
// Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/).
// If the project path changed, the old session JSONL won't be found by the CLI
// at the new project directory. Skip resume to avoid passing an invalid --resume arg.
if (storedProjectPath && path.resolve(storedProjectPath) !== path.resolve(request.cwd)) {
logger.info(
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
`[${request.teamName}] Project path changed: ${storedProjectPath}${request.cwd}. ` +
`Skipping session resume — sessions are per-project.`
);
} else {
logger.info(
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
);
const resumeProjectPath = storedProjectPath ?? request.cwd;
const projectId = encodePath(resumeProjectPath);
const baseDir = extractBaseDir(projectId);
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
if (await this.pathExists(jsonlPath)) {
previousSessionId = candidateId;
logger.info(
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
);
} else {
logger.info(
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
);
}
}
}
} catch {
@ -1156,6 +1407,11 @@ export class TeamProvisioningService {
// Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names.
await this.normalizeTeamConfigForLaunch(request.teamName, configRaw);
// Update projectPath in config IMMEDIATELY so TeamDetailView shows the correct path
// even if provisioning is interrupted or the user stops the team early.
// If launch fails, restorePrelaunchConfig() will revert to the backup (old projectPath).
await this.updateConfigProjectPath(request.teamName, request.cwd);
let claudePath: string | null;
try {
await ensureCwdExists(request.cwd);
@ -1207,6 +1463,9 @@ export class TeamProvisioningService {
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
authFailureRetried: false,
authRetryInProgress: false,
spawnContext: null,
progress: {
runId,
teamName: request.teamName,
@ -1291,6 +1550,13 @@ export class TeamProvisioningService {
});
run.onProgress(run.progress);
run.child = child;
run.spawnContext = {
claudePath,
args: launchArgs,
cwd: request.cwd,
env: { ...shellEnv },
prompt,
};
// Send launch prompt
if (child.stdin?.writable) {
@ -1304,50 +1570,8 @@ export class TeamProvisioningService {
child.stdin.write(message + '\n');
}
if (child.stdout) {
let stdoutLineBuf = '';
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
run.stdoutBuffer += text;
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT);
}
stdoutLineBuf += text;
const lines = stdoutLineBuf.split('\n');
stdoutLineBuf = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed) as Record<string, unknown>;
this.handleStreamJsonMessage(run, msg);
} catch {
// Not valid JSON
}
}
const currentTs = Date.now();
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
run.lastLogProgressAt = currentTs;
emitLogsProgress(run);
}
});
}
if (child.stderr) {
child.stderr.on('data', (chunk: Buffer) => {
run.stderrBuffer += chunk.toString('utf8');
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT);
}
const currentTs = Date.now();
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
run.lastLogProgressAt = currentTs;
emitLogsProgress(run);
}
});
}
this.attachStdoutHandler(run);
this.attachStderrHandler(run);
// For launch, skip the filesystem monitor — files (config, inboxes, tasks)
// already exist from the previous run and would trigger immediate false
@ -2217,6 +2441,13 @@ export class TeamProvisioningService {
if (run.progress.state === 'failed' || run.cancelRequested) {
return;
}
// Skip if respawn after auth failure is in progress — the old process is being replaced
if (run.authRetryInProgress) {
logger.info(
`[${run.teamName}] Process exited (code ${code ?? '?'}) during auth-failure respawn — ignoring`
);
return;
}
// === Process exited AFTER provisioning completed ===
// This means the team went offline (crash, kill, or natural exit).
@ -2610,6 +2841,35 @@ export class TeamProvisioningService {
return token.length > 0 ? token : null;
}
/**
* Immediately update projectPath in config.json at launch start, before CLI spawn.
* Ensures TeamDetailView shows the correct project path even if provisioning
* is interrupted. On failure, restorePrelaunchConfig() reverts to the backup.
*/
private async updateConfigProjectPath(teamName: string, cwd: string): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as Record<string, unknown>;
config.projectPath = cwd;
const pathHistory = Array.isArray(config.projectPathHistory)
? (config.projectPathHistory as string[]).filter((p) => typeof p === 'string' && p !== cwd)
: [];
pathHistory.push(cwd);
config.projectPathHistory = pathHistory.slice(-500);
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
logger.info(`[${teamName}] Updated config.projectPath immediately: ${cwd}`);
} catch (error) {
// Non-fatal: updateConfigPostLaunch will update it later if provisioning succeeds.
logger.warn(
`[${teamName}] Failed to update projectPath early: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Single atomic read-mutate-write for post-launch config updates.
* Combines session history append and projectPath update to avoid
@ -3119,40 +3379,67 @@ export class TeamProvisioningService {
throw new Error(`Failed to warm up Claude CLI: ${errorText}`);
}
// Stage 2: verify `-p` mode auth actually works
let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null;
try {
pingProbe = await this.spawnProbe(
claudePath,
['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'],
cwd,
env,
PREFLIGHT_TIMEOUT_MS
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
warning:
'Preflight check for `claude -p` did not complete. ' +
`Proceeding anyway. Details: ${message}`,
};
}
// Stage 2: verify `-p` mode auth actually works (with retry for stale locks after Ctrl+C)
for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) {
let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null;
try {
pingProbe = await this.spawnProbe(
claudePath,
['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'],
cwd,
env,
PREFLIGHT_TIMEOUT_MS
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
logger.warn(
`Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
);
await new Promise((resolve) => setTimeout(resolve, PREFLIGHT_AUTH_RETRY_DELAY_MS));
continue;
}
return {
warning:
'Preflight check for `claude -p` did not complete. ' +
`Proceeding anyway. Details: ${message}`,
};
}
const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr);
const lowerOutput = combinedOutput.toLowerCase();
const isAuthFailure =
lowerOutput.includes('not logged in') ||
lowerOutput.includes('please run /login') ||
lowerOutput.includes('missing api key') ||
lowerOutput.includes('invalid api key');
const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr);
const lowerOutput = combinedOutput.toLowerCase();
const isAuthFailure =
lowerOutput.includes('not logged in') ||
lowerOutput.includes('please run /login') ||
lowerOutput.includes('missing api key') ||
lowerOutput.includes('invalid api key');
if (isAuthFailure || pingProbe.exitCode !== 0) {
const hint = isAuthFailure
? 'Claude CLI `-p` mode is not authenticated. ' +
'Set ANTHROPIC_API_KEY, or run `claude setup-token` to generate a long-lived OAuth token, ' +
'then export it as CLAUDE_CODE_OAUTH_TOKEN.'
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
return { warning: hint };
if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
logger.warn(
`Preflight auth failure detected (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms — likely stale locks from interrupted process`
);
await new Promise((resolve) => setTimeout(resolve, PREFLIGHT_AUTH_RETRY_DELAY_MS));
continue;
}
if (isAuthFailure || pingProbe.exitCode !== 0) {
const hint = isAuthFailure
? 'Claude CLI `-p` mode is not authenticated. ' +
'Set ANTHROPIC_API_KEY, or run `claude setup-token` to generate a long-lived OAuth token, ' +
'then export it as CLAUDE_CODE_OAUTH_TOKEN.' +
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
return { warning: hint };
}
if (attempt > 1) {
logger.info(
`Preflight auth succeeded on attempt ${attempt} (previous attempt had auth failure)`
);
}
return {};
}
return {};

View file

@ -3,7 +3,7 @@ import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import type { TaskComment, TeamTask } from '@shared/types';
import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types';
const logger = createLogger('Service:TeamTaskReader');
@ -97,6 +97,21 @@ export class TeamTaskReader {
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
// safety: if a field is added to TeamTask but not mapped here,
// TypeScript will error. This prevents silently dropping new fields.
const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals)
? (parsed.workIntervals as unknown[])
.filter(
(i): i is { startedAt: string; completedAt?: string } =>
Boolean(i) &&
typeof i === 'object' &&
typeof (i as Record<string, unknown>).startedAt === 'string' &&
((i as Record<string, unknown>).completedAt === undefined ||
typeof (i as Record<string, unknown>).completedAt === 'string')
)
.map((i) => ({
startedAt: i.startedAt,
completedAt: i.completedAt,
}))
: undefined;
const task: TeamTask = {
id:
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
@ -110,6 +125,7 @@ export class TeamTaskReader {
)
? (parsed.status as TeamTask['status'])
: 'pending',
workIntervals,
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
related: Array.isArray(parsed.related)
@ -119,15 +135,22 @@ export class TeamTaskReader {
updatedAt,
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
comments: Array.isArray(parsed.comments)
? (parsed.comments as TaskComment[]).filter(
(c) =>
c &&
typeof c === 'object' &&
typeof c.id === 'string' &&
typeof c.author === 'string' &&
typeof c.text === 'string' &&
typeof c.createdAt === 'string'
)
? (parsed.comments as TaskComment[])
.filter(
(c) =>
c &&
typeof c === 'object' &&
typeof c.id === 'string' &&
typeof c.author === 'string' &&
typeof c.text === 'string' &&
typeof c.createdAt === 'string'
)
.map((c) => ({
...c,
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
? c.type
: ('regular' as const),
}))
: undefined,
needsClarification: (['lead', 'user'] as const).includes(
parsed.needsClarification as 'lead' | 'user'

View file

@ -5,7 +5,7 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { TaskComment, TeamTask, TeamTaskStatus } from '@shared/types';
import type { TaskComment, TaskCommentType, TeamTask, TeamTaskStatus } from '@shared/types';
const taskWriteLocks = new Map<string, Promise<void>>();
@ -46,13 +46,23 @@ export class TeamTaskWriter {
// Ensure CLI-compatible format: description, blocks, blockedBy are required
// by Claude Code CLI's Zod schema validation (safeParse fails without them)
const createdAt = task.createdAt ?? new Date().toISOString();
const cliCompatibleTask: TeamTask = {
...task,
description: task.description ?? '',
blocks: task.blocks ?? [],
blockedBy: task.blockedBy ?? [],
related: task.related ?? [],
createdAt: task.createdAt ?? new Date().toISOString(),
createdAt,
workIntervals:
task.status === 'in_progress'
? // Start the first work interval on creation when task starts immediately.
[
...(Array.isArray(task.workIntervals) && task.workIntervals.length > 0
? task.workIntervals
: [{ startedAt: createdAt }]),
]
: task.workIntervals,
};
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
@ -92,6 +102,176 @@ export class TeamTaskWriter {
});
}
async addRelationship(
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> {
if (taskId === targetId) {
throw new Error('Cannot link a task to itself');
}
// For 'blocks', delegate as reverse blockedBy (swap task/target intentionally)
if (type === 'blocks') {
const swappedTask = targetId;
const swappedTarget = taskId;
return this.addRelationship(teamName, swappedTask, swappedTarget, 'blockedBy');
}
const tasksDir = path.join(getTasksBasePath(), teamName);
const taskPath = path.join(tasksDir, `${taskId}.json`);
const targetPath = path.join(tasksDir, `${targetId}.json`);
// Lock both paths in sorted order to avoid deadlocks
const [firstPath, secondPath] =
taskPath < targetPath ? [taskPath, targetPath] : [targetPath, taskPath];
await withTaskLock(firstPath, () =>
withTaskLock(secondPath, async () => {
// Read both tasks
const taskRaw = await this.readTaskFile(taskPath, taskId);
const targetRaw = await this.readTaskFile(targetPath, targetId);
const task = JSON.parse(taskRaw) as TeamTask;
const target = JSON.parse(targetRaw) as TeamTask;
if (type === 'blockedBy') {
// Cycle detection: walk target's blockedBy chain to check if taskId is reachable
await this.checkBlockCycle(tasksDir, taskId, targetId);
// task.blockedBy += targetId
const blockedBy = task.blockedBy ?? [];
if (!blockedBy.includes(targetId)) {
task.blockedBy = [...blockedBy, targetId];
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
}
// target.blocks += taskId (reverse)
const blocks = target.blocks ?? [];
if (!blocks.includes(taskId)) {
target.blocks = [...blocks, taskId];
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
}
} else {
// related — bidirectional
const relA = task.related ?? [];
if (!relA.includes(targetId)) {
task.related = [...relA, targetId];
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
}
const relB = target.related ?? [];
if (!relB.includes(taskId)) {
target.related = [...relB, taskId];
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
}
}
})
);
}
async removeRelationship(
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> {
// For 'blocks', delegate as reverse blockedBy (swap task/target intentionally)
if (type === 'blocks') {
const swappedTask = targetId;
const swappedTarget = taskId;
return this.removeRelationship(teamName, swappedTask, swappedTarget, 'blockedBy');
}
const tasksDir = path.join(getTasksBasePath(), teamName);
const taskPath = path.join(tasksDir, `${taskId}.json`);
const targetPath = path.join(tasksDir, `${targetId}.json`);
const [firstPath, secondPath] =
taskPath < targetPath ? [taskPath, targetPath] : [targetPath, taskPath];
await withTaskLock(firstPath, () =>
withTaskLock(secondPath, async () => {
// Read task (must exist)
const taskRaw = await this.readTaskFile(taskPath, taskId);
const task = JSON.parse(taskRaw) as TeamTask;
if (type === 'blockedBy') {
task.blockedBy = (task.blockedBy ?? []).filter((id) => id !== targetId);
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
// Remove reverse from target if it exists
try {
const targetRaw = await fs.promises.readFile(targetPath, 'utf8');
const target = JSON.parse(targetRaw) as TeamTask;
target.blocks = (target.blocks ?? []).filter((id) => id !== taskId);
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
// Target doesn't exist — skip silently
}
} else {
// related — remove bidirectional
task.related = (task.related ?? []).filter((id) => id !== targetId);
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
try {
const targetRaw = await fs.promises.readFile(targetPath, 'utf8');
const target = JSON.parse(targetRaw) as TeamTask;
target.related = (target.related ?? []).filter((id) => id !== taskId);
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
})
);
}
private async readTaskFile(taskPath: string, taskId: string): Promise<string> {
try {
return await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
}
/**
* Walks targetId's blockedBy chain to detect if sourceId is reachable.
* Reads are outside locks (deliberate TOCTOU trade-off the calling method
* holds locks on both source and target, and only other tasks are read here).
*/
private async checkBlockCycle(
tasksDir: string,
sourceId: string,
targetId: string
): Promise<void> {
const visited = new Set<string>();
const stack = [targetId];
while (stack.length > 0) {
const current = stack.pop()!;
if (current === sourceId) {
throw new Error(`Circular dependency: #${targetId} already depends on #${sourceId}`);
}
if (visited.has(current)) continue;
visited.add(current);
try {
const raw = await fs.promises.readFile(path.join(tasksDir, `${current}.json`), 'utf8');
const task = JSON.parse(raw) as TeamTask;
if (Array.isArray(task.blockedBy)) {
for (const dep of task.blockedBy) {
stack.push(dep);
}
}
} catch {
// Skip unreadable tasks
}
}
}
async updateStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
@ -107,6 +287,29 @@ export class TeamTaskWriter {
}
const task = JSON.parse(raw) as TeamTask;
const prevStatus = task.status;
const nowIso = new Date().toISOString();
// Maintain workIntervals as periods of time where status === 'in_progress'.
const intervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
const last = intervals.length > 0 ? intervals[intervals.length - 1] : undefined;
const wasInProgress = prevStatus === 'in_progress';
const isInProgress = status === 'in_progress';
if (!wasInProgress && isInProgress) {
// Entering in_progress: open a new interval if none is open.
if (!last || typeof last.completedAt === 'string') {
intervals.push({ startedAt: nowIso });
}
} else if (wasInProgress && !isInProgress) {
// Leaving in_progress: close open interval if present.
if (last && last.completedAt === undefined) {
last.completedAt = nowIso;
}
}
task.workIntervals = intervals.length > 0 ? intervals : undefined;
task.status = status;
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
@ -157,8 +360,20 @@ export class TeamTaskWriter {
}
const task = JSON.parse(raw) as TeamTask;
const nowIso = new Date().toISOString();
// Ensure any open in_progress interval is closed on delete.
if (task.status === 'in_progress') {
const intervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
const last = intervals.length > 0 ? intervals[intervals.length - 1] : undefined;
if (last && last.completedAt === undefined) {
last.completedAt = nowIso;
}
task.workIntervals = intervals.length > 0 ? intervals : task.workIntervals;
}
task.status = 'deleted';
task.deletedAt = new Date().toISOString();
task.deletedAt = nowIso;
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
@ -251,7 +466,7 @@ export class TeamTaskWriter {
teamName: string,
taskId: string,
text: string,
options?: { id?: string; author?: string; createdAt?: string }
options?: { id?: string; author?: string; createdAt?: string; type?: TaskCommentType }
): Promise<TaskComment> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
const comment: TaskComment = {
@ -259,6 +474,7 @@ export class TeamTaskWriter {
author: options?.author ?? 'user',
text,
createdAt: options?.createdAt ?? new Date().toISOString(),
type: options?.type ?? 'regular',
};
await withTaskLock(taskPath, async () => {

View file

@ -147,6 +147,7 @@ export type WorktreeSource =
| 'auto-claude' // /Users/.../.auto-claude/worktrees/tasks/{task-id}
| '21st' // /Users/.../.21st/worktrees/{id}/{name [bracket-id]}
| 'claude-desktop' // /Users/.../.claude-worktrees/{repo}/{name}
| 'claude-code' // /Users/.../.claude/worktrees/{name}
| 'ccswitch' // /Users/.../.ccswitch/worktrees/{repo}/{name}
| 'git' // Standard git worktree (main repo or detached)
| 'unknown'; // Non-git project or undetectable

38
src/main/utils/appIcon.ts Normal file
View file

@ -0,0 +1,38 @@
/**
* Resolves the application icon path for native notifications and windows.
*
* On macOS the signed bundle provides the icon automatically,
* so this is primarily needed for Windows and Linux.
*/
import { existsSync } from 'fs';
import { join } from 'path';
let cachedPath: string | undefined;
let resolved = false;
/**
* Returns the absolute path to the app icon (PNG), or undefined if not found.
* Result is cached after the first call.
*/
export function getAppIconPath(): string | undefined {
if (resolved) return cachedPath;
const isDev = process.env.NODE_ENV === 'development';
const candidates = isDev
? [join(process.cwd(), 'resources/icon.png')]
: [
join(process.resourcesPath, 'resources/icon.png'),
join(__dirname, '../../resources/icon.png'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
cachedPath = candidate;
break;
}
}
resolved = true;
return cachedPath;
}

View file

@ -3,6 +3,9 @@
*
* Provides security sandboxing for file path access to prevent
* unauthorized access to sensitive system files.
*
* Cross-platform: uses path.resolve() for consistent drive-letter
* handling on Windows (normalizeForCompare, isPathWithinRoot).
*/
import * as fs from 'fs';
@ -62,12 +65,14 @@ export interface PathValidationResult {
}
function normalizeForCompare(input: string, isWindows: boolean): string {
const normalized = path.normalize(input);
const normalized = path.resolve(path.normalize(input));
return isWindows ? normalized.toLowerCase() : normalized;
}
export function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
return targetPath === rootPath || targetPath.startsWith(rootPath + path.sep);
const target = path.resolve(targetPath);
const root = path.resolve(rootPath);
return target === root || target.startsWith(root + path.sep);
}
function resolveRealPathIfExists(inputPath: string): string | null {

View file

@ -62,6 +62,12 @@ export const CONFIG_FIND_WSL_CLAUDE_ROOTS = 'config:findWslClaudeRoots';
/** Open config file in external editor */
export const CONFIG_OPEN_IN_EDITOR = 'config:openInEditor';
/** Add a custom project path (Select Folder persistence) */
export const CONFIG_ADD_CUSTOM_PROJECT_PATH = 'config:addCustomProjectPath';
/** Remove a custom project path */
export const CONFIG_REMOVE_CUSTOM_PROJECT_PATH = 'config:removeCustomProjectPath';
/** Pin a session */
export const CONFIG_PIN_SESSION = 'config:pinSession';
@ -315,6 +321,12 @@ export const TEAM_SET_TASK_CLARIFICATION = 'team:setTaskClarification';
/** Show native OS notification for a team message */
export const TEAM_SHOW_MESSAGE_NOTIFICATION = 'team:showMessageNotification';
/** Add a relationship (blockedBy/blocks/related) between two tasks */
export const TEAM_ADD_TASK_RELATIONSHIP = 'team:addTaskRelationship';
/** Remove a relationship (blockedBy/blocks/related) between two tasks */
export const TEAM_REMOVE_TASK_RELATIONSHIP = 'team:removeTaskRelationship';
// =============================================================================
// CLI Installer API Channels
// =============================================================================
@ -431,6 +443,9 @@ export const EDITOR_DELETE_FILE = 'editor:deleteFile';
/** Move file or directory to a new location */
export const EDITOR_MOVE_FILE = 'editor:moveFile';
/** Rename file or directory in place */
export const EDITOR_RENAME_FILE = 'editor:renameFile';
/** Search in files (literal string search) */
export const EDITOR_SEARCH_IN_FILES = 'editor:searchInFiles';
@ -443,5 +458,17 @@ export const EDITOR_GIT_STATUS = 'editor:gitStatus';
/** Enable/disable file watcher for current project */
export const EDITOR_WATCH_DIR = 'editor:watchDir';
/** Update list of watched file paths (open tabs) */
export const EDITOR_SET_WATCHED_FILES = 'editor:setWatchedFiles';
/** Update list of watched directories (shallow: depth=0) */
export const EDITOR_SET_WATCHED_DIRS = 'editor:setWatchedDirs';
/** Read binary file as base64 for inline preview */
export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview';
/** File change event from watcher (main -> renderer) */
export const EDITOR_CHANGE = 'editor:change';
/** List project files by path (for @file mentions, independent of editor state) */
export const PROJECT_LIST_FILES = 'project:listFiles';

View file

@ -19,14 +19,19 @@ import {
EDITOR_LIST_FILES,
EDITOR_MOVE_FILE,
EDITOR_OPEN,
EDITOR_READ_BINARY_PREVIEW,
EDITOR_READ_DIR,
EDITOR_READ_FILE,
EDITOR_RENAME_FILE,
EDITOR_SEARCH_IN_FILES,
EDITOR_SET_WATCHED_DIRS,
EDITOR_SET_WATCHED_FILES,
EDITOR_WATCH_DIR,
EDITOR_WRITE_FILE,
HTTP_SERVER_GET_STATUS,
HTTP_SERVER_START,
HTTP_SERVER_STOP,
PROJECT_LIST_FILES,
REVIEW_APPLY_DECISIONS,
REVIEW_CHECK_CONFLICT,
REVIEW_CLEAR_DECISIONS,
@ -52,6 +57,7 @@ import {
SSH_TEST,
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT,
TEAM_ADD_TASK_RELATIONSHIP,
TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING,
TEAM_CHANGE,
@ -78,6 +84,7 @@ import {
TEAM_PROVISIONING_PROGRESS,
TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REMOVE_TASK_RELATIONSHIP,
TEAM_REQUEST_REVIEW,
TEAM_RESTORE,
TEAM_RESTORE_TASK,
@ -112,6 +119,7 @@ import {
WINDOW_MINIMIZE,
} from './constants/ipcChannels';
import {
CONFIG_ADD_CUSTOM_PROJECT_PATH,
CONFIG_ADD_IGNORE_REGEX,
CONFIG_ADD_IGNORE_REPOSITORY,
CONFIG_ADD_TRIGGER,
@ -124,6 +132,7 @@ import {
CONFIG_HIDE_SESSIONS,
CONFIG_OPEN_IN_EDITOR,
CONFIG_PIN_SESSION,
CONFIG_REMOVE_CUSTOM_PROJECT_PATH,
CONFIG_REMOVE_IGNORE_REGEX,
CONFIG_REMOVE_IGNORE_REPOSITORY,
CONFIG_REMOVE_TRIGGER,
@ -195,6 +204,7 @@ import type {
WslClaudeRootCandidate,
} from '@shared/types';
import type {
BinaryPreviewResult,
CreateDirResponse,
CreateFileResponse,
DeleteFileResponse,
@ -441,6 +451,12 @@ const electronAPI: ElectronAPI = {
unhideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSIONS, projectId, sessionIds);
},
addCustomProjectPath: async (projectPath: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_ADD_CUSTOM_PROJECT_PATH, projectPath);
},
removeCustomProjectPath: async (projectPath: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_REMOVE_CUSTOM_PROJECT_PATH, projectPath);
},
},
// Deep link navigation
@ -696,7 +712,12 @@ const electronAPI: ElectronAPI = {
getLogsForTask: async (
teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
}
) => {
return invokeIpcWithResult<MemberLogSummary[]>(
TEAM_GET_LOGS_FOR_TASK,
@ -758,6 +779,34 @@ const electronAPI: ElectronAPI = {
showMessageNotification: async (data: TeamMessageNotificationData) => {
return invokeIpcWithResult<void>(TEAM_SHOW_MESSAGE_NOTIFICATION, data);
},
addTaskRelationship: async (
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
) => {
return invokeIpcWithResult<void>(
TEAM_ADD_TASK_RELATIONSHIP,
teamName,
taskId,
targetId,
type
);
},
removeTaskRelationship: async (
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
) => {
return invokeIpcWithResult<void>(
TEAM_REMOVE_TASK_RELATIONSHIP,
teamName,
taskId,
targetId,
type
);
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
ipcRenderer.on(
TEAM_CHANGE,
@ -858,28 +907,36 @@ const electronAPI: ElectronAPI = {
);
},
// Editable diff
saveEditedFile: async (filePath: string, content: string) => {
return invokeIpcWithResult<{ success: boolean }>(REVIEW_SAVE_EDITED_FILE, filePath, content);
saveEditedFile: async (filePath: string, content: string, projectPath?: string) => {
return invokeIpcWithResult<{ success: boolean }>(
REVIEW_SAVE_EDITED_FILE,
filePath,
content,
projectPath
);
},
// Decision persistence
loadDecisions: async (teamName: string, scopeKey: string) => {
return invokeIpcWithResult<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null>(REVIEW_LOAD_DECISIONS, teamName, scopeKey);
},
saveDecisions: async (
teamName: string,
scopeKey: string,
hunkDecisions: Record<string, HunkDecision>,
fileDecisions: Record<string, HunkDecision>
fileDecisions: Record<string, HunkDecision>,
hunkContextHashesByFile?: Record<string, Record<number, string>>
) => {
return invokeIpcWithResult<void>(
REVIEW_SAVE_DECISIONS,
teamName,
scopeKey,
hunkDecisions,
fileDecisions
fileDecisions,
hunkContextHashesByFile ?? null
);
},
clearDecisions: async (teamName: string, scopeKey: string) => {
@ -957,6 +1014,12 @@ const electronAPI: ElectronAPI = {
},
},
// ===== Project API (editor-independent) =====
project: {
listFiles: (projectPath: string) =>
invokeIpcWithResult<QuickOpenFile[]>(PROJECT_LIST_FILES, projectPath),
},
// ===== Editor API =====
editor: {
open: (projectPath: string) => invokeIpcWithResult<void>(EDITOR_OPEN, projectPath),
@ -974,11 +1037,19 @@ const electronAPI: ElectronAPI = {
invokeIpcWithResult<DeleteFileResponse>(EDITOR_DELETE_FILE, filePath),
moveFile: (sourcePath: string, destDir: string) =>
invokeIpcWithResult<MoveFileResponse>(EDITOR_MOVE_FILE, sourcePath, destDir),
renameFile: (sourcePath: string, newName: string) =>
invokeIpcWithResult<MoveFileResponse>(EDITOR_RENAME_FILE, sourcePath, newName),
searchInFiles: (options: SearchInFilesOptions) =>
invokeIpcWithResult<SearchInFilesResult>(EDITOR_SEARCH_IN_FILES, options),
listFiles: () => invokeIpcWithResult<QuickOpenFile[]>(EDITOR_LIST_FILES),
readBinaryPreview: (filePath: string) =>
invokeIpcWithResult<BinaryPreviewResult>(EDITOR_READ_BINARY_PREVIEW, filePath),
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
setWatchedFiles: (filePaths: string[]) =>
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_FILES, filePaths),
setWatchedDirs: (dirPaths: string[]) =>
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_DIRS, dirPaths),
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
callback(data);

View file

@ -63,7 +63,7 @@ import type {
WslClaudeRootCandidate,
} from '@shared/types';
import type { AgentConfig } from '@shared/types/api';
import type { EditorAPI } from '@shared/types/editor';
import type { EditorAPI, ProjectAPI } from '@shared/types/editor';
import type { TerminalAPI } from '@shared/types/terminal';
export class HttpAPIClient implements ElectronAPI {
@ -481,6 +481,10 @@ export class HttpAPIClient implements ElectronAPI {
this.post('/api/config/hide-sessions', { projectId, sessionIds }),
unhideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
this.post('/api/config/unhide-sessions', { projectId, sessionIds }),
addCustomProjectPath: (projectPath: string): Promise<void> =>
this.post('/api/config/add-custom-project-path', { projectPath }),
removeCustomProjectPath: (projectPath: string): Promise<void> =>
this.post('/api/config/remove-custom-project-path', { projectPath }),
};
// ---------------------------------------------------------------------------
@ -808,6 +812,22 @@ export class HttpAPIClient implements ElectronAPI {
showMessageNotification: async (): Promise<void> => {
// Not available via HTTP client — native notifications require Electron
},
addTaskRelationship: async (
_teamName: string,
_taskId: string,
_targetId: string,
_type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> => {
throw new Error('Task relationships are not available in browser mode');
},
removeTaskRelationship: async (
_teamName: string,
_taskId: string,
_targetId: string,
_type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> => {
throw new Error('Task relationships are not available in browser mode');
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent)
@ -863,7 +883,13 @@ export class HttpAPIClient implements ElectronAPI {
loadDecisions: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
saveDecisions: async (): Promise<never> => {
saveDecisions: async (
_teamName: string,
_scopeKey: string,
_hunkDecisions: Record<string, unknown>,
_fileDecisions: Record<string, unknown>,
_hunkContextHashesByFile?: Record<string, Record<number, string>>
): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
clearDecisions: async (): Promise<never> => {
@ -912,6 +938,16 @@ export class HttpAPIClient implements ElectronAPI {
onExit: (): (() => void) => () => {},
};
// ---------------------------------------------------------------------------
// Project (not available in browser mode)
// ---------------------------------------------------------------------------
project: ProjectAPI = {
listFiles: async () => {
throw new Error('Project API not available in browser mode');
},
};
// ---------------------------------------------------------------------------
// Editor (not available in browser mode)
// ---------------------------------------------------------------------------
@ -944,18 +980,30 @@ export class HttpAPIClient implements ElectronAPI {
moveFile: async () => {
throw new Error('Editor not available in browser mode');
},
renameFile: async () => {
throw new Error('Editor not available in browser mode');
},
searchInFiles: async () => {
throw new Error('Editor not available in browser mode');
},
listFiles: async () => {
throw new Error('Editor not available in browser mode');
},
readBinaryPreview: async () => {
throw new Error('Editor not available in browser mode');
},
gitStatus: async () => {
throw new Error('Editor not available in browser mode');
},
watchDir: async () => {
throw new Error('Editor not available in browser mode');
},
setWatchedFiles: async () => {
throw new Error('Editor not available in browser mode');
},
setWatchedDirs: async () => {
throw new Error('Editor not available in browser mode');
},
onEditorChange: () => {
return () => {};
},

View file

@ -224,6 +224,46 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
return map;
}, [conversation]);
// --- New-item animation tracking ---
const knownGroupIdsRef = useRef<Set<string>>(new Set());
const isInitialRenderRef = useRef(true);
const prevTabIdRef = useRef(effectiveTabId);
// Reset animation tracking when switching tabs/sessions
if (prevTabIdRef.current !== effectiveTabId) {
prevTabIdRef.current = effectiveTabId;
knownGroupIdsRef.current.clear();
isInitialRenderRef.current = true;
}
const newGroupIds = useMemo(() => {
const items = conversation?.items;
if (!items || items.length === 0) {
knownGroupIdsRef.current.clear();
isInitialRenderRef.current = true;
return new Set<string>();
}
// First render: seed all known IDs, no animations
if (isInitialRenderRef.current) {
isInitialRenderRef.current = false;
for (const item of items) {
knownGroupIdsRef.current.add(item.group.id);
}
return new Set<string>();
}
// Subsequent updates: detect new items
const newIds = new Set<string>();
for (const item of items) {
if (!knownGroupIdsRef.current.has(item.group.id)) {
newIds.add(item.group.id);
knownGroupIdsRef.current.add(item.group.id);
}
}
return newIds;
}, [conversation]);
const rowVirtualizer = useVirtualizer({
count: shouldVirtualize ? (conversation?.items.length ?? 0) : 0,
getScrollElement: () => scrollContainerRef.current,
@ -849,6 +889,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
isSearchHighlight={isSearchHighlight}
isNavigationHighlight={isNavigationHighlight}
highlightColor={effectiveHighlightColor}
isNew={newGroupIds.has(item.group.id)}
registerChatItemRef={registerChatItemRef}
registerAIGroupRef={registerAIGroupRefCombined}
registerToolRef={registerToolRef}
@ -867,6 +908,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
isSearchHighlight={isSearchHighlight}
isNavigationHighlight={isNavigationHighlight}
highlightColor={effectiveHighlightColor}
isNew={newGroupIds.has(item.group.id)}
registerChatItemRef={registerChatItemRef}
registerAIGroupRef={registerAIGroupRefCombined}
registerToolRef={registerToolRef}

View file

@ -21,6 +21,8 @@ interface ChatHistoryItemProps {
readonly isSearchHighlight: boolean;
readonly isNavigationHighlight: boolean;
readonly highlightColor?: TriggerColor;
/** Whether this item just appeared (triggers enter animation) */
readonly isNew?: boolean;
readonly registerChatItemRef: (groupId: string) => (el: HTMLElement | null) => void;
readonly registerAIGroupRef: (groupId: string) => (el: HTMLElement | null) => void;
/** Register ref for individual tool items (for precise scroll targeting) */
@ -54,10 +56,13 @@ const ChatHistoryItemInner = ({
isSearchHighlight,
isNavigationHighlight,
highlightColor,
isNew,
registerChatItemRef,
registerAIGroupRef,
registerToolRef,
}: ChatHistoryItemProps): JSX.Element | null => {
const enterClass = isNew ? 'chat-message-enter-animate' : '';
switch (item.type) {
case 'user': {
const isHighlighted = highlightedGroupId === item.group.id;
@ -70,7 +75,7 @@ const ChatHistoryItemInner = ({
return (
<div
ref={registerChatItemRef(item.group.id)}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className}`}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
style={hl.style}
>
<UserChatGroup userGroup={item.group} />
@ -88,7 +93,7 @@ const ChatHistoryItemInner = ({
return (
<div
ref={registerChatItemRef(item.group.id)}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className}`}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
style={hl.style}
>
<SystemChatGroup systemGroup={item.group} />
@ -110,7 +115,7 @@ const ChatHistoryItemInner = ({
return (
<div
ref={registerAIGroupRef(item.group.id)}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className}`}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
style={hl.style}
>
<AIChatGroup
@ -123,7 +128,13 @@ const ChatHistoryItemInner = ({
);
}
case 'compact':
return <CompactBoundary compactGroup={item.group} />;
return isNew ? (
<div className={enterClass}>
<CompactBoundary compactGroup={item.group} />
</div>
) : (
<CompactBoundary compactGroup={item.group} />
);
default:
return null;
}

View file

@ -23,7 +23,7 @@ import {
PROSE_TABLE_HEADER_BG,
} from '@renderer/constants/cssVariables';
import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
import { FileText } from 'lucide-react';
import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow';
@ -34,6 +34,8 @@ import {
type SearchContext,
} from '../searchHighlightUtils';
import { MermaidDiagram } from './MermaidDiagram';
// =============================================================================
// Types
// =============================================================================
@ -49,6 +51,100 @@ interface MarkdownViewerProps {
copyable?: boolean;
/** When true, renders without wrapper background/border (for embedding inside cards) */
bare?: boolean;
/** Base directory for resolving relative URLs (images, links) via local-resource:// protocol */
baseDir?: string;
}
// =============================================================================
// Helpers
// =============================================================================
/** Check if a URL is relative (not absolute, not data, not mailto, not hash) */
function isRelativeUrl(url: string): boolean {
return (
!!url &&
!url.startsWith('http://') &&
!url.startsWith('https://') &&
!url.startsWith('data:') &&
!url.startsWith('#') &&
!url.startsWith('mailto:')
);
}
/** Resolve a relative path to an absolute path given a base directory */
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
return `${baseDir}/${cleaned}`;
}
// =============================================================================
// LocalImage — loads images via IPC (readBinaryPreview) for local file access
// =============================================================================
interface LocalImageProps {
src: string;
alt?: string;
baseDir: string;
}
const LocalImage = React.memo(function LocalImage({
src,
alt,
baseDir,
}: LocalImageProps): React.ReactElement {
const [dataUrl, setDataUrl] = React.useState<string | null>(null);
const [error, setError] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
setDataUrl(null);
setError(false);
const fullPath = resolveRelativePath(src, baseDir);
window.electronAPI.editor
.readBinaryPreview(fullPath)
.then((result) => {
if (!cancelled) {
setDataUrl(`data:${result.mimeType};base64,${result.base64}`);
}
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [src, baseDir]);
if (error) {
return (
<span className="inline-flex items-center gap-1 text-xs text-text-muted">
[Image: {alt || src}]
</span>
);
}
if (!dataUrl) {
return (
<span className="inline-block size-4 animate-pulse rounded bg-surface-raised align-middle" />
);
}
return <img src={dataUrl} alt={alt || ''} className="my-2 max-w-full rounded" />;
});
/** Extract plain text from a hast (HTML AST) node tree */
interface HastNode {
type: string;
value?: string;
children?: HastNode[];
}
function hastToText(node: HastNode): string {
if (node.type === 'text') return node.value ?? '';
if (node.children) return node.children.map(hastToText).join('');
return '';
}
// =============================================================================
@ -180,18 +276,29 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
);
},
// Code blocks
pre: ({ children }) => (
<pre
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
{children}
</pre>
),
// Code blocks — intercept mermaid diagrams at the pre level
pre: ({ children, node }) => {
// Check if this pre contains a mermaid code block
const codeEl = node?.children?.[0];
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
const cls = (codeEl.properties as Record<string, unknown>)?.className;
if (Array.isArray(cls) && cls.some((c) => String(c) === 'language-mermaid')) {
return <MermaidDiagram code={hastToText(codeEl as unknown as HastNode)} />;
}
}
return (
<pre
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
{children}
</pre>
);
},
// Blockquotes
blockquote: ({ children }) => (
@ -288,6 +395,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
itemId,
copyable = false,
bare = false,
baseDir,
}) => {
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
@ -435,7 +543,20 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
// Create markdown components with optional search highlighting
// When search is active, create fresh each render (match counter is stateful and must start at 0)
// useMemo would cache stale closures when parent re-renders without search deps changing
const components = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents;
const baseComponents = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents;
// When baseDir is set (editor preview), override img to load local files via IPC
const components = baseDir
? {
...baseComponents,
img: ({ src, alt }: { src?: string; alt?: string }) => {
if (src && isRelativeUrl(src)) {
return <LocalImage src={src} alt={alt} baseDir={baseDir} />;
}
return <img src={src} alt={alt || ''} className="my-2 max-w-full rounded" />;
},
}
: baseComponents;
return (
<div
@ -481,8 +602,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
<div className="min-w-0 break-words p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? [] : REHYPE_PLUGINS}
urlTransform={(url) => url}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components}
>
{content}

View file

@ -0,0 +1,116 @@
/**
* Renders a Mermaid diagram from code string to SVG.
*
* Lazy-initializes mermaid once with dark theme.
* Each render call uses a unique ID to avoid collisions.
* SVG output is sanitized with DOMPurify before DOM insertion.
* Falls back to raw code display on parse errors.
*/
import React, { useEffect, useRef, useState } from 'react';
import { PROSE_PRE_BG, PROSE_PRE_BORDER } from '@renderer/constants/cssVariables';
import DOMPurify from 'dompurify';
import mermaid from 'mermaid';
// =============================================================================
// Mermaid initialization (once per app lifecycle)
// =============================================================================
let initialized = false;
function ensureMermaidInit(): void {
if (initialized) return;
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
themeVariables: {
darkMode: true,
background: 'transparent',
primaryColor: '#3b82f6',
primaryTextColor: '#fafafa',
primaryBorderColor: '#3b82f6',
lineColor: '#71717a',
secondaryColor: '#27272a',
tertiaryColor: '#1f1f23',
},
});
initialized = true;
}
// Monotonic counter for unique diagram IDs
let idCounter = 0;
// =============================================================================
// Component
// =============================================================================
interface MermaidDiagramProps {
code: string;
}
export const MermaidDiagram = React.memo(function MermaidDiagram({
code,
}: MermaidDiagramProps): React.ReactElement {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!code.trim()) return;
ensureMermaidInit();
let cancelled = false;
const diagramId = `mermaid-${++idCounter}`;
mermaid
.render(diagramId, code.trim())
.then(({ svg }) => {
if (!cancelled && containerRef.current) {
const sanitized = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
});
containerRef.current.replaceChildren();
containerRef.current.insertAdjacentHTML('afterbegin', sanitized);
setError(null);
}
})
.catch((err: unknown) => {
if (!cancelled) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
}
});
return () => {
cancelled = true;
};
}, [code]);
if (error) {
return (
<div
className="my-3 overflow-auto rounded-lg p-3 text-xs"
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
<div className="mb-2 text-amber-400">Mermaid syntax error</div>
<pre className="whitespace-pre-wrap font-mono text-text-muted">{code}</pre>
</div>
);
}
return (
<div
ref={containerRef}
className="my-3 flex justify-center overflow-auto rounded-lg p-3"
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
/>
);
});

View file

@ -52,6 +52,11 @@ const SOURCE_CONFIG: Record<WorktreeSource, SourceConfig> = {
bgColor: WORKTREE_BADGE_BG,
textColor: WORKTREE_BADGE_TEXT,
},
'claude-code': {
label: 'Worktree',
bgColor: WORKTREE_BADGE_BG,
textColor: WORKTREE_BADGE_TEXT,
},
ccswitch: {
label: 'ccswitch',
bgColor: WORKTREE_BADGE_BG,

View file

@ -24,8 +24,9 @@ import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:DashboardView');
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { formatDistanceToNow } from 'date-fns';
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Users } from 'lucide-react';
import { Command, FolderGit2, FolderOpen, GitBranch, GitFork, Search, Users } from 'lucide-react';
import { CliStatusBanner } from './CliStatusBanner';
@ -52,7 +53,7 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
// Handle Cmd+K to open full command palette
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyK') {
e.preventDefault();
openCommandPalette();
}
@ -138,6 +139,41 @@ const RepositoryCard = ({
const mainWorktree = repo.worktrees.find((w) => w.isMainWorktree) ?? repo.worktrees[0];
const mainBranch = mainWorktree?.gitBranch;
// Detect if this is a worktree project:
// 1. No main worktree in the group (isMainWorktree flag)
// 2. OR the shown worktree has a tool-created source
// 3. OR path-based fallback for .claude/worktrees/ directories
const WORKTREE_PATH_MARKERS = [
'/.claude/worktrees/',
'/.claude-worktrees/',
'/.auto-claude/worktrees/',
'/.21st/worktrees/',
'/.ccswitch/worktrees/',
'/.cursor/worktrees/',
'/vibe-kanban/worktrees/',
'/conductor/workspaces/',
];
const shownWorktree = repo.worktrees[0];
const isWorktreeBySource =
shownWorktree?.source && !['git', 'unknown'].includes(shownWorktree.source);
const isWorktreeByPath =
shownWorktree && WORKTREE_PATH_MARKERS.some((m) => shownWorktree.path.includes(m));
const isWorktreeProject =
!repo.worktrees.some((w) => w.isMainWorktree) || isWorktreeBySource || isWorktreeByPath;
// Get the source label for worktree badge
const SOURCE_LABELS: Record<string, string> = {
'vibe-kanban': 'Vibe',
conductor: 'Conductor',
'auto-claude': 'Auto',
'21st': '21st',
'claude-desktop': 'Desktop',
'claude-code': 'Worktree',
ccswitch: 'ccswitch',
};
const worktreeSourceLabel = shownWorktree?.source && SOURCE_LABELS[shownWorktree.source];
const color = useMemo(() => projectColor(repo.name), [repo.name]);
const cardRef = useRef<HTMLButtonElement>(null);
const [isHovered, setIsHovered] = useState(false);
@ -171,30 +207,49 @@ const RepositoryCard = ({
{/* Icon + Project name */}
<div className="mb-1 flex items-center gap-2.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
<FolderGit2
className="size-4 transition-colors group-hover:text-text"
style={{ color: color.icon }}
/>
{isWorktreeProject ? (
<GitFork
className="size-4 transition-colors group-hover:text-text"
style={{ color: color.icon }}
/>
) : (
<FolderGit2
className="size-4 transition-colors group-hover:text-text"
style={{ color: color.icon }}
/>
)}
</div>
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
{repo.name}
</h3>
{isWorktreeProject && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-purple-500/15 px-1.5 py-0.5 text-[9px] font-medium text-purple-400">
{worktreeSourceLabel ?? 'Worktree'}
</span>
)}
</div>
{/* Project path - monospace, muted, clickable to open in file manager */}
<div
role="button"
tabIndex={0}
onClick={handleOpenPath}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleOpenPath(e as unknown as React.MouseEvent);
}}
className="flex w-full min-w-0 cursor-pointer items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary"
title={`Open in file manager: ${projectPath}`}
>
<FolderOpen className="size-3 shrink-0" />
<span className="truncate">{formattedPath}</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
role="button"
tabIndex={0}
onClick={handleOpenPath}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
handleOpenPath(e as unknown as React.MouseEvent);
}}
className="flex w-full min-w-0 cursor-pointer items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary"
>
<FolderOpen className="size-3 shrink-0" />
<span className="truncate">{formattedPath}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p className="font-mono text-[11px]">{projectPath}</p>
</TooltipContent>
</Tooltip>
{/* Git branch / worktree info */}
{mainBranch ? (
@ -316,10 +371,11 @@ function findMatchingWorktree(
}
const NewProjectCard = (): React.JSX.Element => {
const { repositoryGroups, fetchRepositoryGroups } = useStore(
const { repositoryGroups, fetchRepositoryGroups, openTeamsTab } = useStore(
useShallow((s) => ({
repositoryGroups: s.repositoryGroups,
fetchRepositoryGroups: s.fetchRepositoryGroups,
openTeamsTab: s.openTeamsTab,
}))
);
@ -341,6 +397,7 @@ const NewProjectCard = (): React.JSX.Element => {
const match = findMatchingWorktree(repositoryGroups, selectedPath);
if (match) {
navigateToMatch(match);
openTeamsTab();
return;
}
@ -350,11 +407,15 @@ const NewProjectCard = (): React.JSX.Element => {
const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath);
if (matchAfterRefresh) {
navigateToMatch(matchAfterRefresh);
openTeamsTab();
return;
}
// Still no match — create a synthetic group for this new folder and navigate to it.
// This allows launching teams in projects that don't have Claude sessions yet.
// Persist the path so it survives app restarts.
await api.config.addCustomProjectPath(selectedPath);
const encodedId = selectedPath.replace(/[/\\]/g, '-');
const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath;
const now = Date.now();
@ -382,6 +443,7 @@ const NewProjectCard = (): React.JSX.Element => {
repositoryGroups: [syntheticGroup, ...state.repositoryGroups],
}));
navigateToMatch({ repoId: encodedId, worktreeId: encodedId });
openTeamsTab();
} catch (error) {
logger.error('Error selecting folder:', error);
}

View file

@ -330,7 +330,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'g' && (e.metaKey || e.ctrlKey)) {
if (e.code === 'KeyG' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setGlobalSearchEnabled((prev) => !prev);
return;

View file

@ -42,6 +42,9 @@ export interface SafeConfig {
snoozedUntil: number | null;
snoozeMinutes: number;
includeSubagentErrors: boolean;
notifyOnLeadInbox: boolean;
notifyOnUserInbox: boolean;
notifyOnClarifications: boolean;
triggers: AppConfig['notifications']['triggers'];
};
display: {
@ -169,6 +172,9 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true,
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
triggers: displayConfig?.notifications?.triggers ?? [],
},
display: {

View file

@ -287,6 +287,9 @@ export function useSettingsHandlers({
snoozedUntil: null,
snoozeMinutes: 30,
includeSubagentErrors: true,
notifyOnLeadInbox: false,
notifyOnUserInbox: true,
notifyOnClarifications: true,
triggers: defaultTriggers,
},
general: {

View file

@ -32,7 +32,13 @@ interface NotificationsSectionProps {
readonly ignoredRepositoryItems: RepositoryDropdownItem[];
readonly excludedRepositoryIds: string[];
readonly onNotificationToggle: (
key: 'enabled' | 'soundEnabled' | 'includeSubagentErrors',
key:
| 'enabled'
| 'soundEnabled'
| 'includeSubagentErrors'
| 'notifyOnLeadInbox'
| 'notifyOnUserInbox'
| 'notifyOnClarifications',
value: boolean
) => void;
readonly onSnooze: (minutes: number) => Promise<void>;
@ -130,6 +136,36 @@ export const NotificationsSection = ({
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
<SettingRow
label="Lead inbox notifications"
description="Notify when teammates send messages to the team lead"
>
<SettingsToggle
enabled={safeConfig.notifications.notifyOnLeadInbox}
onChange={(v) => onNotificationToggle('notifyOnLeadInbox', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
<SettingRow
label="User inbox notifications"
description="Notify when teammates send messages to you"
>
<SettingsToggle
enabled={safeConfig.notifications.notifyOnUserInbox}
onChange={(v) => onNotificationToggle('notifyOnUserInbox', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
<SettingRow
label="Task clarification notifications"
description="Show native OS notifications when a task needs your input"
>
<SettingsToggle
enabled={safeConfig.notifications.notifyOnClarifications}
onChange={(v) => onNotificationToggle('notifyOnClarifications', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
<SettingRow
label="Snooze notifications"
description={

View file

@ -57,6 +57,7 @@ const SOURCE_LABELS: Record<WorktreeSource, string> = {
'auto-claude': 'Auto Claude',
'21st': '21st',
'claude-desktop': 'Claude Desktop',
'claude-code': 'Claude Code',
ccswitch: 'ccswitch',
git: 'Git',
unknown: 'Other',

View file

@ -6,6 +6,8 @@ interface MemberBadgeProps {
color?: string;
/** Avatar + badge size variant */
size?: 'sm' | 'md';
/** Hide the avatar icon, show only the name badge */
hideAvatar?: boolean;
onClick?: (name: string) => void;
}
@ -18,6 +20,7 @@ export const MemberBadge = ({
name,
color,
size = 'sm',
hideAvatar,
onClick,
}: MemberBadgeProps): React.JSX.Element => {
const colors = getTeamColorSet(color ?? '');
@ -59,7 +62,7 @@ export const MemberBadge = ({
onClick(name);
}}
>
{avatar}
{!hideAvatar && avatar}
{badge}
</button>
);
@ -67,7 +70,7 @@ export const MemberBadge = ({
return (
<span className="inline-flex items-center gap-1">
{avatar}
{!hideAvatar && avatar}
{badge}
</span>
);

View file

@ -16,6 +16,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
@ -26,6 +27,7 @@ import {
AlertTriangle,
Bell,
CheckCheck,
Code,
Columns3,
FolderOpen,
GitBranch,
@ -74,6 +76,7 @@ import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
import type { Session } from '@renderer/types/data';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type { EditorSelectionAction } from '@shared/types/editor';
@ -89,6 +92,7 @@ interface CreateTaskDialogState {
defaultDescription: string;
defaultOwner: string;
defaultStartImmediately?: boolean;
defaultChip?: InlineChip;
}
interface TimeWindow {
@ -148,6 +152,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const [trashOpen, setTrashOpen] = useState(false);
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
const [sendDialogDefaultText, setSendDialogDefaultText] = useState<string | undefined>(undefined);
const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState<InlineChip | undefined>(
undefined
);
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
undefined
);
@ -259,6 +266,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
});
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
// Open editor overlay when a file reveal is requested (e.g. from chip click)
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
useEffect(() => {
if (pendingRevealFile && data?.config.projectPath) {
setEditorOpen(true);
}
}, [pendingRevealFile, data?.config.projectPath]);
useEffect(() => {
if (!teamName) {
return;
@ -526,13 +541,26 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const handleEditorAction = useCallback(
(action: EditorSelectionAction) => {
const chip = createChipFromSelection(action, []) ?? undefined;
if (action.type === 'sendMessage') {
setSendDialogDefaultText(action.formattedContext);
setSendDialogDefaultText(chip ? undefined : action.formattedContext);
setSendDialogDefaultChip(chip);
setSendDialogRecipient(undefined);
setReplyQuote(undefined);
setSendDialogOpen(true);
} else if (action.type === 'createTask') {
openCreateTaskDialog('', action.formattedContext);
if (chip) {
setCreateTaskDialog({
open: true,
defaultSubject: '',
defaultDescription: '',
defaultOwner: '',
defaultStartImmediately: undefined,
defaultChip: chip,
});
} else {
openCreateTaskDialog('', action.formattedContext);
}
}
},
@ -805,13 +833,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<span className="max-w-60 truncate font-mono">
{formatProjectPath(data.config.projectPath)}
</span>
<button
onClick={() => setEditorOpen(true)}
className="ml-1 rounded px-1 py-0.5 text-[10px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
title="Open in Editor"
>
<Pencil size={10} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setEditorOpen(true)}
className="ml-1 flex items-center gap-0.5 rounded border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
>
<Code size={10} className="shrink-0" /> Edit code
</button>
</TooltipTrigger>
<TooltipContent>Open project in built-in editor</TooltipContent>
</Tooltip>
</span>
)}
{leadBranch && (
@ -921,6 +953,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onSendMessage={(member) => {
setSendDialogRecipient(member.name);
setSendDialogDefaultText(undefined);
setSendDialogDefaultChip(undefined);
setReplyQuote(undefined);
setSendDialogOpen(true);
}}
@ -1238,6 +1271,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onReplyToMessage={(message) => {
setSendDialogRecipient(message.from);
setSendDialogDefaultText(undefined);
setSendDialogDefaultChip(undefined);
setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) });
setSendDialogOpen(true);
}}
@ -1288,6 +1322,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setSelectedMember(null);
setSendDialogRecipient(name || undefined);
setSendDialogDefaultText(undefined);
setSendDialogDefaultChip(undefined);
setReplyQuote(undefined);
setSendDialogOpen(true);
}}
@ -1342,6 +1377,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
defaultDescription={createTaskDialog.defaultDescription}
defaultOwner={createTaskDialog.defaultOwner}
defaultStartImmediately={createTaskDialog.defaultStartImmediately}
defaultChip={createTaskDialog.defaultChip}
onClose={closeCreateTaskDialog}
onSubmit={handleCreateTask}
submitting={creatingTask}
@ -1450,6 +1486,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
members={activeMembers}
defaultRecipient={sendDialogRecipient}
defaultText={sendDialogDefaultText}
defaultChip={sendDialogDefaultChip}
quotedMessage={replyQuote}
sending={sendingMessage}
sendError={sendMessageError}
@ -1474,6 +1511,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setSendDialogOpen(false);
setReplyQuote(undefined);
setSendDialogDefaultText(undefined);
setSendDialogDefaultChip(undefined);
}}
/>
@ -1524,6 +1562,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
memberName={reviewDialogState.memberName}
taskId={reviewDialogState.taskId}
initialFilePath={reviewDialogState.initialFilePath}
projectPath={data.config.projectPath}
onEditorAction={handleEditorAction}
/>
</div>

View file

@ -541,7 +541,7 @@ export const TeamListView = (): React.JSX.Element => {
const renderHeader = (): React.JSX.Element => (
<div className="mb-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-[var(--color-text)]">Teams</h2>
<h2 className="text-base font-semibold text-[var(--color-text)]">Select Team</h2>
<div className="flex items-center gap-2">
<Button
variant="outline"

View file

@ -317,7 +317,12 @@ export const ActivityItem = ({
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
&rarr;
</span>
<MemberBadge name={message.to} color={recipientColor} onClick={onMemberNameClick} />
<MemberBadge
name={message.to}
color={recipientColor}
hideAvatar={message.to === 'user'}
onClick={onMemberNameClick}
/>
</>
) : null}

View file

@ -34,6 +34,7 @@ const MessageRowWithObserver = ({
memberColor,
recipientColor,
isUnread,
isNew,
onMemberNameClick,
onCreateTask,
onReply,
@ -46,6 +47,7 @@ const MessageRowWithObserver = ({
memberColor?: string;
recipientColor?: string;
isUnread?: boolean;
isNew?: boolean;
onMemberNameClick?: (name: string) => void;
onCreateTask?: (subject: string, description: string) => void;
onReply?: (message: InboxMessage) => void;
@ -83,7 +85,7 @@ const MessageRowWithObserver = ({
}, [onVisible]);
return (
<div ref={ref} className="min-h-px">
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
<ActivityItem
message={message}
teamName={teamName}
@ -113,6 +115,11 @@ export const ActivityTimeline = ({
}: ActivityTimelineProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
// --- New-message animation tracking ---
const knownKeysRef = useRef<Set<string>>(new Set<string>());
const isInitializedRef = useRef(false);
const prevVisibleCountRef = useRef(visibleCount);
// 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.
@ -152,19 +159,59 @@ export const ActivityTimeline = ({
// Auto-expand when user was seeing all and new messages arrive — derived state sync.
// Reading/updating ref during render is intentional (React docs: derived state sync).
/* eslint-disable react-hooks/refs -- ref stores previous frame's "showing all" for derived state sync */
/* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
const wasShowingAll = wasShowingAllRef.current;
if (wasShowingAll && hiddenCount > 0) {
setVisibleCount(messages.length);
}
wasShowingAllRef.current = hiddenCount === 0;
/* eslint-enable react-hooks/refs -- end of intentional ref access during render */
const visibleMessages = useMemo(
() => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages),
[messages, visibleCount, hiddenCount]
);
// Determine which messages are "new" (should animate).
const newMessageKeys = useMemo(() => {
const getKey = (msg: InboxMessage, idx: number): string =>
`${msg.messageId ?? idx}-${msg.timestamp}-${msg.from}`;
// First render: seed known keys, no animations
if (!isInitializedRef.current) {
isInitializedRef.current = true;
for (let i = 0; i < visibleMessages.length; i++) {
knownKeysRef.current.add(getKey(visibleMessages[i], i));
}
prevVisibleCountRef.current = visibleCount;
return new Set<string>();
}
// Pagination expansion ("Show more" / "Show all"): add keys silently
const isPaginationExpansion = visibleCount > prevVisibleCountRef.current;
prevVisibleCountRef.current = visibleCount;
if (isPaginationExpansion) {
for (let i = 0; i < visibleMessages.length; i++) {
knownKeysRef.current.add(getKey(visibleMessages[i], i));
}
return new Set<string>();
}
// Normal update: unknown keys are new messages
const newKeys = new Set<string>();
for (let i = 0; i < visibleMessages.length; i++) {
const key = getKey(visibleMessages[i], i);
if (!knownKeysRef.current.has(key)) {
newKeys.add(key);
knownKeysRef.current.add(key);
}
}
return newKeys;
}, [visibleMessages, visibleCount]);
/* eslint-enable react-hooks/refs -- end animation tracking block */
const handleShowMore = (): void => {
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
};
@ -203,6 +250,7 @@ export const ActivityTimeline = ({
memberColor={info?.color}
recipientColor={recipientColor}
isUnread={isUnread}
isNew={newMessageKeys.has(messageKey)}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}

View file

@ -22,11 +22,16 @@ import {
SelectValue,
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useStore } from '@renderer/store';
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertTriangle, Search } from 'lucide-react';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -40,6 +45,7 @@ interface CreateTaskDialogProps {
defaultDescription?: string;
defaultOwner?: string;
defaultStartImmediately?: boolean;
defaultChip?: InlineChip;
onClose: () => void;
onSubmit: (
subject: string,
@ -63,16 +69,19 @@ export const CreateTaskDialog = ({
defaultDescription = '',
defaultOwner = '',
defaultStartImmediately,
defaultChip,
onClose,
onSubmit,
submitting = false,
}: CreateTaskDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const [subject, setSubject] = useState(defaultSubject);
const descriptionDraft = useDraftPersistence({
key: `createTask:${teamName}:description`,
initialValue: defaultDescription || undefined,
});
const descChipDraft = useChipDraftPersistence(`createTask:${teamName}:descChips`);
const [owner, setOwner] = useState<string>(defaultOwner);
const [blockedBy, setBlockedBy] = useState<string[]>([]);
const [related, setRelated] = useState<string[]>([]);
@ -84,7 +93,11 @@ export const CreateTaskDialog = ({
if (open && !prevOpen) {
setSubject(defaultSubject);
if (defaultDescription) {
if (defaultChip) {
const token = chipToken(defaultChip);
descriptionDraft.setValue(token + '\n');
descChipDraft.setChips([defaultChip]);
} else if (defaultDescription) {
descriptionDraft.setValue(defaultDescription);
}
setOwner(defaultOwner);
@ -130,11 +143,23 @@ export const CreateTaskDialog = ({
);
};
const handleDescChipRemove = (chipId: string): void => {
const chip = descChipDraft.chips.find((c) => c.id === chipId);
if (chip) {
descriptionDraft.setValue(removeChipTokenFromText(descriptionDraft.value, chip));
}
descChipDraft.setChips(descChipDraft.chips.filter((c) => c.id !== chipId));
};
const handleSubmit = (): void => {
if (!canSubmit) return;
const serializedDesc = serializeChipsWithText(
descriptionDraft.value.trim(),
descChipDraft.chips
);
onSubmit(
subject.trim(),
descriptionDraft.value.trim(),
serializedDesc,
owner || undefined,
blockedBy.length > 0 ? blockedBy : undefined,
related.length > 0 ? related : undefined,
@ -142,6 +167,7 @@ export const CreateTaskDialog = ({
startImmediately
);
descriptionDraft.clearDraft();
descChipDraft.clearChipDraft();
promptDraft.clearDraft();
};
@ -239,6 +265,10 @@ export const CreateTaskDialog = ({
value={descriptionDraft.value}
onValueChange={descriptionDraft.setValue}
suggestions={mentionSuggestions}
chips={descChipDraft.chips}
onChipRemove={handleDescChipRemove}
projectPath={projectPath}
onFileChipInsert={(chip) => descChipDraft.setChips([...descChipDraft.chips, chip])}
minRows={3}
maxRows={12}
footerRight={
@ -259,6 +289,7 @@ export const CreateTaskDialog = ({
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
projectPath={projectPath}
minRows={3}
maxRows={12}
footerRight={

View file

@ -29,6 +29,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
import { MembersJsonEditor } from './MembersJsonEditor';
import { ProjectPathSelector } from './ProjectPathSelector';
@ -142,15 +143,33 @@ function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
.filter((member): member is NonNullable<typeof member> => member !== null);
}
// eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS
const TEAM_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MEMBER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
/** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */
function sanitizeTeamName(name: string): string {
let result = name
.replace(/[^a-zA-Z0-9]/g, '-')
.replace(/-{2,}/g, '-')
.toLowerCase();
// Trim leading/trailing dashes without backtracking-vulnerable regex
while (result.startsWith('-')) result = result.slice(1);
while (result.endsWith('-')) result = result.slice(0, -1);
return result;
}
function isValidMemberName(name: string): boolean {
if (name.length < 1 || name.length > 128) return false;
if (!/^[a-zA-Z0-9]/.test(name)) return false;
return /^[a-zA-Z0-9._-]+$/.test(name);
}
function validateTeamNameInline(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (!TEAM_NAME_RE.test(trimmed) || trimmed.length > 64) {
return 'Use kebab-case [a-z0-9-], max 64 chars';
const sanitized = sanitizeTeamName(trimmed);
if (!sanitized) {
return 'Name must contain at least one letter or digit';
}
if (sanitized.length > 128) {
return 'Name is too long (max 128 chars)';
}
return null;
}
@ -158,7 +177,7 @@ function validateTeamNameInline(name: string): string | null {
function validateMemberNameInline(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (!MEMBER_NAME_RE.test(trimmed)) {
if (!isValidMemberName(trimmed)) {
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
return null;
@ -169,11 +188,20 @@ function validateRequest(
options?: { requireCwd?: boolean }
): ValidationResult {
const requireCwd = options?.requireCwd ?? true;
if (!TEAM_NAME_RE.test(request.teamName) || request.teamName.length > 64) {
const sanitized = sanitizeTeamName(request.teamName);
if (!sanitized) {
return {
valid: false,
errors: {
teamName: 'Use kebab-case [a-z0-9-], max 64 chars',
teamName: 'Name must contain at least one letter or digit',
},
};
}
if (sanitized.length > 128) {
return {
valid: false,
errors: {
teamName: 'Name is too long (max 128 chars)',
},
};
}
@ -201,7 +229,7 @@ function validateRequest(
},
};
}
if (request.members.some((member) => !MEMBER_NAME_RE.test(member.name.trim()))) {
if (request.members.some((member) => !isValidMemberName(member.name.trim()))) {
return {
valid: false,
errors: {
@ -257,11 +285,26 @@ export const CreateTeamDialog = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [selectedModel, setSelectedModelRaw] = useState(
() => localStorage.getItem('team:lastSelectedModel') ?? ''
);
const [extendedContext, setExtendedContextRaw] = useState(
() => localStorage.getItem('team:lastExtendedContext') === 'true'
);
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value);
localStorage.setItem('team:lastSelectedModel', value);
};
const setExtendedContext = (value: boolean): void => {
setExtendedContextRaw(value);
localStorage.setItem('team:lastExtendedContext', String(value));
};
const resetUIState = (): void => {
setLocalError(null);
setFieldErrors({});
@ -281,7 +324,6 @@ export const CreateTeamDialog = ({
setSelectedProjectPath('');
setCustomCwd('');
setLaunchTeam(true);
setSelectedModel('');
setJsonEditorOpen(false);
setJsonText('');
setJsonError(null);
@ -516,12 +558,19 @@ export const CreateTeamDialog = ({
[members]
);
const effectiveModel =
selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
const effectiveModel = useMemo(() => {
const base = selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
if (!extendedContext) return base;
// 1M context is only supported for opus and sonnet
if (base === 'haiku') return base;
return base ? `${base}[1m]` : 'sonnet[1m]';
}, [selectedModel, extendedContext]);
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
const request = useMemo<TeamCreateRequest>(
() => ({
teamName: teamName.trim(),
teamName: sanitizedTeamName,
description: description.trim() || undefined,
color: teamColor || undefined,
members: buildMembers(members),
@ -529,7 +578,7 @@ export const CreateTeamDialog = ({
prompt: prompt.trim() || undefined,
model: effectiveModel,
}),
[teamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel]
[sanitizedTeamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel]
);
const activeError = localError ?? provisioningError;
@ -576,7 +625,7 @@ export const CreateTeamDialog = ({
};
const handleSubmit = (): void => {
if (existingTeamNames.includes(request.teamName)) {
if (existingTeamNames.includes(sanitizedTeamName)) {
setFieldErrors({ teamName: 'Team name already exists' });
setLocalError('Check form fields');
return;
@ -600,6 +649,7 @@ export const CreateTeamDialog = ({
description: request.description,
color: request.color,
members: request.members,
cwd: effectiveCwd || undefined,
});
onOpenTeam(request.teamName, effectiveCwd || undefined);
resetFormState();
@ -710,13 +760,18 @@ export const CreateTeamDialog = ({
onChange={(event) => setTeamName(event.target.value)}
placeholder="team-alpha"
/>
{existingTeamNames.includes(teamName.trim()) ? (
{existingTeamNames.includes(sanitizedTeamName) ? (
<p className="text-[11px] text-red-300">Team name already exists</p>
) : validateTeamNameInline(teamName) ? (
<p className="text-[11px] text-red-300">{validateTeamNameInline(teamName)}</p>
) : fieldErrors.teamName ? (
<p className="text-[11px] text-red-300">{fieldErrors.teamName}</p>
) : null}
{sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
On disk: <span className="font-mono">{sanitizedTeamName}</span>
</p>
) : null}
</div>
<div className="space-y-1.5 md:col-span-2">
@ -870,6 +925,7 @@ export const CreateTeamDialog = ({
value={prompt}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
projectPath={effectiveCwd || null}
placeholder="Instructions for the team lead during provisioning..."
footerRight={
promptDraft.isSaved ? (
@ -881,19 +937,27 @@ export const CreateTeamDialog = ({
/>
</div>
<div className="space-y-1.5">
<Label className="label-optional">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
<div>
<div className="flex items-center gap-2.5">
<Label className="label-optional shrink-0">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 w-auto min-w-[180px] text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
</div>
<ExtendedContextCheckbox
id="create-extended-context"
checked={extendedContext}
onCheckedChange={setExtendedContext}
disabled={selectedModel === 'haiku'}
/>
</div>
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (

View file

@ -0,0 +1,66 @@
import React from 'react';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import { AlertTriangle } from 'lucide-react';
interface ExtendedContextCheckboxProps {
id: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
}
export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = ({
id,
checked,
onCheckedChange,
disabled = false,
}) => (
<>
<div className="mt-2 flex items-center gap-2">
<Checkbox
id={id}
checked={checked && !disabled}
disabled={disabled}
onCheckedChange={(value) => onCheckedChange(value === true)}
/>
<Label
htmlFor={id}
className={`flex cursor-pointer items-center gap-1.5 text-xs font-normal ${
disabled ? 'cursor-not-allowed text-text-muted opacity-50' : 'text-text-secondary'
}`}
>
Extended context (1M tokens)
{disabled && <span className="text-[10px] italic">(not available for this model)</span>}
</Label>
</div>
{checked && (
<div className="mt-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" />
<div className="space-y-1 text-amber-300/90">
<p>
Beyond 200K tokens, premium pricing applies: 2x input cost, 1.5x output cost. For
subscribers, extra usage is billed separately.
</p>
<p>
Requires API tier 4+ or extra usage enabled.{' '}
<button
type="button"
className="underline underline-offset-2 hover:text-amber-200"
onClick={() =>
window.electronAPI.openExternal(
'https://platform.claude.com/docs/en/build-with-claude/context-windows#1m-token-context-window'
)
}
>
Learn more
</button>
</p>
</div>
</div>
</div>
)}
</>
);

View file

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import {
@ -65,9 +66,24 @@ export const LaunchTeamDialog = ({
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedModel, setSelectedModel] = useState('');
const [selectedModel, setSelectedModelRaw] = useState(
() => localStorage.getItem('team:lastSelectedModel') ?? ''
);
const [extendedContext, setExtendedContextRaw] = useState(
() => localStorage.getItem('team:lastExtendedContext') === 'true'
);
const [clearContext, setClearContext] = useState(false);
const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value);
localStorage.setItem('team:lastSelectedModel', value);
};
const setExtendedContext = (value: boolean): void => {
setExtendedContextRaw(value);
localStorage.setItem('team:lastExtendedContext', String(value));
};
const resetFormState = (): void => {
setLocalError(null);
setIsSubmitting(false);
@ -77,7 +93,6 @@ export const LaunchTeamDialog = ({
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
setSelectedModel('');
setClearContext(false);
};
@ -240,7 +255,12 @@ export const LaunchTeamDialog = ({
teamName,
cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined,
model: selectedModel || undefined,
model: (() => {
if (!extendedContext) return selectedModel || undefined;
// 1M context is only supported for opus and sonnet
if (selectedModel === 'haiku') return selectedModel;
return selectedModel ? `${selectedModel}[1m]` : 'sonnet[1m]';
})(),
clearContext: clearContext || undefined,
});
resetFormState();
@ -344,6 +364,7 @@ export const LaunchTeamDialog = ({
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
projectPath={effectiveCwd || null}
placeholder="Instructions for team lead... Use @ to mention team members."
footerRight={
promptDraft.isSaved ? (
@ -353,30 +374,38 @@ export const LaunchTeamDialog = ({
/>
</div>
<div className="space-y-1.5">
<Label className="label-optional">Model (optional)</Label>
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{[
{ value: '', label: 'Default' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.5' },
{ value: 'haiku', label: 'Haiku 4.5' },
].map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
selectedModel === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => setSelectedModel(opt.value)}
>
{opt.label}
</button>
))}
<div>
<div className="flex items-center gap-2.5">
<Label className="label-optional shrink-0">Model (optional)</Label>
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{[
{ value: '', label: 'Default' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.5' },
{ value: 'haiku', label: 'Haiku 4.5' },
].map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
selectedModel === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => setSelectedModel(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
<ExtendedContextCheckbox
id="launch-extended-context"
checked={extendedContext}
onCheckedChange={setExtendedContext}
disabled={selectedModel === 'haiku'}
/>
</div>
<div className="space-y-2">

View file

@ -12,6 +12,7 @@ import {
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -35,6 +36,7 @@ export const ReviewDialog = ({
onCancel,
onSubmit,
}: ReviewDialogProps): React.JSX.Element => {
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const draft = useDraftPersistence({
key: `requestChanges:${teamName}:${taskId ?? ''}`,
enabled: Boolean(teamName && taskId),
@ -88,7 +90,7 @@ export const ReviewDialog = ({
onValueChange={draft.setValue}
placeholder="Describe what needs to change..."
suggestions={mentionSuggestions}
hintText="Use @ to mention team members"
projectPath={projectPath}
footerRight={
draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -21,12 +22,19 @@ import {
} from '@renderer/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useStore } from '@renderer/store';
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
@ -41,6 +49,8 @@ interface SendMessageDialogProps {
defaultRecipient?: string;
/** Pre-filled message text (e.g. from editor selection action) */
defaultText?: string;
/** Pre-filled inline code chip (from editor selection action) */
defaultChip?: InlineChip;
quotedMessage?: QuotedMessage;
sending: boolean;
sendError: string | null;
@ -56,6 +66,7 @@ export const SendMessageDialog = ({
members,
defaultRecipient,
defaultText,
defaultChip,
quotedMessage,
sending,
sendError,
@ -64,9 +75,12 @@ export const SendMessageDialog = ({
onClose,
}: SendMessageDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
const [quoteExpanded, setQuoteExpanded] = useState(false);
const [member, setMember] = useState('');
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
const chipDraft = useChipDraftPersistence('sendMessage:chips');
const [summary, setSummary] = useState('');
const [prevOpen, setPrevOpen] = useState(false);
const [prevResult, setPrevResult] = useState<SendMessageResult | null>(null);
@ -76,8 +90,13 @@ export const SendMessageDialog = ({
setMember(defaultRecipient ?? '');
setSummary('');
setQuote(quotedMessage);
setQuoteExpanded(false);
setPrevResult(lastResult);
if (defaultText) {
if (defaultChip) {
const token = chipToken(defaultChip);
textDraft.setValue(token + '\n');
chipDraft.setChips([defaultChip]);
} else if (defaultText) {
textDraft.setValue(defaultText);
}
}
@ -98,12 +117,16 @@ export const SendMessageDialog = ({
useEffect(() => {
if (pendingAutoClose) {
textDraft.clearDraft();
chipDraft.clearChipDraft();
setPendingAutoClose(false);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only trigger on pendingAutoClose flag
}, [pendingAutoClose]);
const QUOTE_COLLAPSE_THRESHOLD = 120;
const isQuoteLong = (quote?.text.length ?? 0) > QUOTE_COLLAPSE_THRESHOLD;
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
@ -121,12 +144,21 @@ export const SendMessageDialog = ({
summary.trim().length > 0 &&
!sending;
const handleChipRemove = (chipId: string): void => {
const chip = chipDraft.chips.find((c) => c.id === chipId);
if (chip) {
textDraft.setValue(removeChipTokenFromText(textDraft.value, chip));
}
chipDraft.setChips(chipDraft.chips.filter((c) => c.id !== chipId));
};
const handleSubmit = (): void => {
if (!canSend) return;
const rawText = textDraft.value.trim();
const finalText = quote ? buildReplyBlock(quote.from, quote.text, rawText) : rawText;
const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips);
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
onSend(member.trim(), finalText, summary.trim());
textDraft.clearDraft();
chipDraft.clearChipDraft();
};
const handleOpenChange = (nextOpen: boolean): void => {
@ -182,45 +214,73 @@ export const SendMessageDialog = ({
</Select>
</div>
{quote ? (
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setQuote(undefined)}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Remove quote</TooltipContent>
</Tooltip>
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to @{quote.from}
</span>
<p className="line-clamp-3 pr-5 text-xs text-[var(--color-text-muted)]">
{quote.text}
</p>
</div>
) : null}
<div className="grid gap-2">
<Label htmlFor="smd-message">Message</Label>
<MentionableTextarea
id="smd-message"
placeholder="Write your message..."
value={textDraft.value}
onValueChange={textDraft.setValue}
suggestions={mentionSuggestions}
minRows={4}
maxRows={12}
footerRight={
textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
<div className={quote ? 'flex flex-col' : 'contents'}>
{quote ? (
<div className="relative overflow-hidden rounded-t-md border border-b-0 border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
{/* Decorative quotation mark */}
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[64px] leading-none text-blue-400/[0.08]">
&ldquo;
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-1.5 top-1.5 z-10 rounded p-0.5 text-blue-300/40 hover:text-blue-200"
onClick={() => setQuote(undefined)}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Remove quote</TooltipContent>
</Tooltip>
<div className="mb-1 flex items-center gap-1.5">
<span className="text-[10px] text-blue-300/60">Replying to</span>
<MemberBadge name={quote.from} color={colorMap.get(quote.from)} size="sm" />
</div>
<div
className={`pr-5 opacity-50 ${quoteExpanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}
>
<MarkdownViewer
content={quote.text}
bare
maxHeight={quoteExpanded ? 'max-h-48' : 'max-h-[3.75rem]'}
/>
</div>
{isQuoteLong ? (
<button
type="button"
className="mt-0.5 text-[10px] text-blue-400/60 hover:text-blue-300"
onClick={() => setQuoteExpanded((v) => !v)}
>
{quoteExpanded ? 'less' : 'more'}
</button>
) : null}
</div>
) : null}
<MentionableTextarea
id="smd-message"
className={quote ? 'rounded-t-none' : undefined}
placeholder="Write your message..."
value={textDraft.value}
onValueChange={textDraft.setValue}
suggestions={mentionSuggestions}
chips={chipDraft.chips}
onChipRemove={handleChipRemove}
projectPath={projectPath}
onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])}
minRows={4}
maxRows={12}
footerRight={
textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
</div>
</div>
<div className="grid gap-2">

View file

@ -33,6 +33,7 @@ export const TaskCommentInput = ({
}: TaskCommentInputProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
@ -109,6 +110,7 @@ export const TaskCommentInput = ({
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}
projectPath={projectPath}
minRows={2}
maxRows={8}
maxLength={MAX_COMMENT_LENGTH}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
@ -54,11 +54,16 @@ export const TaskCommentsSection = ({
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
useEffect(() => {
// Reset local state when team/task changes (React-recommended pattern for
// adjusting state based on props without using effects or refs during render)
const currentKey = teamIdKey(teamName, taskId);
const [prevKey, setPrevKey] = useState(currentKey);
if (prevKey !== currentKey) {
setPrevKey(currentKey);
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
setExpandedCommentIds(new Set());
setReplyTo(null);
}, [teamIdKey(teamName, taskId)]);
}
const toggleCommentExpanded = useCallback((commentId: string) => {
setExpandedCommentIds((prev) => {

View file

@ -598,6 +598,7 @@ export const TaskDetailDialog = ({
taskId={currentTask.id}
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
taskWorkIntervals={currentTask.workIntervals}
/>
</div>
</CollapsibleTeamSection>

View file

@ -9,8 +9,14 @@
import { useCallback, useEffect, useRef } from 'react';
import { defaultKeymap, history, historyKeymap, redo, undo } from '@codemirror/commands';
import { bracketMatching, indentOnInput, syntaxHighlighting } from '@codemirror/language';
import { search, searchKeymap } from '@codemirror/search';
import {
bracketMatching,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import { gotoLine, search, searchKeymap } from '@codemirror/search';
import { Compartment, EditorState } from '@codemirror/state';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import {
@ -20,11 +26,16 @@ import {
keymap,
lineNumbers,
} from '@codemirror/view';
import {
createSearchPanel,
editorSearchPanelTheme,
} from '@renderer/components/team/editor/EditorSearchPanel';
import { useStore } from '@renderer/store';
import {
getAsyncLanguageDesc,
getSyncLanguageExtension,
} from '@renderer/utils/codemirrorLanguages';
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
import { editorBridge } from '@renderer/utils/editorBridge';
@ -40,9 +51,6 @@ const DIRTY_DEBOUNCE_MS = 300;
const AUTOSAVE_DELAY_MS = 30_000;
const MAX_DRAFT_SIZE = 500 * 1024; // 500KB
const MAX_DRAFTS = 10;
const SELECTION_DEBOUNCE_MS = 150;
const MAX_SELECTION_TEXT = 5000;
/** Compartment for dynamic line wrap toggling */
const lineWrapCompartment = new Compartment();
@ -65,35 +73,8 @@ interface CodeMirrorEditorProps {
onDraftRecovered?: (filePath: string) => void;
/** Called when text selection changes (for floating action menu) */
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
}
// =============================================================================
// Selection info helper
// =============================================================================
function buildSelectionInfo(
view: EditorView,
sel: { from: number; to: number }
): EditorSelectionInfo | null {
const coords = view.coordsAtPos(sel.to);
if (!coords) return null; // selection end is off-screen
let text = view.state.sliceDoc(sel.from, sel.to);
if (text.length > MAX_SELECTION_TEXT) {
text = text.slice(0, MAX_SELECTION_TEXT) + '…';
}
return {
text,
filePath: '', // filled by parent (CodeMirrorEditor has no file context in buildEditableExtensions)
fromLine: view.state.doc.lineAt(sel.from).number,
toLine: view.state.doc.lineAt(sel.to).number,
screenRect: {
top: coords.top,
right: coords.right ?? coords.left,
bottom: coords.bottom,
},
};
/** Called with the current document text on changes (debounced, for live preview) */
onDocChange?: (content: string) => void;
}
// =============================================================================
@ -122,12 +103,14 @@ function buildEditableExtensions(
highlightActiveLineGutter(),
bracketMatching(),
indentOnInput(),
foldGutter(),
// History
history(),
// Search (Cmd+F)
search(),
// Search (Cmd+F) — custom panel with UI Kit
search({ createPanel: createSearchPanel }),
editorSearchPanelTheme,
// Save keymap (Cmd+S / Ctrl+S)
keymap.of([
@ -150,7 +133,13 @@ function buildEditableExtensions(
]),
// Keymaps
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
// Filter out built-in gotoLine (Alt-g) — replaced by custom GoToLineDialog
keymap.of([
...defaultKeymap,
...historyKeymap,
...searchKeymap.filter((k) => k.run !== gotoLine),
...foldKeymap,
]),
// Update listener for dirty flag + cursor position + selection
EditorView.updateListener.of((update) => {
@ -241,6 +230,8 @@ function enforceDraftLimit(): void {
// Component
// =============================================================================
const DOC_CHANGE_DEBOUNCE_MS = 150;
export const CodeMirrorEditor = ({
filePath,
content,
@ -249,6 +240,7 @@ export const CodeMirrorEditor = ({
onCursorChange,
onDraftRecovered,
onSelectionChange,
onDocChange,
}: CodeMirrorEditorProps): React.ReactElement => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
@ -262,6 +254,8 @@ export const CodeMirrorEditor = ({
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Selection debounce
const selectionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Doc change debounce (live preview)
const docChangeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const markFileModified = useStore((s) => s.markFileModified);
const discardChanges = useStore((s) => s.discardChanges);
@ -281,6 +275,9 @@ export const CodeMirrorEditor = ({
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
const onDocChangeRef = useRef(onDocChange);
onDocChangeRef.current = onDocChange;
const lineWrapRef = useRef(lineWrap);
lineWrapRef.current = lineWrap;
@ -303,6 +300,13 @@ export const CodeMirrorEditor = ({
saveDraft(filePathRef.current, view.state.doc.toString());
}
}, AUTOSAVE_DELAY_MS);
// Live content callback for markdown preview
if (docChangeTimerRef.current) clearTimeout(docChangeTimerRef.current);
docChangeTimerRef.current = setTimeout(() => {
const view = viewRef.current;
if (view) onDocChangeRef.current?.(view.state.doc.toString());
}, DOC_CHANGE_DEBOUNCE_MS);
}, [markFileModified]);
const handleCursorMove = useCallback((line: number, col: number) => {
@ -442,6 +446,7 @@ export const CodeMirrorEditor = ({
const dirtyTimer = dirtyTimerRef;
const autosaveTimer = autosaveTimerRef;
const selectionTimer = selectionTimerRef;
const docChangeTimer = docChangeTimerRef;
return () => {
// Save scroll position before destroying
@ -454,6 +459,7 @@ export const CodeMirrorEditor = ({
if (dirtyTimer.current) clearTimeout(dirtyTimer.current);
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
if (selectionTimer.current) clearTimeout(selectionTimer.current);
if (docChangeTimer.current) clearTimeout(docChangeTimer.current);
view.destroy();
viewRef.current = null;
@ -496,5 +502,5 @@ export const CodeMirrorEditor = ({
};
}, []);
return <div ref={containerRef} className="size-full overflow-auto" />;
return <div ref={containerRef} className="size-full overflow-hidden" />;
};

View file

@ -0,0 +1,42 @@
/**
* Placeholder for non-previewable binary files shows file info and "Open in System Viewer" button.
*/
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { FileQuestion } from 'lucide-react';
interface EditorBinaryPlaceholderProps {
filePath: string;
fileName: string;
size: number;
}
export const EditorBinaryPlaceholder = ({
filePath,
fileName,
size,
}: EditorBinaryPlaceholderProps): React.ReactElement => {
const projectPath = useStore((s) => s.editorProjectPath);
const sizeFormatted =
size < 1024
? `${size} B`
: size < 1024 * 1024
? `${(size / 1024).toFixed(1)} KB`
: `${(size / 1024 / 1024).toFixed(1)} MB`;
const handleOpenExternal = (): void => {
window.electronAPI.openPath(filePath, projectPath ?? undefined).catch(console.error);
};
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<FileQuestion className="size-12 opacity-30" />
<p className="text-sm font-medium text-text-secondary">{fileName}</p>
<p className="text-xs">Binary file ({sizeFormatted})</p>
<Button variant="outline" size="sm" className="mt-2" onClick={handleOpenExternal}>
Open in System Viewer
</Button>
</div>
);
};

View file

@ -1,8 +1,12 @@
/**
* Placeholder for binary files shows file info and "Open in System Viewer" button.
* Router for binary file display picks the right preview component
* based on file type from the preview registry.
*/
import { FileQuestion } from 'lucide-react';
import { getPreviewType, isPreviewable } from '@renderer/utils/previewRegistry';
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
import { EditorImagePreview } from './EditorImagePreview';
interface EditorBinaryStateProps {
filePath: string;
@ -14,28 +18,11 @@ export const EditorBinaryState = ({
size,
}: EditorBinaryStateProps): React.ReactElement => {
const fileName = filePath.split('/').pop() ?? filePath;
const sizeFormatted =
size < 1024
? `${size} B`
: size < 1024 * 1024
? `${(size / 1024).toFixed(1)} KB`
: `${(size / 1024 / 1024).toFixed(1)} MB`;
const previewType = getPreviewType(fileName);
const handleOpenExternal = (): void => {
window.electronAPI.openPath(filePath).catch(console.error);
};
if (previewType === 'image' && isPreviewable(fileName, size)) {
return <EditorImagePreview filePath={filePath} fileName={fileName} size={size} />;
}
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<FileQuestion className="size-12 opacity-30" />
<p className="text-sm font-medium text-text-secondary">{fileName}</p>
<p className="text-xs">Binary file ({sizeFormatted})</p>
<button
onClick={handleOpenExternal}
className="mt-2 rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
>
Open in System Viewer
</button>
</div>
);
return <EditorBinaryPlaceholder filePath={filePath} fileName={fileName} size={size} />;
};

View file

@ -4,20 +4,25 @@
* Each segment is clickable expands and scrolls the folder in the file tree.
*/
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { ChevronRight } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { getFileIcon } from './fileIcons';
import { FileIcon } from './FileIcon';
// =============================================================================
// Component
// =============================================================================
export const EditorBreadcrumb = (): React.ReactElement | null => {
const activeTabId = useStore((s) => s.editorActiveTabId);
const projectPath = useStore((s) => s.editorProjectPath);
const { activeTabId, projectPath } = useStore(
useShallow((s) => ({
activeTabId: s.editorActiveTabId,
projectPath: s.editorProjectPath,
}))
);
const expandDirectory = useStore((s) => s.expandDirectory);
const segments = useMemo(() => {
@ -30,19 +35,19 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
return relativePath.split('/');
}, [activeTabId, projectPath]);
const handleSegmentClick = useCallback(
(segmentIndex: number): void => {
if (!projectPath) return;
const dirSegments = segments.slice(0, segmentIndex + 1);
const dirPath = `${projectPath}/${dirSegments.join('/')}`;
void expandDirectory(dirPath);
},
[segments, projectPath, expandDirectory]
);
if (segments.length === 0) return null;
const fileName = segments[segments.length - 1];
const iconInfo = getFileIcon(fileName);
const Icon = iconInfo.icon;
const handleSegmentClick = (segmentIndex: number): void => {
if (!projectPath) return;
// Build absolute path up to this segment (it's a directory)
const dirSegments = segments.slice(0, segmentIndex + 1);
const dirPath = `${projectPath}/${dirSegments.join('/')}`;
void expandDirectory(dirPath);
};
return (
<div className="flex items-center gap-0.5 overflow-x-auto px-3 py-1 text-xs text-text-muted">
@ -53,7 +58,7 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
{idx > 0 && <ChevronRight className="text-text-muted/50 size-3" />}
{isLast ? (
<span className="flex items-center gap-1 text-text-secondary">
<Icon className="size-3" style={{ color: iconInfo.color }} />
<FileIcon fileName={fileName} className="size-3" />
{segment}
</span>
) : (

View file

@ -9,7 +9,16 @@
import React, { useCallback, useRef, useState } from 'react';
import * as ContextMenu from '@radix-ui/react-context-menu';
import { FilePlus, FolderOpen, FolderPlus, Trash2 } from 'lucide-react';
import {
ClipboardCopy,
FilePlus,
FolderOpen,
FolderPlus,
ListTodo,
MessageSquare,
Pencil,
Trash2,
} from 'lucide-react';
// =============================================================================
// Types
@ -23,9 +32,15 @@ interface TargetEntry {
interface EditorContextMenuProps {
children: React.ReactNode;
projectPath: string | null;
onNewFile: (parentDir: string) => void;
onNewFolder: (parentDir: string) => void;
onDelete: (path: string) => void;
onRename: (path: string) => void;
/** Trigger "Create Task" with a file mention (files only, not directories) */
onCreateTask?: (filePath: string) => void;
/** Trigger "Write Teammate" with a file mention (files only, not directories) */
onSendMessage?: (filePath: string) => void;
}
// =============================================================================
@ -34,9 +49,13 @@ interface EditorContextMenuProps {
export const EditorContextMenu = ({
children,
projectPath,
onNewFile,
onNewFolder,
onDelete,
onRename,
onCreateTask,
onSendMessage,
}: EditorContextMenuProps): React.ReactElement => {
const [target, setTarget] = useState<TargetEntry | null>(null);
const triggerRef = useRef<HTMLDivElement>(null);
@ -71,7 +90,7 @@ export const EditorContextMenu = ({
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<div ref={triggerRef} onContextMenu={handleContextMenu}>
<div ref={triggerRef} onContextMenu={handleContextMenu} className="h-full">
{children}
</div>
</ContextMenu.Trigger>
@ -102,6 +121,15 @@ export const EditorContextMenu = ({
{target && (
<>
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50"
disabled={target.isSensitive}
onSelect={() => onRename(target.path)}
>
<Pencil className="size-3.5 text-text-muted" />
Rename
</ContextMenu.Item>
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-red-400 outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50"
disabled={target.isSensitive}
@ -116,15 +144,65 @@ export const EditorContextMenu = ({
)}
{target && (
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={() => {
void window.electronAPI.showInFolder(target.path);
}}
>
<FolderOpen className="size-3.5 text-text-muted" />
Reveal in Finder
</ContextMenu.Item>
<>
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={() => void navigator.clipboard.writeText(target.path)}
>
<ClipboardCopy className="size-3.5 text-text-muted" />
Copy Path
</ContextMenu.Item>
{projectPath && target.path.startsWith(projectPath) && (
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={() => {
const relative = target.path.slice(projectPath.length + 1);
void navigator.clipboard.writeText(relative);
}}
>
<ClipboardCopy className="size-3.5 text-text-muted" />
Copy Relative Path
</ContextMenu.Item>
)}
<ContextMenu.Separator className="my-1 h-px bg-border" />
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={() => {
void window.electronAPI.showInFolder(target.path);
}}
>
<FolderOpen className="size-3.5 text-text-muted" />
Reveal in Finder
</ContextMenu.Item>
</>
)}
{/* Team actions — file only */}
{target && !target.isDir && (onCreateTask || onSendMessage) && (
<>
<ContextMenu.Separator className="my-1 h-px bg-border" />
{onSendMessage && (
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={() => onSendMessage(target.path)}
>
<MessageSquare className="size-3.5 text-text-muted" />
Write Teammate
</ContextMenu.Item>
)}
{onCreateTask && (
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={() => onCreateTask(target.path)}
>
<ListTodo className="size-3.5 text-text-muted" />
Create Task
</ContextMenu.Item>
)}
</>
)}
</ContextMenu.Content>
</ContextMenu.Portal>

View file

@ -2,6 +2,7 @@
* Error state for file read failures (EACCES, ENOENT, etc.).
*/
import { Button } from '@renderer/components/ui/button';
import { AlertTriangle } from 'lucide-react';
interface EditorErrorStateProps {
@ -25,22 +26,14 @@ export const EditorErrorState = ({
<p className="max-w-md text-center text-sm text-text-secondary">{error}</p>
<div className="flex gap-2">
{onRetry && (
<button
type="button"
onClick={onRetry}
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
>
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</button>
</Button>
)}
{onClose && (
<button
type="button"
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
>
<Button variant="outline" size="sm" onClick={onClose}>
Close Tab
</button>
</Button>
)}
</div>
</div>

View file

@ -16,13 +16,23 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store';
import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { EditorContextMenu } from './EditorContextMenu';
import { getFileIcon } from './fileIcons';
import { FileIcon } from './FileIcon';
import { GitStatusBadge } from './GitStatusBadge';
import { NewFileDialog } from './NewFileDialog';
@ -37,6 +47,10 @@ import type { FileTreeEntry, GitFileStatusType } from '@shared/types/editor';
interface EditorFileTreeProps {
selectedFilePath: string | null;
onFileSelect: (filePath: string) => void;
/** Trigger "Create Task" with a file mention from context menu */
onCreateTask?: (filePath: string) => void;
/** Trigger "Write Teammate" with a file mention from context menu */
onSendMessage?: (filePath: string) => void;
}
interface NewItemState {
@ -64,30 +78,58 @@ const AUTO_EXPAND_DELAY_MS = 500;
// Component
// =============================================================================
// Render counter for debugging — tracks how often the tree re-renders
let fileTreeRenderCount = 0;
export const EditorFileTree = ({
selectedFilePath,
onFileSelect,
onCreateTask,
onSendMessage,
}: EditorFileTreeProps): React.ReactElement => {
const fileTree = useStore((s) => s.editorFileTree);
const expandedDirs = useStore((s) => s.editorExpandedDirs);
fileTreeRenderCount++;
if (fileTreeRenderCount % 5 === 0) {
console.debug(`[perf] EditorFileTree render #${fileTreeRenderCount}`);
}
// Data selectors — grouped with useShallow to prevent unnecessary re-renders
const { fileTree, expandedDirs, loading, error, gitFiles, projectPath } = useStore(
useShallow((s) => ({
fileTree: s.editorFileTree,
expandedDirs: s.editorExpandedDirs,
loading: s.editorFileTreeLoading,
error: s.editorFileTreeError,
gitFiles: s.editorGitFiles,
projectPath: s.editorProjectPath,
}))
);
// Actions — stable references in Zustand, no grouping needed
const expandDirectory = useStore((s) => s.expandDirectory);
const collapseDirectory = useStore((s) => s.collapseDirectory);
const loading = useStore((s) => s.editorFileTreeLoading);
const error = useStore((s) => s.editorFileTreeError);
const createFileInTree = useStore((s) => s.createFileInTree);
const createDirInTree = useStore((s) => s.createDirInTree);
const deleteFileFromTree = useStore((s) => s.deleteFileFromTree);
const moveFileInTree = useStore((s) => s.moveFileInTree);
const renameFileInTree = useStore((s) => s.renameFileInTree);
const openFile = useStore((s) => s.openFile);
const gitFiles = useStore((s) => s.editorGitFiles);
const projectPath = useStore((s) => s.editorProjectPath);
const [newItemState, setNewItemState] = useState<NewItemState | null>(null);
const [renamingPath, setRenamingPath] = useState<string | null>(null);
const [deleteConfirmPath, setDeleteConfirmPath] = useState<string | null>(null);
const [draggedItem, setDraggedItem] = useState<FlatTreeItem | null>(null);
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null);
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Defer DnD initialization — mount tree without drag/drop first, enable after idle
const [dndReady, setDndReady] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => {
requestAnimationFrame(() => setDndReady(true));
});
return () => cancelAnimationFrame(id);
}, []);
// Cleanup auto-expand timer on unmount
useEffect(() => {
return () => {
@ -101,14 +143,21 @@ export const EditorFileTree = ({
// Convert hierarchical FileTreeEntry[] → TreeNode[] (respects entry.type)
const treeNodes = useMemo(() => {
if (!fileTree) return [];
return sortTreeNodes(convertEntriesToNodes(fileTree));
const t0 = performance.now();
const nodes = sortTreeNodes(convertEntriesToNodes(fileTree));
const ms = performance.now() - t0;
if (ms > 2) console.debug(`[perf] treeNodes: ${ms.toFixed(1)}ms, nodes=${nodes.length}`);
return nodes;
}, [fileTree]);
// Flatten tree into visible items list for virtualization
// expandedDirs is keyed by absolute path, and node.fullPath = entry.path (absolute)
const flatItems = useMemo(() => {
const t0 = performance.now();
const items: FlatTreeItem[] = [];
flattenVisible(treeNodes, expandedDirs, items, 0);
const ms = performance.now() - t0;
if (ms > 2) console.debug(`[perf] flatItems: ${ms.toFixed(1)}ms, items=${items.length}`);
return items;
}, [treeNodes, expandedDirs]);
@ -121,16 +170,42 @@ export const EditorFileTree = ({
return map;
}, [flatItems]);
// Virtual scrolling — increase overscan during drag for more drop targets
// Compute insertion index for inline new-item input
const newItemInsert = useMemo(() => {
if (!newItemState) return null;
const { parentDir } = newItemState;
const parentIdx = flatItems.findIndex((fi) => fi.node.fullPath === parentDir);
if (parentIdx === -1) {
// parentDir is the project root (not a node in flatItems) — insert at top
return { index: 0, depth: 0 };
}
// Insert right after the parent directory node (top of its children)
return { index: parentIdx + 1, depth: flatItems[parentIdx].depth + 1 };
}, [newItemState, flatItems]);
// Virtual scrolling — reduced overscan during initial mount, increase during drag
const virtualizer = useVirtualizer({
count: flatItems.length,
count: flatItems.length + (newItemInsert ? 1 : 0),
getScrollElement: () => scrollRef.current,
estimateSize: () => ITEM_HEIGHT,
overscan: draggedItem ? 20 : 10,
overscan: !dndReady ? 3 : draggedItem ? 20 : 10,
});
// Scroll to file when selectedFilePath changes (e.g. from revealFileInEditor)
useEffect(() => {
if (!selectedFilePath) return;
const idx = flatItems.findIndex((fi) => fi.node.fullPath === selectedFilePath);
if (idx >= 0) {
virtualizer.scrollToIndex(idx, { align: 'center' });
}
}, [selectedFilePath, flatItems, virtualizer]);
// Git status lookup: absolute path → status type
const gitStatusMap = useMemo(() => {
const t0 = performance.now();
const map = new Map<string, GitFileStatusType>();
if (!gitFiles.length || !projectPath) return map;
for (const file of gitFiles) {
@ -139,6 +214,8 @@ export const EditorFileTree = ({
: `${projectPath}/${file.path}`;
map.set(absPath, file.status);
}
const ms = performance.now() - t0;
if (ms > 2) console.debug(`[perf] gitStatusMap: ${ms.toFixed(1)}ms, files=${gitFiles.length}`);
return map;
}, [gitFiles, projectPath]);
@ -163,25 +240,58 @@ export const EditorFileTree = ({
[onFileSelect, expandedDirs, expandDirectory, collapseDirectory]
);
// Context menu handlers
const handleNewFile = useCallback((parentDir: string) => {
setNewItemState({ parentDir, type: 'file' });
}, []);
const handleNewFolder = useCallback((parentDir: string) => {
setNewItemState({ parentDir, type: 'directory' });
}, []);
const handleDelete = useCallback(
async (path: string) => {
const fileName = path.split('/').pop() ?? path;
const confirmed = window.confirm(`Move "${fileName}" to Trash?`);
if (!confirmed) return;
await deleteFileFromTree(path);
// Context menu handlers — expand parent directory so the input appears inline
const handleNewFile = useCallback(
(parentDir: string) => {
if (parentDir !== projectPath && !expandedDirs[parentDir]) {
void expandDirectory(parentDir);
}
setNewItemState({ parentDir, type: 'file' });
},
[deleteFileFromTree]
[projectPath, expandedDirs, expandDirectory]
);
const handleNewFolder = useCallback(
(parentDir: string) => {
if (parentDir !== projectPath && !expandedDirs[parentDir]) {
void expandDirectory(parentDir);
}
setNewItemState({ parentDir, type: 'directory' });
},
[projectPath, expandedDirs, expandDirectory]
);
const handleDelete = useCallback((path: string) => {
setDeleteConfirmPath(path);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!deleteConfirmPath) return;
await deleteFileFromTree(deleteConfirmPath);
setDeleteConfirmPath(null);
}, [deleteConfirmPath, deleteFileFromTree]);
const handleCancelDelete = useCallback(() => {
setDeleteConfirmPath(null);
}, []);
const handleRename = useCallback((path: string) => {
setRenamingPath(path);
}, []);
const handleRenameSubmit = useCallback(
async (newName: string) => {
if (!renamingPath) return;
await renameFileInTree(renamingPath, newName);
setRenamingPath(null);
},
[renamingPath, renameFileInTree]
);
const handleRenameCancel = useCallback(() => {
setRenamingPath(null);
}, []);
const handleNewItemSubmit = useCallback(
async (name: string) => {
if (!newItemState) return;
@ -332,9 +442,13 @@ export const EditorFileTree = ({
return (
<EditorContextMenu
projectPath={projectPath}
onNewFile={handleNewFile}
onNewFolder={handleNewFolder}
onDelete={handleDelete}
onRename={handleRename}
onCreateTask={onCreateTask}
onSendMessage={onSendMessage}
>
<DndContext
sensors={sensors}
@ -344,7 +458,11 @@ export const EditorFileTree = ({
onDragCancel={handleDragCancel}
autoScroll={{ threshold: { x: 0, y: 0.15 } }}
>
<RootDropZone ref={scrollRef} projectPath={projectPath}>
<RootDropZone
ref={scrollRef}
projectPath={projectPath}
isDropTarget={dropTargetPath === projectPath}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
@ -353,41 +471,91 @@ export const EditorFileTree = ({
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = flatItems[virtualItem.index];
const { index } = virtualItem;
// Render inline new-item input at the correct tree position
if (index === newItemInsert?.index) {
return (
<div
key="__new-item-input__"
style={{
position: 'absolute',
top: `${virtualItem.start}px`,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
paddingLeft: `${Math.min(newItemInsert.depth, MAX_DEPTH) * INDENT_PX}px`,
}}
>
<NewFileDialog
type={newItemState!.type}
parentDir={newItemState!.parentDir}
onSubmit={handleNewItemSubmit}
onCancel={handleNewItemCancel}
/>
</div>
);
}
// Adjust index for items after the insertion point
const flatIdx = newItemInsert && index > newItemInsert.index ? index - 1 : index;
const item = flatItems[flatIdx];
return (
<DraggableTreeItem
<div
key={item.node.fullPath}
item={item}
activeNodePath={activeNodePath}
gitStatusMap={gitStatusMap}
dropTargetPath={dropTargetPath}
isDragActive={!!draggedItem}
onClick={handleNodeClick}
style={{
position: 'absolute',
top: 0,
top: `${virtualItem.start}px`,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
/>
>
<DraggableTreeItem
item={item}
activeNodePath={activeNodePath}
gitStatus={gitStatusMap.get(item.node.fullPath)}
dropTargetPath={dropTargetPath}
isDragActive={!!draggedItem}
onClick={handleNodeClick}
isRenaming={renamingPath === item.node.fullPath}
onRenameSubmit={handleRenameSubmit}
onRenameCancel={handleRenameCancel}
/>
</div>
);
})}
</div>
{/* Spacer at bottom — drop here to move to project root */}
{draggedItem && (
<div className="h-16 w-full shrink-0" aria-label="Drop here for project root" />
)}
</RootDropZone>
<DragOverlay dropAnimation={null}>
{draggedItem && <DragOverlayFileItem item={draggedItem} />}
</DragOverlay>
</DndContext>
{newItemState && (
<NewFileDialog
type={newItemState.type}
parentDir={newItemState.parentDir}
onSubmit={handleNewItemSubmit}
onCancel={handleNewItemCancel}
/>
)}
{/* Delete confirmation dialog */}
<Dialog open={!!deleteConfirmPath} onOpenChange={(open) => !open && handleCancelDelete()}>
<DialogContent className="w-96 max-w-96">
<DialogHeader>
<DialogTitle className="text-sm">Move to Trash</DialogTitle>
<DialogDescription>
Move &ldquo;{deleteConfirmPath?.split('/').pop() ?? ''}&rdquo; to Trash?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={handleCancelDelete}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={() => void handleConfirmDelete()}>
Move to Trash
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</EditorContextMenu>
);
};
@ -398,8 +566,8 @@ export const EditorFileTree = ({
const RootDropZone = React.forwardRef<
HTMLDivElement,
{ projectPath: string | null; children: React.ReactNode }
>(({ projectPath, children }, ref) => {
{ projectPath: string | null; isDropTarget: boolean; children: React.ReactNode }
>(({ projectPath, isDropTarget, children }, ref) => {
const { setNodeRef } = useDroppable({
id: 'root-drop-zone',
data: { isRoot: true, path: projectPath },
@ -417,7 +585,13 @@ const RootDropZone = React.forwardRef<
);
return (
<div ref={combinedRef} className="h-full overflow-y-auto" role="tree">
<div
ref={combinedRef}
className={`scrollbar-thin h-full overflow-y-auto transition-colors ${
isDropTarget ? 'bg-blue-400/5 ring-1 ring-inset ring-blue-400/30' : ''
}`}
role="tree"
>
{children}
</div>
);
@ -432,11 +606,13 @@ RootDropZone.displayName = 'RootDropZone';
interface DraggableTreeItemProps {
item: FlatTreeItem;
activeNodePath: string | null;
gitStatusMap: Map<string, GitFileStatusType>;
gitStatus?: GitFileStatusType;
dropTargetPath: string | null;
isDragActive: boolean;
onClick: (node: TreeNode<FileTreeEntry>) => void;
style: React.CSSProperties;
isRenaming?: boolean;
onRenameSubmit?: (newName: string) => void;
onRenameCancel?: () => void;
}
/* eslint-disable react/jsx-props-no-spreading -- dnd-kit requires prop spreading for drag attributes, listeners, and data attributes */
@ -444,11 +620,13 @@ const DraggableTreeItem = React.memo(
({
item,
activeNodePath,
gitStatusMap,
gitStatus,
dropTargetPath,
isDragActive,
onClick,
style,
isRenaming,
onRenameSubmit,
onRenameCancel,
}: DraggableTreeItemProps): React.ReactElement => {
const { node, depth, isExpanded } = item;
const isSelected = activeNodePath === node.fullPath;
@ -483,8 +661,10 @@ const DraggableTreeItem = React.memo(
[setDragRef, setDropRef, node.isFile]
);
// Visual: highlight drop target directory
// Visual: highlight drop target directory and its visible children
const isDropTarget = !node.isFile && dropTargetPath === node.fullPath;
const isInsideDropTarget =
dropTargetPath != null && node.fullPath.startsWith(dropTargetPath + '/');
const dataAttrs: Record<string, string> = {};
if (node.data) {
@ -508,9 +688,7 @@ const DraggableTreeItem = React.memo(
if (node.data?.isSensitive) {
icon = <Lock className="size-3.5 shrink-0 text-yellow-500" />;
} else if (node.isFile) {
const fileIcon = getFileIcon(node.name);
const FileIcon = fileIcon.icon;
icon = <FileIcon className="size-3.5 shrink-0" style={{ color: fileIcon.color }} />;
icon = <FileIcon fileName={node.name} className="size-3.5" />;
} else if (isExpanded) {
icon = <FolderOpen className="size-3.5 shrink-0 text-text-muted" />;
} else {
@ -525,17 +703,12 @@ const DraggableTreeItem = React.memo(
role="treeitem"
aria-selected={node.isFile ? isSelected : undefined}
aria-expanded={!node.isFile ? isExpanded : undefined}
className={`flex cursor-pointer select-none items-center gap-1 truncate px-2 text-xs transition-colors hover:bg-surface-raised ${
className={`flex h-full cursor-pointer select-none items-center gap-1 truncate px-2 text-xs transition-colors hover:bg-surface-raised ${
isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary'
} ${isDragging ? 'opacity-30' : ''} ${
isDropTarget ? 'rounded bg-blue-400/10 ring-2 ring-blue-400/50' : ''
}`}
style={{
...style,
paddingLeft: `${visualDepth * INDENT_PX + 8}px`,
display: 'flex',
alignItems: 'center',
}}
} ${isInsideDropTarget && !isDropTarget ? 'border-l-2 border-l-blue-400/40 bg-blue-400/5' : ''}`}
style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={0}
@ -549,10 +722,16 @@ const DraggableTreeItem = React.memo(
<ChevronRight className="size-3 shrink-0 text-text-muted" />
))}
{icon}
<span className="truncate">{node.name}</span>
{node.data && gitStatusMap.has(node.data.path) && (
<GitStatusBadge status={gitStatusMap.get(node.data.path)!} />
{isRenaming ? (
<InlineRenameInput
initialName={node.name}
onSubmit={onRenameSubmit!}
onCancel={onRenameCancel!}
/>
) : (
<span className="truncate">{node.name}</span>
)}
{!isRenaming && gitStatus && <GitStatusBadge status={gitStatus} />}
</div>
);
}
@ -570,9 +749,7 @@ const DragOverlayFileItem = ({ item }: { item: FlatTreeItem }): React.ReactEleme
let icon: React.ReactNode;
if (node.isFile) {
const fileIcon = getFileIcon(node.name);
const FileIcon = fileIcon.icon;
icon = <FileIcon className="size-3.5" style={{ color: fileIcon.color }} />;
icon = <FileIcon fileName={node.name} className="size-3.5" />;
} else {
icon = <FolderOpen className="size-3.5 text-text-muted" />;
}
@ -585,6 +762,89 @@ const DragOverlayFileItem = ({ item }: { item: FlatTreeItem }): React.ReactEleme
);
};
// =============================================================================
// Inline rename input
// =============================================================================
const InlineRenameInput = ({
initialName,
onSubmit,
onCancel,
}: {
initialName: string;
onSubmit: (newName: string) => void;
onCancel: () => void;
}): React.ReactElement => {
const [value, setValue] = useState(initialName);
const submitted = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
// Focus + select on mount (delayed to survive Radix/DnD focus interference)
useEffect(() => {
const timer = setTimeout(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
const dotIdx = initialName.lastIndexOf('.');
if (dotIdx > 0) {
input.setSelectionRange(0, dotIdx);
} else {
input.select();
}
}, 100);
return () => clearTimeout(timer);
}, [initialName]);
// Click-outside → submit (replaces unreliable onBlur)
useEffect(() => {
const handlePointerDown = (e: PointerEvent): void => {
if (inputRef.current && !inputRef.current.contains(e.target as Node)) {
doSubmit();
}
};
const timer = setTimeout(() => {
document.addEventListener('pointerdown', handlePointerDown, true);
}, 150);
return () => {
clearTimeout(timer);
document.removeEventListener('pointerdown', handlePointerDown, true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- doSubmit reads value via ref pattern
}, []);
const doSubmit = (): void => {
if (submitted.current) return;
submitted.current = true;
const trimmed = inputRef.current?.value.trim() ?? '';
if (trimmed && trimmed !== initialName) {
onSubmit(trimmed);
} else {
onCancel();
}
};
return (
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
doSubmit();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
e.stopPropagation();
}}
onBlur={() => requestAnimationFrame(() => inputRef.current?.focus())}
onClick={(e) => e.stopPropagation()}
className="min-w-0 flex-1 rounded border border-blue-400/50 bg-surface px-1 py-0 text-xs text-text outline-none focus:ring-1 focus:ring-blue-400/50"
/>
);
};
// =============================================================================
// Helpers
// =============================================================================

View file

@ -0,0 +1,137 @@
/**
* Inline image preview for the project editor.
*
* Loads binary file as base64 data URL via IPC, displays centered image
* with checkerboard background for transparency, metadata, and lightbox on click.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { Loader2 } from 'lucide-react';
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
interface EditorImagePreviewProps {
filePath: string;
fileName: string;
size: number;
}
export const EditorImagePreview = ({
filePath,
fileName,
size,
}: EditorImagePreviewProps): React.ReactElement => {
const projectPath = useStore((s) => s.editorProjectPath);
const [dataUrl, setDataUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
// Reset state when filePath changes (setState-during-render, React-approved pattern)
const [prevFilePath, setPrevFilePath] = useState(filePath);
if (prevFilePath !== filePath) {
setPrevFilePath(filePath);
setLoading(true);
setError(null);
setDataUrl(null);
setDimensions(null);
}
useEffect(() => {
let cancelled = false;
window.electronAPI.editor
.readBinaryPreview(filePath)
.then((result) => {
if (cancelled) return;
setDataUrl(`data:${result.mimeType};base64,${result.base64}`);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [filePath]);
const handleImageLoad = useCallback(() => {
const img = imgRef.current;
if (img) {
setDimensions({ w: img.naturalWidth, h: img.naturalHeight });
}
}, []);
const handleOpenExternal = useCallback((): void => {
window.electronAPI.openPath(filePath, projectPath ?? undefined).catch(console.error);
}, [filePath, projectPath]);
const sizeFormatted =
size < 1024
? `${size} B`
: size < 1024 * 1024
? `${(size / 1024).toFixed(1)} KB`
: `${(size / 1024 / 1024).toFixed(1)} MB`;
if (loading) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Loader2 className="size-8 animate-spin opacity-40" />
<p className="text-xs">Loading preview</p>
</div>
);
}
if (error || !dataUrl) {
return <EditorBinaryPlaceholder filePath={filePath} fileName={fileName} size={size} />;
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
<button
type="button"
className="checkerboard-bg flex max-h-[60vh] max-w-[80%] cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border border-border-subtle p-1"
onClick={() => setLightboxOpen(true)}
aria-label="Open full-size preview"
>
<img
ref={imgRef}
src={dataUrl}
alt={fileName}
className="max-h-[60vh] object-contain"
onLoad={handleImageLoad}
draggable={false}
/>
</button>
<p className="text-xs text-text-muted">
{fileName}
{dimensions ? `${dimensions.w}×${dimensions.h}` : ''}
{`${sizeFormatted}`}
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleOpenExternal}>
Open in System Viewer
</Button>
</div>
<ImageLightbox
src={dataUrl}
alt={fileName}
open={lightboxOpen}
onClose={() => setLightboxOpen(false)}
/>
</div>
);
};

View file

@ -0,0 +1,508 @@
/**
* Custom CodeMirror search/replace panel using the project UI Kit.
*
* Replaces the default CodeMirror search panel with a styled version
* that uses our Input, Button, and Tooltip components for consistent
* design language across the app.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
closeSearchPanel,
findNext,
findPrevious,
getSearchQuery,
replaceAll,
replaceNext,
SearchQuery,
setSearchQuery,
} from '@codemirror/search';
import { EditorView, type Panel } from '@codemirror/view';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import {
ArrowDown,
ArrowUp,
CaseSensitive,
ChevronDown,
ChevronRight,
Regex,
WholeWord,
X,
} from 'lucide-react';
import type { EditorState } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
// =============================================================================
// Constants
// =============================================================================
const MAX_MATCH_COUNT = 999;
// =============================================================================
// SearchToggleButton
// =============================================================================
interface SearchToggleButtonProps {
active: boolean;
onClick: () => void;
tooltip: string;
shortcut?: string;
children: React.ReactNode;
}
const SearchToggleButton = React.memo(function SearchToggleButton({
active,
onClick,
tooltip,
shortcut,
children,
}: SearchToggleButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex size-[22px] items-center justify-center rounded transition-colors',
active
? 'bg-blue-500/20 text-blue-400'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]'
)}
onClick={onClick}
tabIndex={-1}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{tooltip}</span>
{shortcut && <span className="ml-1.5 text-[var(--color-text-muted)]">{shortcut}</span>}
</TooltipContent>
</Tooltip>
);
});
// =============================================================================
// Match counter
// =============================================================================
function countMatches(query: SearchQuery, state: EditorState): number {
if (!query.valid || !query.search) return 0;
try {
const cursor = query.getCursor(state);
let count = 0;
while (!cursor.next().done) {
count++;
if (count > MAX_MATCH_COUNT) return -1;
}
return count;
} catch {
return 0;
}
}
// =============================================================================
// EditorSearchPanelContent
// =============================================================================
interface EditorSearchPanelContentProps {
view: EditorView;
initialSearch: string;
initialReplace: string;
initialCaseSensitive: boolean;
initialRegexp: boolean;
initialWholeWord: boolean;
registerUpdateNotifier: (cb: () => void) => void;
}
const EditorSearchPanelContent = ({
view,
initialSearch,
initialReplace,
initialCaseSensitive,
initialRegexp,
initialWholeWord,
registerUpdateNotifier,
}: EditorSearchPanelContentProps) => {
const [searchText, setSearchText] = useState(initialSearch);
const [replaceText, setReplaceText] = useState(initialReplace);
const [caseSensitive, setCaseSensitive] = useState(initialCaseSensitive);
const [useRegexp, setUseRegexp] = useState(initialRegexp);
const [wholeWord, setWholeWord] = useState(initialWholeWord);
const [showReplace, setShowReplace] = useState(false);
const [updateTick, setUpdateTick] = useState(0);
const searchInputRef = useRef<HTMLInputElement>(null);
// Focus search input on mount
useEffect(() => {
requestAnimationFrame(() => {
searchInputRef.current?.focus();
searchInputRef.current?.select();
});
}, []);
// Build query object (memoized)
const query = useMemo(
() =>
new SearchQuery({
search: searchText,
replace: replaceText,
caseSensitive,
regexp: useRegexp,
wholeWord,
}),
[searchText, replaceText, caseSensitive, useRegexp, wholeWord]
);
// Dispatch search query to CodeMirror for highlighting
useEffect(() => {
view.dispatch({ effects: setSearchQuery.of(query) });
}, [query, view]);
// Register for editor updates (doc changes → recount via updateTick)
useEffect(() => {
registerUpdateNotifier(() => setUpdateTick((t) => t + 1));
}, [registerUpdateNotifier]);
// Match count — derived from query + document state
// updateTick triggers recount on document changes (e.g. after replace)
const matchCount = useMemo(
() => countMatches(query, view.state),
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateTick is a proxy dep for view.state changes
[query, view, updateTick]
);
// Navigation
const handleFindNext = useCallback(() => {
findNext(view);
}, [view]);
const handleFindPrev = useCallback(() => {
findPrevious(view);
}, [view]);
// Replace
const handleReplaceNext = useCallback(() => {
replaceNext(view);
}, [view]);
const handleReplaceAll = useCallback(() => {
replaceAll(view);
}, [view]);
// Close
const handleClose = useCallback(() => {
closeSearchPanel(view);
view.focus();
}, [view]);
// Keyboard handlers
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
handleClose();
} else if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) {
findPrevious(view);
} else {
findNext(view);
}
}
},
[view, handleClose]
);
const handleReplaceKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
handleClose();
} else if (e.key === 'Enter') {
e.preventDefault();
handleReplaceNext();
}
},
[handleClose, handleReplaceNext]
);
// Match count display
const matchCountText = searchText
? matchCount === -1
? `${MAX_MATCH_COUNT}+`
: matchCount === 0
? 'No results'
: `${matchCount} found`
: '';
return (
<TooltipProvider delayDuration={400}>
<div className="flex flex-col gap-1 px-2 py-1.5">
{/* Search row */}
<div className="flex items-center gap-1">
{/* Toggle replace visibility */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-[22px] w-5 items-center justify-center rounded text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setShowReplace((prev) => !prev)}
tabIndex={-1}
>
{showReplace ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Toggle Replace</TooltipContent>
</Tooltip>
{/* Search input */}
<Input
ref={searchInputRef}
className="h-[26px] min-w-[180px] flex-1 rounded border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-xs"
placeholder="Search"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleSearchKeyDown}
spellCheck={false}
/>
{/* Toggle buttons */}
<SearchToggleButton
active={caseSensitive}
onClick={() => setCaseSensitive((prev) => !prev)}
tooltip="Match Case"
>
<CaseSensitive className="size-[14px]" />
</SearchToggleButton>
<SearchToggleButton
active={wholeWord}
onClick={() => setWholeWord((prev) => !prev)}
tooltip="Match Whole Word"
>
<WholeWord className="size-[14px]" />
</SearchToggleButton>
<SearchToggleButton
active={useRegexp}
onClick={() => setUseRegexp((prev) => !prev)}
tooltip="Use Regular Expression"
>
<Regex className="size-[14px]" />
</SearchToggleButton>
{/* Separator */}
<div className="mx-0.5 h-4 w-px bg-[var(--color-border)]" />
{/* Match count */}
{matchCountText && (
<span
className={cn(
'min-w-[60px] whitespace-nowrap text-center text-xs tabular-nums',
matchCount === 0 && searchText ? 'text-red-400' : 'text-[var(--color-text-muted)]'
)}
>
{matchCountText}
</span>
)}
{/* Navigation */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-[22px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={handleFindPrev}
disabled={matchCount === 0}
tabIndex={-1}
>
<ArrowUp className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
Previous Match <span className="text-[var(--color-text-muted)]">Enter</span>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-[22px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={handleFindNext}
disabled={matchCount === 0}
tabIndex={-1}
>
<ArrowDown className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
Next Match <span className="text-[var(--color-text-muted)]">Enter</span>
</TooltipContent>
</Tooltip>
{/* Close */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-[22px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={handleClose}
tabIndex={-1}
>
<X className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
Close <span className="text-[var(--color-text-muted)]">Esc</span>
</TooltipContent>
</Tooltip>
</div>
{/* Replace row */}
{showReplace && (
<div className="flex items-center gap-1">
{/* Spacer to align with search input */}
<div className="w-5 shrink-0" />
<Input
className="h-[26px] min-w-[180px] flex-1 rounded border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-xs"
placeholder="Replace"
value={replaceText}
onChange={(e) => setReplaceText(e.target.value)}
onKeyDown={handleReplaceKeyDown}
spellCheck={false}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-[22px] px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={handleReplaceNext}
disabled={matchCount === 0}
tabIndex={-1}
>
Replace
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Replace Next</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-[22px] px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={handleReplaceAll}
disabled={matchCount === 0}
tabIndex={-1}
>
All
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Replace All</TooltipContent>
</Tooltip>
</div>
)}
</div>
</TooltipProvider>
);
};
// =============================================================================
// Panel factory for CodeMirror
// =============================================================================
export function createSearchPanel(view: EditorView): Panel {
const dom = document.createElement('div');
const root = createRoot(dom);
// Get initial values
const existingQuery = getSearchQuery(view.state);
const sel = view.state.selection.main;
const selText = sel.empty ? '' : view.state.sliceDoc(sel.from, sel.to);
const initialSearch = selText && !selText.includes('\n') ? selText : existingQuery.search;
// Mutable ref for update notifications from CodeMirror
let notifyUpdate: (() => void) | null = null;
root.render(
<EditorSearchPanelContent
view={view}
initialSearch={initialSearch}
initialReplace={existingQuery.replace}
initialCaseSensitive={existingQuery.caseSensitive}
initialRegexp={existingQuery.regexp}
initialWholeWord={existingQuery.wholeWord}
registerUpdateNotifier={(cb) => {
notifyUpdate = cb;
}}
/>
);
return {
dom,
top: true,
update(update: ViewUpdate) {
if (update.docChanged) {
notifyUpdate?.();
}
},
destroy() {
notifyUpdate = null;
root.unmount();
},
};
}
// =============================================================================
// Theme: panel container + search match highlighting
// =============================================================================
export const editorSearchPanelTheme = EditorView.theme({
'.cm-panels': {
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
'.cm-panels-top': {
borderBottom: '1px solid var(--color-border)',
},
'.cm-panels-bottom': {
borderTop: '1px solid var(--color-border)',
},
// Search match highlighting in editor content
'.cm-searchMatch': {
backgroundColor: 'var(--highlight-bg-inactive)',
borderRadius: '2px',
},
'.cm-searchMatch-selected': {
backgroundColor: 'var(--highlight-bg) !important',
borderRadius: '2px',
},
});

View file

@ -5,6 +5,7 @@
* Uses onMouseDown preventDefault to avoid deselecting text in CM6.
*/
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { ListTodo, MessageSquare } from 'lucide-react';
@ -92,16 +93,17 @@ interface MenuButtonProps {
const MenuButton = ({ icon, label, onClick }: MenuButtonProps): React.ReactElement => (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
<Button
variant="ghost"
size="icon"
tabIndex={-1}
aria-label={label}
onClick={onClick}
onMouseDown={(e) => e.preventDefault()} // prevent CM6 selection loss
className="rounded p-1.5 text-text-secondary transition-colors hover:bg-surface-raised hover:text-text"
className="size-7 p-1.5 text-text-secondary"
>
{icon}
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{label}

View file

@ -5,10 +5,10 @@
* the appropriate modifier symbols.
*/
import { useEffect, useMemo } from 'react';
import { useMemo } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog';
import { IS_MAC } from '@renderer/utils/platformKeys';
import { X } from 'lucide-react';
// =============================================================================
// Types
@ -64,6 +64,13 @@ const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
{ mac: '⌘ /', other: 'Ctrl+/', description: 'Toggle Comment' },
],
},
{
title: 'Markdown',
shortcuts: [
{ mac: '⌘ ⇧ M', other: 'Ctrl+Shift+M', description: 'Split Preview' },
{ mac: '⌘ ⇧ V', other: 'Ctrl+Shift+V', description: 'Full Preview' },
],
},
{
title: 'General',
shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }],
@ -75,19 +82,6 @@ const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
// =============================================================================
export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): React.ReactElement => {
// Escape closes help (capture phase)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [onClose]);
// Resolve platform-specific keys once
const resolvedGroups = useMemo(
() =>
@ -102,29 +96,11 @@ export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): Reac
);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center" role="presentation">
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
{/* Dialog */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-dialog-title"
className="relative z-10 w-[480px] rounded-lg border border-border-emphasis bg-surface p-6 shadow-2xl"
>
<div className="mb-4 flex items-center justify-between">
<h2 id="shortcuts-dialog-title" className="text-sm font-semibold text-text">
Keyboard Shortcuts
</h2>
<button
onClick={onClose}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="w-[480px] max-w-[480px]">
<DialogHeader>
<DialogTitle className="text-sm">Keyboard Shortcuts</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
{resolvedGroups.map((group) => (
@ -143,7 +119,7 @@ export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): Reac
</div>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -2,8 +2,12 @@
* Status bar: cursor position, language, encoding, indent style, git branch.
*/
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { GitBranch } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
interface EditorStatusBarProps {
line: number;
@ -11,14 +15,19 @@ interface EditorStatusBarProps {
language: string;
}
export const EditorStatusBar = ({
export const EditorStatusBar = React.memo(function EditorStatusBar({
line,
col,
language,
}: EditorStatusBarProps): React.ReactElement => {
const gitBranch = useStore((s) => s.editorGitBranch);
const isGitRepo = useStore((s) => s.editorIsGitRepo);
const watcherEnabled = useStore((s) => s.editorWatcherEnabled);
}: EditorStatusBarProps): React.ReactElement {
const { gitBranch, isGitRepo, watcherEnabled } = useStore(
useShallow((s) => ({
gitBranch: s.editorGitBranch,
isGitRepo: s.editorIsGitRepo,
watcherEnabled: s.editorWatcherEnabled,
}))
);
const toggleWatcher = useStore((s) => s.toggleWatcher);
return (
<div className="flex h-6 shrink-0 items-center justify-between border-t border-border bg-surface-sidebar px-3 text-[11px] text-text-muted">
@ -34,15 +43,30 @@ export const EditorStatusBar = ({
)}
</div>
<div className="flex items-center gap-4">
{watcherEnabled && (
<span className="text-green-400" title="File watcher active">
watching
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void toggleWatcher(!watcherEnabled)}
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
watcherEnabled
? 'bg-green-500/15 text-green-400 hover:bg-green-500/20'
: 'text-text-muted hover:bg-surface-raised hover:text-text-secondary'
}`}
aria-label={watcherEnabled ? 'Disable file watcher' : 'Enable file watcher'}
aria-pressed={watcherEnabled}
>
{watcherEnabled ? 'watching' : 'watch'}
</button>
</TooltipTrigger>
<TooltipContent side="top">
{watcherEnabled ? 'Disable external change watcher' : 'Watch for external changes'}
</TooltipContent>
</Tooltip>
<span>{language}</span>
<span>UTF-8</span>
<span>Spaces: 2</span>
</div>
</div>
);
};
});

View file

@ -1,14 +1,31 @@
/**
* Tab bar for the project editor.
* Shows open files as tabs with dirty indicator (dot) and close button.
* Shows open files as tabs with dirty indicator (dot), close button,
* right-click context menu (close others, close to left/right, close all),
* and drag-and-drop reordering via @dnd-kit.
*/
import { useCallback, useMemo, useState } from 'react';
import {
closestCenter,
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { getFileIcon } from './fileIcons';
import { EditorTabContextMenu } from './EditorTabContextMenu';
import { FileIcon } from './FileIcon';
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import type { EditorFileTab } from '@shared/types/editor';
// =============================================================================
@ -27,99 +44,222 @@ interface EditorTabBarProps {
export const EditorTabBar = ({
onRequestCloseTab,
}: EditorTabBarProps): React.ReactElement | null => {
const tabs = useStore((s) => s.editorOpenTabs);
const activeTabId = useStore((s) => s.editorActiveTabId);
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
const { tabs, activeTabId, modifiedFiles } = useStore(
useShallow((s) => ({
tabs: s.editorOpenTabs,
activeTabId: s.editorActiveTabId,
modifiedFiles: s.editorModifiedFiles,
}))
);
const setActiveEditorTab = useStore((s) => s.setActiveEditorTab);
const reorderEditorTabs = useStore((s) => s.reorderEditorTabs);
const closeOtherEditorTabs = useStore((s) => s.closeOtherEditorTabs);
const closeEditorTabsToLeft = useStore((s) => s.closeEditorTabsToLeft);
const closeEditorTabsToRight = useStore((s) => s.closeEditorTabsToRight);
const closeAllEditorTabs = useStore((s) => s.closeAllEditorTabs);
const [draggedTab, setDraggedTab] = useState<EditorFileTab | null>(null);
const tabIds = useMemo(() => tabs.map((t) => t.id), [tabs]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
})
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const tab = tabs.find((t) => t.id === event.active.id);
setDraggedTab(tab ?? null);
},
[tabs]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setDraggedTab(null);
const { active, over } = event;
if (over && active.id !== over.id) {
reorderEditorTabs(String(active.id), String(over.id));
}
},
[reorderEditorTabs]
);
const handleDragCancel = useCallback(() => {
setDraggedTab(null);
}, []);
if (tabs.length === 0) return null;
return (
<div
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
role="tablist"
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{tabs.map((tab) => (
<Tab
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
isModified={!!modifiedFiles[tab.filePath]}
onActivate={() => setActiveEditorTab(tab.id)}
onClose={() => onRequestCloseTab(tab.id)}
/>
))}
</div>
<div
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
role="tablist"
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{tabs.map((tab, index) => (
<SortableEditorTab
key={tab.id}
tab={tab}
tabIndex={index}
totalTabs={tabs.length}
isActive={tab.id === activeTabId}
isModified={!!modifiedFiles[tab.filePath]}
onActivate={() => setActiveEditorTab(tab.id)}
onRequestClose={onRequestCloseTab}
onCloseOthers={closeOtherEditorTabs}
onCloseToLeft={closeEditorTabsToLeft}
onCloseToRight={closeEditorTabsToRight}
onCloseAll={closeAllEditorTabs}
/>
))}
</SortableContext>
</div>
<DragOverlay dropAnimation={null}>
{draggedTab && <EditorTabOverlay tab={draggedTab} />}
</DragOverlay>
</DndContext>
);
};
// =============================================================================
// Tab item
// Sortable tab item
// =============================================================================
interface TabProps {
interface SortableEditorTabProps {
tab: EditorFileTab;
tabIndex: number;
totalTabs: number;
isActive: boolean;
isModified: boolean;
onActivate: () => void;
onClose: () => void;
onRequestClose: (tabId: string) => void;
onCloseOthers: (tabId: string) => void;
onCloseToLeft: (tabId: string) => void;
onCloseToRight: (tabId: string) => void;
onCloseAll: () => void;
}
const Tab = ({ tab, isActive, isModified, onActivate, onClose }: TabProps): React.ReactElement => {
const SortableEditorTab = ({
tab,
tabIndex,
totalTabs,
isActive,
isModified,
onActivate,
onRequestClose,
onCloseOthers,
onCloseToLeft,
onCloseToRight,
onCloseAll,
}: SortableEditorTabProps): React.ReactElement => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: isDragging ? 'none' : transition,
opacity: isDragging ? 0.3 : 1,
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
onClose();
onRequestClose(tab.id);
};
const handleAuxClick = (e: React.MouseEvent) => {
if (e.button === 1) {
e.preventDefault();
onClose();
onRequestClose(tab.id);
}
};
const iconInfo = getFileIcon(tab.fileName);
const FileIcon = iconInfo.icon;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onActivate}
onAuxClick={handleAuxClick}
role="tab"
aria-selected={isActive}
className={`group flex h-full shrink-0 items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
isActive
? 'bg-surface text-text'
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
}`}
>
{isModified && (
<span
className="size-1.5 shrink-0 rounded-full bg-amber-400"
aria-label="Unsaved changes"
/>
)}
<FileIcon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
<span className="max-w-40 truncate">
{tab.fileName}
{tab.disambiguatedLabel && (
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
)}
</span>
<span
onClick={handleClose}
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
role="button"
aria-label={`Close ${tab.fileName}`}
tabIndex={-1}
>
<X className="size-3" />
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
</Tooltip>
// Sortable wrapper — must be the outermost element so @dnd-kit controls its position.
// ContextMenu.Trigger inside EditorTabContextMenu adds an extra <div>,
// so the useSortable ref/transform CANNOT live on the inner <button>.
<div
ref={setNodeRef}
style={style}
className="flex h-full shrink-0"
// eslint-disable-next-line react/jsx-props-no-spreading -- @dnd-kit useSortable requires prop spreading
{...attributes}
// eslint-disable-next-line react/jsx-props-no-spreading -- @dnd-kit useSortable requires prop spreading
{...listeners}
>
<EditorTabContextMenu
tabId={tab.id}
tabIndex={tabIndex}
totalTabs={totalTabs}
onClose={onRequestClose}
onCloseOthers={onCloseOthers}
onCloseToLeft={onCloseToLeft}
onCloseToRight={onCloseToRight}
onCloseAll={onCloseAll}
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onActivate}
onAuxClick={handleAuxClick}
role="tab"
aria-selected={isActive}
className={`group flex h-full shrink-0 cursor-grab items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
isActive
? 'bg-surface text-text'
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
}`}
>
{isModified && (
<span
className="size-1.5 shrink-0 rounded-full bg-amber-400"
aria-label="Unsaved changes"
/>
)}
<FileIcon fileName={tab.fileName} className="size-3.5" />
<span className="max-w-40 truncate">
{tab.fileName}
{tab.disambiguatedLabel && (
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
)}
</span>
<span
onClick={handleClose}
onPointerDown={(e) => e.stopPropagation()}
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
role="button"
aria-label={`Close ${tab.fileName}`}
tabIndex={-1}
>
<X className="size-3" />
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
</Tooltip>
</EditorTabContextMenu>
</div>
);
};
// =============================================================================
// Drag overlay (ghost shown while dragging)
// =============================================================================
const EditorTabOverlay = ({ tab }: { tab: EditorFileTab }): React.ReactElement => (
<div className="flex items-center gap-1.5 rounded border border-border-emphasis bg-surface-raised px-3 py-1 text-xs text-text shadow-lg">
<FileIcon fileName={tab.fileName} className="size-3.5" />
<span className="max-w-40 truncate">{tab.fileName}</span>
</div>
);

View file

@ -0,0 +1,88 @@
/**
* Context menu for editor tabs.
* Supports: close, close others, close to left/right, close all.
*/
import * as ContextMenu from '@radix-ui/react-context-menu';
interface EditorTabContextMenuProps {
children: React.ReactNode;
tabId: string;
tabIndex: number;
totalTabs: number;
onClose: (tabId: string) => void;
onCloseOthers: (tabId: string) => void;
onCloseToLeft: (tabId: string) => void;
onCloseToRight: (tabId: string) => void;
onCloseAll: () => void;
}
export const EditorTabContextMenu = ({
children,
tabId,
tabIndex,
totalTabs,
onClose,
onCloseOthers,
onCloseToLeft,
onCloseToRight,
onCloseAll,
}: EditorTabContextMenuProps): React.ReactElement => {
const hasLeft = tabIndex > 0;
const hasRight = tabIndex < totalTabs - 1;
const hasOthers = totalTabs > 1;
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<div className="flex h-full">{children}</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content className="z-50 min-w-[180px] rounded-md border border-border-emphasis bg-surface-overlay p-1 shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95">
<ContextMenu.Item
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={() => onClose(tabId)}
>
Close
</ContextMenu.Item>
<ContextMenu.Item
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-40"
disabled={!hasOthers}
onSelect={() => onCloseOthers(tabId)}
>
Close Others
</ContextMenu.Item>
<ContextMenu.Separator className="my-1 h-px bg-border" />
<ContextMenu.Item
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-40"
disabled={!hasLeft}
onSelect={() => onCloseToLeft(tabId)}
>
Close Tabs to the Left
</ContextMenu.Item>
<ContextMenu.Item
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-40"
disabled={!hasRight}
onSelect={() => onCloseToRight(tabId)}
>
Close Tabs to the Right
</ContextMenu.Item>
<ContextMenu.Separator className="my-1 h-px bg-border" />
<ContextMenu.Item
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={onCloseAll}
>
Close All
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
);
};

View file

@ -2,23 +2,49 @@
* Toolbar with Save, Undo, Redo buttons.
*/
import React from 'react';
import { redo, undo } from '@codemirror/commands';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { editorBridge } from '@renderer/utils/editorBridge';
import { shortcutLabel } from '@renderer/utils/platformKeys';
import { Redo2, Save, Undo2, WrapText } from 'lucide-react';
import { Columns2, Eye, Redo2, Save, Undo2, WrapText } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
// =============================================================================
// Types
// =============================================================================
export type MdPreviewMode = 'off' | 'split' | 'preview';
// =============================================================================
// Component
// =============================================================================
export const EditorToolbar = (): React.ReactElement | null => {
const activeTabId = useStore((s) => s.editorActiveTabId);
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
const saving = useStore((s) => s.editorSaving);
interface EditorToolbarProps {
isMarkdown?: boolean;
mdPreviewMode?: MdPreviewMode;
onToggleSplit?: () => void;
onToggleFullPreview?: () => void;
}
export const EditorToolbar = ({
isMarkdown = false,
mdPreviewMode = 'off',
onToggleSplit,
onToggleFullPreview,
}: EditorToolbarProps): React.ReactElement | null => {
const { activeTabId, modifiedFiles, saving, lineWrap } = useStore(
useShallow((s) => ({
activeTabId: s.editorActiveTabId,
modifiedFiles: s.editorModifiedFiles,
saving: s.editorSaving,
lineWrap: s.editorLineWrap,
}))
);
const saveFile = useStore((s) => s.saveFile);
const lineWrap = useStore((s) => s.editorLineWrap);
const toggleLineWrap = useStore((s) => s.toggleLineWrap);
if (!activeTabId) return null;
@ -69,6 +95,25 @@ export const EditorToolbar = (): React.ReactElement | null => {
onClick={toggleLineWrap}
active={lineWrap}
/>
{isMarkdown && (
<>
<div className="mx-1 h-4 w-px bg-border" />
<ToolbarButton
icon={<Columns2 className="size-3.5" />}
label={mdPreviewMode === 'split' ? 'Close split preview' : 'Split preview'}
shortcut={shortcutLabel('⌘ ⇧ M', 'Ctrl+Shift+M')}
onClick={onToggleSplit ?? (() => {})}
active={mdPreviewMode === 'split'}
/>
<ToolbarButton
icon={<Eye className="size-3.5" />}
label={mdPreviewMode === 'preview' ? 'Close preview' : 'Full preview'}
shortcut={shortcutLabel('⌘ ⇧ V', 'Ctrl+Shift+V')}
onClick={onToggleFullPreview ?? (() => {})}
active={mdPreviewMode === 'preview'}
/>
</>
)}
</div>
);
};
@ -86,30 +131,33 @@ interface ToolbarButtonProps {
active?: boolean;
}
const ToolbarButton = ({
const ToolbarButton = React.memo(function ToolbarButton({
icon,
label,
shortcut,
onClick,
disabled = false,
active = false,
}: ToolbarButtonProps): React.ReactElement => (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onClick}
disabled={disabled}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors hover:bg-surface-raised hover:text-text disabled:opacity-40 disabled:hover:bg-transparent ${
active ? 'bg-surface-raised text-text' : 'text-text-muted'
}`}
aria-label={`${label} (${shortcut})`}
aria-pressed={active}
>
{icon}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{label} ({shortcut})
</TooltipContent>
</Tooltip>
);
}: ToolbarButtonProps): React.ReactElement {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={onClick}
disabled={disabled}
className={`h-auto gap-1 px-1.5 py-0.5 text-xs ${
active ? 'bg-surface-raised text-text' : 'text-text-muted'
}`}
aria-label={`${label} (${shortcut})`}
aria-pressed={active}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{label} ({shortcut})
</TooltipContent>
</Tooltip>
);
});

View file

@ -0,0 +1,66 @@
/**
* FileIcon renders a file-type icon.
*
* For programming languages/frameworks: uses Devicon CDN SVG (real colorful logos).
* For generic types (images, fonts, configs): uses lucide-react icons with tinted color.
* Falls back to lucide if the Devicon image fails to load.
*
* Applies a subtle glow (drop-shadow) in dark mode so dark-colored icons
* remain visible against dark backgrounds (e.g. Go, Rust, C).
*/
import { memo, useCallback, useState } from 'react';
import { cn } from '@renderer/lib/utils';
import { getDeviconUrl, getFileIcon } from './fileIcons';
// =============================================================================
// Types
// =============================================================================
interface FileIconProps {
/** File name (e.g. "index.ts", "Dockerfile", "logo.png") */
fileName: string;
/** Tailwind size class (e.g. "size-3.5", "size-4"). Defaults to "size-3.5" */
className?: string;
}
// Track slugs that failed to load so we don't retry them across mounts
const failedSlugs = new Set<string>();
// =============================================================================
// Component
// =============================================================================
export const FileIcon = memo(({ fileName, className = 'size-3.5' }: FileIconProps) => {
const info = getFileIcon(fileName);
const slug = info.deviconSlug;
const canUseDevicon = slug != null && !failedSlugs.has(slug);
const [imgFailed, setImgFailed] = useState(false);
const handleError = useCallback(() => {
if (slug) failedSlugs.add(slug);
setImgFailed(true);
}, [slug]);
if (canUseDevicon && !imgFailed) {
return (
<img
src={getDeviconUrl(slug)}
className={cn('file-icon-glow shrink-0', className)}
onError={handleError}
alt=""
draggable={false}
loading="lazy"
/>
);
}
// Fallback to lucide icon
const Icon = info.icon;
return <Icon className={cn('shrink-0', className)} style={{ color: info.color }} />;
});
FileIcon.displayName = 'FileIcon';

View file

@ -10,6 +10,8 @@
* - R (renamed) cyan
*/
import React from 'react';
import type { GitFileStatusType } from '@shared/types/editor';
// =============================================================================
@ -33,11 +35,13 @@ interface GitStatusBadgeProps {
status: GitFileStatusType;
}
export const GitStatusBadge = ({ status }: GitStatusBadgeProps): React.ReactElement => {
export const GitStatusBadge = React.memo(function GitStatusBadge({
status,
}: GitStatusBadgeProps): React.ReactElement {
const config = STATUS_CONFIG[status];
return (
<span className={`ml-auto shrink-0 text-[10px] leading-none ${config.color}`} title={status}>
{config.letter}
</span>
);
};
});

View file

@ -0,0 +1,186 @@
/**
* Go to Line dialog (Cmd+G) custom replacement for CodeMirror's built-in gotoLine.
*
* Supports: line numbers, relative offsets (+5, -3), percentages (50%),
* and optional column positions (42:10).
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { EditorView } from '@codemirror/view';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { editorBridge } from '@renderer/utils/editorBridge';
// =============================================================================
// Types
// =============================================================================
interface GoToLineDialogProps {
onClose: () => void;
}
// =============================================================================
// Line parsing
// =============================================================================
interface ParsedTarget {
line: number;
col?: number;
}
function parseLineInput(input: string, view: EditorView): ParsedTarget | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Split line:col
const [linePart, colPart] = trimmed.split(':');
const col = colPart ? parseInt(colPart, 10) : undefined;
if (col !== undefined && (isNaN(col) || col < 1)) return null;
const lineStr = linePart.trim();
if (!lineStr) return null;
const totalLines = view.state.doc.lines;
// Percentage: "50%"
if (lineStr.endsWith('%')) {
const pct = parseFloat(lineStr.slice(0, -1));
if (isNaN(pct)) return null;
const line = Math.max(1, Math.min(totalLines, Math.round((pct / 100) * totalLines)));
return { line, col };
}
// Relative: "+5" or "-3"
if (lineStr.startsWith('+') || lineStr.startsWith('-')) {
const offset = parseInt(lineStr, 10);
if (isNaN(offset)) return null;
const currentPos = view.state.selection.main.head;
const currentLine = view.state.doc.lineAt(currentPos).number;
const line = Math.max(1, Math.min(totalLines, currentLine + offset));
return { line, col };
}
// Absolute: "42"
const lineNum = parseInt(lineStr, 10);
if (isNaN(lineNum)) return null;
const line = Math.max(1, Math.min(totalLines, lineNum));
return { line, col };
}
// =============================================================================
// Component
// =============================================================================
export const GoToLineDialog = ({ onClose }: GoToLineDialogProps): React.ReactElement => {
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
// Focus on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Escape to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [onClose]);
const handleGo = useCallback(() => {
const view = editorBridge.getView();
if (!view) return;
const target = parseLineInput(value, view);
if (!target) return;
const lineInfo = view.state.doc.line(target.line);
const colOffset = target.col ? Math.min(target.col - 1, lineInfo.length) : 0;
const pos = lineInfo.from + colOffset;
view.dispatch({
selection: { anchor: pos },
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
});
view.focus();
onClose();
}, [value, onClose]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleGo();
}
},
[handleGo]
);
// Current line info for placeholder
const view = editorBridge.getView();
const totalLines = view?.state.doc.lines ?? 0;
const currentLine = view ? view.state.doc.lineAt(view.state.selection.main.head).number : 0;
return (
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
role="presentation"
/>
{/* Dialog */}
<div
role="dialog"
aria-modal="true"
aria-label="Go to Line"
className="relative z-10 w-[360px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl"
>
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between">
<span className="text-xs text-text-secondary">
Go to Line{' '}
<span className="text-text-muted">
(current: {currentLine}, total: {totalLines})
</span>
</span>
</div>
<div className="flex items-center gap-2">
<Input
ref={inputRef}
className="h-8 flex-1 bg-transparent text-sm"
placeholder="Line number, +offset, -offset, or %"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
spellCheck={false}
autoComplete="off"
/>
<Button
variant="secondary"
size="sm"
className="h-8 px-4"
onClick={handleGo}
disabled={!value.trim()}
>
Go
</Button>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,57 @@
/**
* Scrollable markdown preview pane for the editor split view.
*
* Wraps MarkdownViewer in a scrollable container with ref access
* for external scroll synchronization (code preview).
*/
import React from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
// =============================================================================
// Types
// =============================================================================
interface MarkdownPreviewPaneProps {
content: string;
className?: string;
scrollRef?: React.RefObject<HTMLDivElement | null>;
onScroll?: () => void;
/** Base directory for resolving relative image/link URLs */
baseDir?: string;
}
// =============================================================================
// Component
// =============================================================================
export const MarkdownPreviewPane = React.memo(function MarkdownPreviewPane({
content,
className = '',
scrollRef,
onScroll,
baseDir,
}: MarkdownPreviewPaneProps): React.ReactElement {
// Callback ref to wire scrollRef (RefObject<T | null>) to the div
const internalRef = React.useRef<HTMLDivElement | null>(null);
const setRef = React.useCallback(
(el: HTMLDivElement | null) => {
internalRef.current = el;
if (scrollRef && 'current' in scrollRef) {
// Forward ref — the mutable cast is the standard pattern for forwarding refs
const mutableRef = scrollRef as React.MutableRefObject<HTMLDivElement | null>;
mutableRef.current = el;
}
},
[scrollRef]
);
return (
<div ref={setRef} className={`h-full overflow-y-auto ${className}`} onScroll={onScroll}>
<div className="p-4">
<MarkdownViewer content={content} bare maxHeight="" baseDir={baseDir} />
</div>
</div>
);
});

View file

@ -0,0 +1,128 @@
/**
* Right-side panel for markdown split/preview mode.
*
* In split mode: renders a drag-resizable handle + MarkdownPreviewPane.
* In preview mode: renders MarkdownPreviewPane at full width (no handle).
*
* CodeMirrorEditor is NOT rendered here it stays in ProjectEditorOverlay
* and is controlled via CSS display/width.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
import { MarkdownPreviewPane } from './MarkdownPreviewPane';
// =============================================================================
// Types
// =============================================================================
interface MarkdownSplitViewProps {
content: string;
mode: 'split' | 'preview';
splitRatio: number;
onSplitRatioChange: (ratio: number) => void;
/** Key that changes when the EditorView changes (e.g. activeTabId) — triggers scroll re-attach */
viewKey?: string | null;
/** Base directory for resolving relative image/link URLs */
baseDir?: string;
}
// =============================================================================
// Constants
// =============================================================================
const MIN_RATIO = 0.2;
const MAX_RATIO = 0.8;
const HANDLE_WIDTH = 4; // px
// =============================================================================
// Component
// =============================================================================
export const MarkdownSplitView = React.memo(function MarkdownSplitView({
content,
mode,
splitRatio,
onSplitRatioChange,
viewKey,
baseDir,
}: MarkdownSplitViewProps): React.ReactElement {
const [isResizing, setIsResizing] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Scroll sync auto-manages its own listener lifecycle via viewKey
const scrollSync = useMarkdownScrollSync(mode === 'split', viewKey);
// --- Resize drag logic ---
const handleMouseMove = useCallback(
(e: MouseEvent) => {
const parent = containerRef.current?.parentElement;
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
const relativeX = e.clientX - parentRect.left;
const newRatio = Math.min(MAX_RATIO, Math.max(MIN_RATIO, relativeX / parentRect.width));
onSplitRatioChange(newRatio);
},
[onSplitRatioChange]
);
const handleMouseUp = useCallback(() => {
setIsResizing(false);
}, []);
useEffect(() => {
if (!isResizing) return;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isResizing, handleMouseMove, handleMouseUp]);
const handleMouseDown = (e: React.MouseEvent): void => {
e.preventDefault();
setIsResizing(true);
};
// --- Preview width ---
const previewWidth =
mode === 'preview' ? '100%' : `calc(${(1 - splitRatio) * 100}% - ${HANDLE_WIDTH}px)`;
return (
<div ref={containerRef} className="flex h-full" style={{ width: previewWidth }}>
{/* Resize handle — only in split mode */}
{mode === 'split' && (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- resize handle
<div
className={`shrink-0 cursor-col-resize border-x border-border transition-colors ${
isResizing ? 'bg-blue-500/50' : 'hover:bg-blue-500/30'
}`}
style={{ width: HANDLE_WIDTH }}
onMouseDown={handleMouseDown}
/>
)}
{/* Preview pane */}
<div className="flex-1 overflow-hidden bg-surface">
<MarkdownPreviewPane
content={content}
scrollRef={scrollSync.previewScrollRef}
onScroll={scrollSync.handlePreviewScroll}
baseDir={baseDir}
/>
</div>
</div>
);
});

View file

@ -1,7 +1,10 @@
/**
* Inline input for creating a new file or directory in the file tree.
*
* Auto-focuses, validates on the client side, submits on Enter, cancels on Escape/blur.
* Auto-focuses, validates on the client side, submits on Enter, cancels on Escape.
* Uses click-outside detection instead of onBlur for dismissal onBlur is
* unreliable when the input lives inside a virtualizer + DnD context + Radix
* context menu (all of which can steal focus transiently).
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
@ -48,12 +51,33 @@ export const NewFileDialog = ({
const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Focus input after Radix context menu finishes its focus restoration
useEffect(() => {
// Auto-focus on mount
inputRef.current?.focus();
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, []);
// Click-outside → cancel (replaces unreliable onBlur)
useEffect(() => {
const handlePointerDown = (e: PointerEvent): void => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onCancel();
}
};
// Delay listener registration so the context menu close click isn't caught
const timer = setTimeout(() => {
document.addEventListener('pointerdown', handlePointerDown, true);
}, 150);
return () => {
clearTimeout(timer);
document.removeEventListener('pointerdown', handlePointerDown, true);
};
}, [onCancel]);
const handleSubmit = useCallback(() => {
const trimmed = value.trim();
const validationError = validateName(trimmed);
@ -73,6 +97,7 @@ export const NewFileDialog = ({
e.preventDefault();
onCancel();
}
e.stopPropagation();
},
[handleSubmit, onCancel]
);
@ -85,7 +110,7 @@ export const NewFileDialog = ({
const Icon = type === 'file' ? FilePlus : FolderPlus;
return (
<div className="flex flex-col px-2 py-1">
<div ref={containerRef} className="flex flex-col px-2 py-1">
<div className="flex items-center gap-1.5">
<Icon className="size-3.5 shrink-0 text-text-muted" />
<input
@ -94,7 +119,7 @@ export const NewFileDialog = ({
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={onCancel}
onBlur={() => requestAnimationFrame(() => inputRef.current?.focus())}
placeholder={type === 'file' ? 'File name...' : 'Folder name...'}
className="min-w-0 flex-1 rounded border border-border-emphasis bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-blue-500"
aria-label={type === 'file' ? 'New file name' : 'New folder name'}

View file

@ -7,10 +7,19 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardShortcuts';
import { useStore } from '@renderer/store';
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { buildFileAction, buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { shortcutLabel } from '@renderer/utils/platformKeys';
import {
AlertTriangle,
@ -22,6 +31,7 @@ import {
RotateCcw,
X,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { CodeMirrorEditor } from './CodeMirrorEditor';
import { EditorBinaryState } from './EditorBinaryState';
@ -34,9 +44,12 @@ import { EditorShortcutsHelp } from './EditorShortcutsHelp';
import { EditorStatusBar } from './EditorStatusBar';
import { EditorTabBar } from './EditorTabBar';
import { EditorToolbar } from './EditorToolbar';
import { GoToLineDialog } from './GoToLineDialog';
import { MarkdownSplitView } from './MarkdownSplitView';
import { QuickOpenDialog } from './QuickOpenDialog';
import { SearchInFilesPanel } from './SearchInFilesPanel';
import type { MdPreviewMode } from './EditorToolbar';
import type {
EditorSelectionAction,
EditorSelectionInfo,
@ -63,23 +76,29 @@ export const ProjectEditorOverlay = ({
onClose,
onEditorAction,
}: ProjectEditorOverlayProps): React.ReactElement => {
// Data selectors — grouped with useShallow to prevent unnecessary re-renders
const { activeTabId, openTabs, modifiedFiles, saveErrors, externalChanges, conflictFile } =
useStore(
useShallow((s) => ({
activeTabId: s.editorActiveTabId,
openTabs: s.editorOpenTabs,
modifiedFiles: s.editorModifiedFiles,
saveErrors: s.editorSaveError,
externalChanges: s.editorExternalChanges,
conflictFile: s.editorConflictFile,
}))
);
// Actions — stable references in Zustand, no grouping needed
const openEditor = useStore((s) => s.openEditor);
const closeEditor = useStore((s) => s.closeEditor);
const openFile = useStore((s) => s.openFile);
const closeEditorTab = useStore((s) => s.closeEditorTab);
const saveFile = useStore((s) => s.saveFile);
const activeTabId = useStore((s) => s.editorActiveTabId);
const openTabs = useStore((s) => s.editorOpenTabs);
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
const saveErrors = useStore((s) => s.editorSaveError);
const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges);
const saveAllFiles = useStore((s) => s.saveAllFiles);
const discardChanges = useStore((s) => s.discardChanges);
// Iter-5: git, watcher, conflict
const externalChanges = useStore((s) => s.editorExternalChanges);
const clearExternalChange = useStore((s) => s.clearExternalChange);
const conflictFile = useStore((s) => s.editorConflictFile);
const forceOverwrite = useStore((s) => s.forceOverwrite);
const resolveConflict = useStore((s) => s.resolveConflict);
const setFileMtime = useStore((s) => s.setFileMtime);
@ -104,9 +123,22 @@ export const ProjectEditorOverlay = ({
const editorContentRef = useRef<HTMLDivElement>(null);
const [containerRect, setContainerRect] = useState<DOMRect>(() => new DOMRect());
// Markdown preview state
const [mdPreviewMode, setMdPreviewMode] = useState<MdPreviewMode>('off');
const [liveContent, setLiveContent] = useState('');
const [splitRatio, setSplitRatio] = useState(() => {
try {
const stored = localStorage.getItem('editor:mdSplitRatio');
return stored ? Math.max(0.2, Math.min(0.8, Number(stored))) : 0.5;
} catch {
return 0.5;
}
});
// Iter-4: New state
const [quickOpenVisible, setQuickOpenVisible] = useState(false);
const [searchPanelVisible, setSearchPanelVisible] = useState(false);
const [goToLineVisible, setGoToLineVisible] = useState(false);
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
const [sidebarVisible, setSidebarVisibleRaw] = useState(() => {
try {
@ -123,6 +155,48 @@ export const ProjectEditorOverlay = ({
// Active tab metadata
const activeTab = openTabs.find((t) => t.id === activeTabId) ?? null;
const isMarkdown = activeTab?.language === 'Markdown';
// Auto-enable split preview for markdown tabs, reset for non-markdown
useEffect(() => {
if (isMarkdown) {
setMdPreviewMode((m) => (m === 'off' ? 'split' : m));
} else {
setMdPreviewMode('off');
}
}, [isMarkdown, activeTabId]);
// Persist split ratio
const handleSplitRatioChange = useCallback((ratio: number) => {
setSplitRatio(ratio);
try {
localStorage.setItem('editor:mdSplitRatio', String(ratio));
} catch {
// localStorage unavailable
}
}, []);
const handleLiveContent = useCallback((content: string) => {
setLiveContent(content);
}, []);
const toggleMdSplit = useCallback(() => {
setMdPreviewMode((m) => (m === 'split' ? 'off' : 'split'));
}, []);
const toggleMdPreview = useCallback(() => {
setMdPreviewMode((m) => (m === 'preview' ? 'off' : 'preview'));
}, []);
// Initialize live content when entering preview mode or switching files
useEffect(() => {
if (mdPreviewMode !== 'off' && fileContent?.content) {
setLiveContent(fileContent.content);
}
}, [mdPreviewMode, fileContent?.content]);
// Content for preview: use live content when available, fallback to file content
const previewContent = liveContent || fileContent?.content || '';
const loadFileContent = useCallback(
async (filePath: string) => {
@ -131,13 +205,19 @@ export const ProjectEditorOverlay = ({
setFileContent(null);
try {
const t0 = performance.now();
let promise = pendingReads.current.get(filePath);
const wasCached = !!promise;
if (!promise) {
promise = window.electronAPI.editor.readFile(filePath);
pendingReads.current.set(filePath, promise);
void promise.finally(() => pendingReads.current.delete(filePath));
}
const result = await promise;
const ipcMs = performance.now() - t0;
console.debug(
`[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${filePath.split('/').pop() ?? ''}`
);
setFileContent(result);
// Track baseline mtime for conflict detection
@ -157,6 +237,9 @@ export const ProjectEditorOverlay = ({
// Active tab save error
const activeSaveError = activeTabId ? (saveErrors[activeTabId] ?? null) : null;
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
const revealAndOpenFile = useStore((s) => s.revealAndOpenFile);
// Initialize editor on mount
useEffect(() => {
void openEditor(projectPath);
@ -165,6 +248,18 @@ export const ProjectEditorOverlay = ({
};
}, [projectPath, openEditor, closeEditor]);
// Process pending file reveal after editor initializes.
// Guard: wait until the file tree is actually loaded (not null) and not loading.
// Without the fileTree check, the effect fires on mount when fileTreeLoading is
// still at its initial `false` value — before openEditor sets it to `true`.
const fileTreeLoading = useStore((s) => s.editorFileTreeLoading);
const fileTreeLoaded = useStore((s) => s.editorFileTree !== null);
useEffect(() => {
if (pendingRevealFile && !fileTreeLoading && fileTreeLoaded) {
void revealAndOpenFile(pendingRevealFile);
}
}, [pendingRevealFile, fileTreeLoading, fileTreeLoaded, revealAndOpenFile]);
// Keep container rect fresh for selection menu positioning (resize, sidebar toggle)
useEffect(() => {
const el = editorContentRef.current;
@ -180,6 +275,9 @@ export const ProjectEditorOverlay = ({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// Skip if another handler already consumed this Escape
// (e.g. CodeMirror search panel close, or React search input onKeyDown)
if (e.defaultPrevented) return;
// Don't close overlay if a dialog is open — dialog handles its own Escape
if (quickOpenVisible || searchPanelVisible || shortcutsHelpVisible) return;
if (showConfirmClose || confirmCloseTabId) return;
@ -381,6 +479,10 @@ export const ProjectEditorOverlay = ({
setSearchPanelVisible((v) => !v);
}, []);
const toggleGoToLine = useCallback(() => {
setGoToLineVisible((v) => !v);
}, []);
const toggleSidebar = useCallback(() => {
setSidebarVisibleRaw((v) => {
const next = !v;
@ -410,8 +512,11 @@ export const ProjectEditorOverlay = ({
useEditorKeyboardShortcuts({
onToggleQuickOpen: toggleQuickOpen,
onToggleSearchPanel: toggleSearchPanel,
onToggleGoToLine: toggleGoToLine,
onToggleSidebar: toggleSidebar,
onClose: handleCloseRequest,
onToggleMdSplit: isMarkdown ? toggleMdSplit : undefined,
onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined,
});
const projectName = projectPath.split('/').pop() ?? projectPath;
@ -437,35 +542,46 @@ export const ProjectEditorOverlay = ({
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
<Button
variant="ghost"
size="icon"
className="size-7 text-text-muted"
onClick={handleManualRefresh}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Refresh (F5)"
>
<RefreshCw className="size-4" />
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Refresh git status (F5)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
<Button
variant="ghost"
size="icon"
className="size-7 text-text-muted"
onClick={() => setShortcutsHelpVisible(true)}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Keyboard shortcuts"
>
<HelpCircle className="size-4" />
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Keyboard shortcuts</TooltipContent>
</Tooltip>
<button
onClick={handleCloseRequest}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Close editor"
>
<X className="size-4" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-text-muted"
onClick={handleCloseRequest}
aria-label="Close editor"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close editor (Esc)</TooltipContent>
</Tooltip>
</div>
</div>
@ -491,13 +607,15 @@ export const ProjectEditorOverlay = ({
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
<Button
variant="ghost"
size="icon"
className="size-6 text-text-muted"
onClick={toggleSidebar}
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Hide sidebar"
>
<PanelLeftClose className="size-3.5" />
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
Hide sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
@ -505,7 +623,22 @@ export const ProjectEditorOverlay = ({
</Tooltip>
</div>
<div className="flex-1 overflow-hidden">
<EditorFileTree selectedFilePath={activeTabId} onFileSelect={handleFileSelect} />
<EditorFileTree
selectedFilePath={activeTabId}
onFileSelect={handleFileSelect}
onCreateTask={
onEditorAction
? (filePath: string) =>
onEditorAction(buildFileAction('createTask', filePath, projectPath))
: undefined
}
onSendMessage={
onEditorAction
? (filePath: string) =>
onEditorAction(buildFileAction('sendMessage', filePath, projectPath))
: undefined
}
/>
</div>
</div>
)}
@ -514,13 +647,14 @@ export const ProjectEditorOverlay = ({
{!sidebarVisible && !searchPanelVisible && (
<Tooltip>
<TooltipTrigger asChild>
<button
<Button
variant="ghost"
className="flex h-full w-6 shrink-0 items-start justify-center rounded-none border-r border-border bg-surface-sidebar pt-2 text-text-muted"
onClick={toggleSidebar}
className="flex h-full w-6 shrink-0 items-start justify-center border-r border-border bg-surface-sidebar pt-2 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Show sidebar"
>
<PanelLeftOpen className="size-3.5" />
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
Show sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
@ -534,25 +668,34 @@ export const ProjectEditorOverlay = ({
<EditorTabBar onRequestCloseTab={handleRequestCloseTab} />
{/* Toolbar */}
<EditorToolbar />
<EditorToolbar
isMarkdown={isMarkdown}
mdPreviewMode={mdPreviewMode}
onToggleSplit={toggleMdSplit}
onToggleFullPreview={toggleMdPreview}
/>
{/* Draft recovery banner */}
{draftRecoveredFile && activeTabId === draftRecoveredFile && (
<div className="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-300">
<RotateCcw className="size-3.5 shrink-0" />
<span>Recovered unsaved changes from a previous session.</span>
<button
<Button
variant="ghost"
size="sm"
className="ml-auto h-auto px-2 py-0.5"
onClick={handleDismissDraftBanner}
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Keep
</button>
<button
</Button>
<Button
variant="destructive"
size="sm"
className="h-auto px-2 py-0.5"
onClick={handleDiscardDraft}
className="rounded px-2 py-0.5 text-red-400 transition-colors hover:bg-red-400/10"
>
Discard
</button>
</Button>
</div>
)}
@ -561,12 +704,14 @@ export const ProjectEditorOverlay = ({
<div className="flex shrink-0 items-center gap-2 border-b border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs text-red-300">
<AlertTriangle className="size-3.5 shrink-0" />
<span className="truncate">Save failed: {activeSaveError}</span>
<button
<Button
variant="ghost"
size="sm"
className="ml-auto h-auto shrink-0 px-2 py-0.5"
onClick={() => activeTabId && void saveFile(activeTabId)}
className="ml-auto shrink-0 rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Retry
</button>
</Button>
</div>
)}
@ -580,26 +725,32 @@ export const ProjectEditorOverlay = ({
: 'File changed on disk.'}
</span>
{externalChanges[activeTabId] === 'delete' ? (
<button
<Button
variant="ghost"
size="sm"
className="ml-auto h-auto px-2 py-0.5"
onClick={() => closeEditorTab(activeTabId)}
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Close tab
</button>
</Button>
) : (
<>
<button
<Button
variant="ghost"
size="sm"
className="ml-auto h-auto px-2 py-0.5"
onClick={handleReloadExternalChange}
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Reload
</button>
<button
</Button>
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-0.5"
onClick={handleKeepMine}
className="rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Keep mine
</button>
</Button>
</>
)}
</div>
@ -620,18 +771,42 @@ export const ProjectEditorOverlay = ({
)}
{fileContent && !fileContent.isBinary && activeTabId && (
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
<CodeMirrorEditor
key={`${activeTabId}-${editorResetKey}`}
filePath={activeTabId}
content={fileContent.content}
fileName={activeTabId.split('/').pop() ?? 'file'}
mtimeMs={fileContent.mtimeMs}
onCursorChange={handleCursorChange}
onDraftRecovered={handleDraftRecovered}
onSelectionChange={setSelectionInfo}
/>
</EditorErrorBoundary>
<div className="flex h-full">
{/* Code editor — always mounted, hidden via display:none in preview mode */}
<div
className="h-full overflow-hidden"
style={{
display: mdPreviewMode === 'preview' ? 'none' : 'block',
width: mdPreviewMode === 'split' ? `${splitRatio * 100}%` : '100%',
}}
>
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
<CodeMirrorEditor
key={`${activeTabId}-${editorResetKey}`}
filePath={activeTabId}
content={fileContent.content}
fileName={activeTabId.split('/').pop() ?? 'file'}
mtimeMs={fileContent.mtimeMs}
onCursorChange={handleCursorChange}
onDraftRecovered={handleDraftRecovered}
onSelectionChange={setSelectionInfo}
onDocChange={mdPreviewMode !== 'off' ? handleLiveContent : undefined}
/>
</EditorErrorBoundary>
</div>
{/* Resize handle + Preview pane */}
{mdPreviewMode !== 'off' && (
<MarkdownSplitView
content={previewContent}
mode={mdPreviewMode}
splitRatio={splitRatio}
onSplitRatioChange={handleSplitRatioChange}
viewKey={activeTabId}
baseDir={activeTabId?.substring(0, activeTabId.lastIndexOf('/'))}
/>
)}
</div>
)}
{!fileLoading && !fileError && !fileContent && !activeTabId && <EditorEmptyState />}
@ -668,101 +843,80 @@ export const ProjectEditorOverlay = ({
/>
)}
{/* Go to Line dialog */}
{goToLineVisible && <GoToLineDialog onClose={() => setGoToLineVisible(false)} />}
{/* Shortcuts help modal */}
{shortcutsHelpVisible && (
<EditorShortcutsHelp onClose={() => setShortcutsHelpVisible(false)} />
)}
{/* Unsaved changes confirmation dialog — overlay close */}
{showConfirmClose && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
<h3 className="mb-2 text-sm font-semibold text-text">Unsaved Changes</h3>
<p className="mb-4 text-sm text-text-secondary">
<Dialog open={showConfirmClose} onOpenChange={(open) => !open && handleCancelClose()}>
<DialogContent className="w-96 max-w-96">
<DialogHeader>
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
<DialogDescription>
You have unsaved changes. What would you like to do?
</p>
<div className="flex justify-end gap-2">
<button
onClick={handleCancelClose}
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Cancel
</button>
<button
onClick={handleDiscardAndClose}
className="rounded px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-400/10"
>
Discard & Close
</button>
<button
onClick={() => void handleSaveAndClose()}
className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
>
Save All & Close
</button>
</div>
</div>
</div>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={handleCancelClose}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDiscardAndClose}>
Discard & Close
</Button>
<Button size="sm" onClick={() => void handleSaveAndClose()}>
Save All & Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Save conflict dialog */}
{conflictFile && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
<h3 className="mb-2 text-sm font-semibold text-text">Save Conflict</h3>
<p className="mb-4 text-sm text-text-secondary">
<Dialog open={!!conflictFile} onOpenChange={(open) => !open && handleCancelConflict()}>
<DialogContent className="w-96 max-w-96">
<DialogHeader>
<DialogTitle className="text-sm">Save Conflict</DialogTitle>
<DialogDescription>
The file has been modified externally since you opened it. Overwrite with your
changes?
</p>
<div className="flex justify-end gap-2">
<button
onClick={handleCancelConflict}
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Cancel
</button>
<button
onClick={handleForceOverwrite}
className="rounded bg-orange-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-orange-500"
>
Overwrite
</button>
</div>
</div>
</div>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={handleCancelConflict}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleForceOverwrite}>
Overwrite
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Unsaved changes confirmation dialog — single tab close */}
{confirmCloseTabId && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
<h3 className="mb-2 text-sm font-semibold text-text">Unsaved Changes</h3>
<p className="mb-4 text-sm text-text-secondary">
<Dialog open={!!confirmCloseTabId} onOpenChange={(open) => !open && handleCancelCloseTab()}>
<DialogContent className="w-96 max-w-96">
<DialogHeader>
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
<DialogDescription>
This file has unsaved changes. What would you like to do?
</p>
<div className="flex justify-end gap-2">
<button
onClick={handleCancelCloseTab}
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Cancel
</button>
<button
onClick={handleDiscardAndCloseTab}
className="rounded px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-400/10"
>
Discard
</button>
<button
onClick={() => void handleSaveAndCloseTab()}
className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
>
Save
</button>
</div>
</div>
</div>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={handleCancelCloseTab}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDiscardAndCloseTab}>
Discard
</Button>
<Button size="sm" onClick={() => void handleSaveAndCloseTab()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View file

@ -37,10 +37,17 @@ export const QuickOpenDialog = ({
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]);
const [loading, setLoading] = useState(true);
// Reset loading state when projectPath changes (React-recommended
// "adjusting state when props change" pattern without effects or refs)
const [prevProjectPath, setPrevProjectPath] = useState(projectPath);
if (prevProjectPath !== projectPath) {
setPrevProjectPath(projectPath);
setLoading(true);
}
// Load all project files on mount via backend API
useEffect(() => {
let cancelled = false;
setLoading(true);
window.electronAPI.editor
.listFiles()

View file

@ -5,13 +5,14 @@
* Results are clickable to open the file at the matched line.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Loader2, Search, X } from 'lucide-react';
import { getFileIcon } from './fileIcons';
import { FileIcon } from './FileIcon';
import type { SearchFileResult, SearchInFilesResult } from '@shared/types/editor';
@ -154,13 +155,20 @@ export const SearchInFilesPanel = ({
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-medium text-text-secondary">Search in Files</span>
<button
onClick={onClose}
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Close search"
>
<X className="size-3.5" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 text-text-muted"
onClick={onClose}
aria-label="Close search"
>
<X className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close search (Esc)</TooltipContent>
</Tooltip>
</div>
{/* Search input */}
@ -259,9 +267,6 @@ const SearchFileGroup = ({
const dirPath = relativePath.includes('/')
? relativePath.slice(0, relativePath.lastIndexOf('/'))
: '';
const iconInfo = getFileIcon(fileName);
const Icon = iconInfo.icon;
return (
<div className="border-border/50 border-b">
<button
@ -269,7 +274,7 @@ const SearchFileGroup = ({
className="flex w-full items-center gap-1.5 px-3 py-1 text-left transition-colors hover:bg-surface-raised"
>
<span className="text-[10px] text-text-muted">{expanded ? '▼' : '▶'}</span>
<Icon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
<FileIcon fileName={fileName} className="size-3.5" />
<span className="truncate text-xs font-medium text-text">{fileName}</span>
{dirPath && <span className="ml-1 truncate text-[10px] text-text-muted">{dirPath}</span>}
<span className="ml-auto shrink-0 text-[10px] text-text-muted">
@ -310,11 +315,11 @@ interface HighlightedLineProps {
caseSensitive: boolean;
}
const HighlightedLine = ({
const HighlightedLine = React.memo(function HighlightedLine({
text,
query,
caseSensitive,
}: HighlightedLineProps): React.ReactElement => {
}: HighlightedLineProps): React.ReactElement {
if (!query) {
return <span className="truncate text-[11px] text-text-secondary">{text}</span>;
}
@ -351,4 +356,4 @@ const HighlightedLine = ({
}
return <span className="truncate text-[11px]">{parts}</span>;
};
});

View file

@ -1,5 +1,8 @@
/**
* File icon mapping maps file extensions to lucide-react icon names and colors.
* File icon mapping maps file extensions/names to icon info.
*
* For programming languages and dev tools, uses Devicon CDN SVGs (colorful logos).
* For generic file types (images, fonts, configs), falls back to lucide-react icons.
*/
import {
@ -7,7 +10,6 @@ import {
Code,
Database,
File,
FileCode,
FileJson,
FileText,
FileType,
@ -26,6 +28,22 @@ import type { LucideIcon } from 'lucide-react';
export interface FileIconInfo {
icon: LucideIcon;
color: string;
/** Devicon slug — when set, FileIcon component renders the real logo from CDN */
deviconSlug?: string;
}
// =============================================================================
// Devicon CDN
// =============================================================================
const DEVICON_BASE = 'https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons';
/**
* Build Devicon CDN URL for a given slug.
* Uses `-original` variant (colorful) with `-wordmark` fallback.
*/
export function getDeviconUrl(slug: string): string {
return `${DEVICON_BASE}/${slug}/${slug}-original.svg`;
}
// =============================================================================
@ -34,74 +52,75 @@ export interface FileIconInfo {
const EXTENSION_MAP: Record<string, FileIconInfo> = {
// TypeScript / JavaScript
ts: { icon: FileCode, color: '#3178c6' },
tsx: { icon: FileCode, color: '#3178c6' },
js: { icon: FileCode, color: '#f7df1e' },
jsx: { icon: FileCode, color: '#61dafb' },
mjs: { icon: FileCode, color: '#f7df1e' },
cjs: { icon: FileCode, color: '#f7df1e' },
ts: { icon: Code, color: '#3178c6', deviconSlug: 'typescript' },
tsx: { icon: Code, color: '#3178c6', deviconSlug: 'react' },
js: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
jsx: { icon: Code, color: '#61dafb', deviconSlug: 'react' },
mjs: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
cjs: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
// Web
html: { icon: Code, color: '#e34c26' },
htm: { icon: Code, color: '#e34c26' },
css: { icon: FileCode, color: '#563d7c' },
scss: { icon: FileCode, color: '#c6538c' },
less: { icon: FileCode, color: '#1d365d' },
vue: { icon: FileCode, color: '#42b883' },
svelte: { icon: FileCode, color: '#ff3e00' },
html: { icon: Code, color: '#e34c26', deviconSlug: 'html5' },
htm: { icon: Code, color: '#e34c26', deviconSlug: 'html5' },
css: { icon: Code, color: '#1572b6', deviconSlug: 'css3' },
scss: { icon: Code, color: '#c6538c', deviconSlug: 'sass' },
less: { icon: Code, color: '#1d365d', deviconSlug: 'less' },
vue: { icon: Code, color: '#42b883', deviconSlug: 'vuejs' },
svelte: { icon: Code, color: '#ff3e00', deviconSlug: 'svelte' },
// Data / Config
json: { icon: FileJson, color: '#cbcb41' },
// Data / Config (no devicon — lucide fallbacks)
json: { icon: FileJson, color: '#cbcb41', deviconSlug: 'json' },
jsonl: { icon: FileJson, color: '#cbcb41' },
yaml: { icon: Settings, color: '#cb171e' },
yml: { icon: Settings, color: '#cb171e' },
yaml: { icon: Settings, color: '#cb171e', deviconSlug: 'yaml' },
yml: { icon: Settings, color: '#cb171e', deviconSlug: 'yaml' },
toml: { icon: Settings, color: '#9c4121' },
xml: { icon: Code, color: '#e37933' },
xml: { icon: Code, color: '#e37933', deviconSlug: 'xml' },
csv: { icon: Database, color: '#4caf50' },
// Markdown / Text
md: { icon: FileText, color: '#519aba' },
mdx: { icon: FileText, color: '#519aba' },
md: { icon: FileText, color: '#519aba', deviconSlug: 'markdown' },
mdx: { icon: FileText, color: '#519aba', deviconSlug: 'markdown' },
txt: { icon: FileText, color: '#89949f' },
rst: { icon: FileText, color: '#89949f' },
// Python
py: { icon: FileCode, color: '#3572a5' },
pyx: { icon: FileCode, color: '#3572a5' },
pyi: { icon: FileCode, color: '#3572a5' },
py: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
pyx: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
pyi: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
// Rust
rs: { icon: FileCode, color: '#dea584' },
rs: { icon: Code, color: '#dea584', deviconSlug: 'rust' },
// Go
go: { icon: FileCode, color: '#00add8' },
go: { icon: Code, color: '#00add8', deviconSlug: 'go' },
// Ruby
rb: { icon: FileCode, color: '#cc342d' },
gemspec: { icon: FileCode, color: '#cc342d' },
rb: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
gemspec: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
// Java / Kotlin
java: { icon: FileCode, color: '#b07219' },
kt: { icon: FileCode, color: '#a97bff' },
kts: { icon: FileCode, color: '#a97bff' },
java: { icon: Code, color: '#b07219', deviconSlug: 'java' },
kt: { icon: Code, color: '#a97bff', deviconSlug: 'kotlin' },
kts: { icon: Code, color: '#a97bff', deviconSlug: 'kotlin' },
// C / C++
c: { icon: FileCode, color: '#555555' },
h: { icon: FileCode, color: '#555555' },
cpp: { icon: FileCode, color: '#f34b7d' },
hpp: { icon: FileCode, color: '#f34b7d' },
cc: { icon: FileCode, color: '#f34b7d' },
// C / C++ / C#
c: { icon: Code, color: '#555555', deviconSlug: 'c' },
h: { icon: Code, color: '#555555', deviconSlug: 'c' },
cpp: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
hpp: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
cc: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
cs: { icon: Code, color: '#178600', deviconSlug: 'csharp' },
// Shell
sh: { icon: Terminal, color: '#89e051' },
bash: { icon: Terminal, color: '#89e051' },
zsh: { icon: Terminal, color: '#89e051' },
sh: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
bash: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
zsh: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
fish: { icon: Terminal, color: '#89e051' },
// SQL
sql: { icon: Database, color: '#e38c00' },
sql: { icon: Database, color: '#e38c00', deviconSlug: 'azuresqldatabase' },
// Images
// Images (no devicon — lucide Image icon)
png: { icon: Image, color: '#a074c4' },
jpg: { icon: Image, color: '#a074c4' },
jpeg: { icon: Image, color: '#a074c4' },
@ -110,46 +129,76 @@ const EXTENSION_MAP: Record<string, FileIconInfo> = {
ico: { icon: Image, color: '#a074c4' },
webp: { icon: Image, color: '#a074c4' },
// Fonts
// Fonts (no devicon — lucide FileType icon)
woff: { icon: FileType, color: '#89949f' },
woff2: { icon: FileType, color: '#89949f' },
ttf: { icon: FileType, color: '#89949f' },
otf: { icon: FileType, color: '#89949f' },
// Config files
// Config files (no devicon — lucide icons)
env: { icon: Lock, color: '#e5a00d' },
ini: { icon: Settings, color: '#89949f' },
conf: { icon: Settings, color: '#89949f' },
cfg: { icon: Settings, color: '#89949f' },
// Other
graphql: { icon: Braces, color: '#e535ab' },
gql: { icon: Braces, color: '#e535ab' },
proto: { icon: Code, color: '#89949f' },
dart: { icon: FileCode, color: '#00b4ab' },
swift: { icon: FileCode, color: '#f05138' },
php: { icon: FileCode, color: '#4f5d95' },
// Other languages
graphql: { icon: Braces, color: '#e535ab', deviconSlug: 'graphql' },
gql: { icon: Braces, color: '#e535ab', deviconSlug: 'graphql' },
proto: { icon: Code, color: '#89949f', deviconSlug: 'protobuf' },
dart: { icon: Code, color: '#00b4ab', deviconSlug: 'dart' },
swift: { icon: Code, color: '#f05138', deviconSlug: 'swift' },
php: { icon: Code, color: '#4f5d95', deviconSlug: 'php' },
r: { icon: Code, color: '#276dc3', deviconSlug: 'r' },
lua: { icon: Code, color: '#000080', deviconSlug: 'lua' },
pl: { icon: Code, color: '#39457e', deviconSlug: 'perl' },
scala: { icon: Code, color: '#dc322f', deviconSlug: 'scala' },
groovy: { icon: Code, color: '#4298b8', deviconSlug: 'groovy' },
ex: { icon: Code, color: '#6e4a7e', deviconSlug: 'elixir' },
exs: { icon: Code, color: '#6e4a7e', deviconSlug: 'elixir' },
erl: { icon: Code, color: '#b83998', deviconSlug: 'erlang' },
hs: { icon: Code, color: '#5e5086', deviconSlug: 'haskell' },
clj: { icon: Code, color: '#db5855', deviconSlug: 'clojure' },
fs: { icon: Code, color: '#b845fc', deviconSlug: 'fsharp' },
zig: { icon: Code, color: '#f7a41d', deviconSlug: 'zig' },
nim: { icon: Code, color: '#ffc200', deviconSlug: 'nimble' },
tf: { icon: Code, color: '#7b42bc', deviconSlug: 'terraform' },
hcl: { icon: Code, color: '#7b42bc', deviconSlug: 'terraform' },
};
// Special full filename mapping
const FILENAME_MAP: Record<string, FileIconInfo> = {
Dockerfile: { icon: FileCode, color: '#2496ed' },
'docker-compose.yml': { icon: FileCode, color: '#2496ed' },
'docker-compose.yaml': { icon: FileCode, color: '#2496ed' },
Dockerfile: { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
'docker-compose.yml': { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
'docker-compose.yaml': { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
Makefile: { icon: Terminal, color: '#427819' },
Rakefile: { icon: Terminal, color: '#cc342d' },
Gemfile: { icon: FileCode, color: '#cc342d' },
'.gitignore': { icon: Settings, color: '#f05032' },
'.gitattributes': { icon: Settings, color: '#f05032' },
'.eslintrc': { icon: Settings, color: '#4b32c3' },
Rakefile: { icon: Terminal, color: '#cc342d', deviconSlug: 'ruby' },
Gemfile: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
'.gitignore': { icon: Settings, color: '#f05032', deviconSlug: 'git' },
'.gitattributes': { icon: Settings, color: '#f05032', deviconSlug: 'git' },
'.eslintrc': { icon: Settings, color: '#4b32c3', deviconSlug: 'eslint' },
'.prettierrc': { icon: Settings, color: '#56b3b4' },
'tsconfig.json': { icon: Settings, color: '#3178c6' },
'package.json': { icon: FileJson, color: '#cb3837' },
'tsconfig.json': { icon: Settings, color: '#3178c6', deviconSlug: 'typescript' },
'package.json': { icon: FileJson, color: '#cb3837', deviconSlug: 'nodejs' },
'pnpm-lock.yaml': { icon: Lock, color: '#f69220' },
'package-lock.json': { icon: Lock, color: '#cb3837' },
'yarn.lock': { icon: Lock, color: '#2c8ebb' },
'package-lock.json': { icon: Lock, color: '#cb3837', deviconSlug: 'npm' },
'yarn.lock': { icon: Lock, color: '#2c8ebb', deviconSlug: 'yarn' },
LICENSE: { icon: FileText, color: '#d9b611' },
'CLAUDE.md': { icon: FileText, color: '#d97706' },
'Cargo.toml': { icon: Settings, color: '#dea584', deviconSlug: 'rust' },
'go.mod': { icon: Settings, color: '#00add8', deviconSlug: 'go' },
'go.sum': { icon: Lock, color: '#00add8', deviconSlug: 'go' },
'.dockerignore': { icon: Settings, color: '#2496ed', deviconSlug: 'docker' },
'vite.config.ts': { icon: Settings, color: '#646cff', deviconSlug: 'vitejs' },
'vite.config.js': { icon: Settings, color: '#646cff', deviconSlug: 'vitejs' },
'webpack.config.js': { icon: Settings, color: '#8dd6f9', deviconSlug: 'webpack' },
'webpack.config.ts': { icon: Settings, color: '#8dd6f9', deviconSlug: 'webpack' },
'.babelrc': { icon: Settings, color: '#f5da55', deviconSlug: 'babel' },
'babel.config.js': { icon: Settings, color: '#f5da55', deviconSlug: 'babel' },
'tailwind.config.js': { icon: Settings, color: '#06b6d4', deviconSlug: 'tailwindcss' },
'tailwind.config.ts': { icon: Settings, color: '#06b6d4', deviconSlug: 'tailwindcss' },
'next.config.js': { icon: Settings, color: '#000000', deviconSlug: 'nextjs' },
'next.config.mjs': { icon: Settings, color: '#000000', deviconSlug: 'nextjs' },
'nuxt.config.ts': { icon: Settings, color: '#00dc82', deviconSlug: 'nuxtjs' },
};
const DEFAULT_ICON: FileIconInfo = { icon: File, color: '#89949f' };

View file

@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -76,6 +77,41 @@ const DependencyBadge = ({
);
};
const TruncatedTitle = ({
text,
className,
}: {
text: string;
className?: string;
}): React.JSX.Element => {
const ref = useRef<HTMLHeadingElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const checkTruncation = useCallback(() => {
const el = ref.current;
if (el) {
setIsTruncated(el.scrollWidth > el.clientWidth);
}
}, []);
return (
<Tooltip open={isTruncated ? undefined : false}>
<TooltipTrigger asChild>
<h5
ref={ref}
className={`truncate text-sm font-medium text-[var(--color-text)] ${className ?? ''}`}
onMouseEnter={checkTruncation}
>
{text}
</h5>
</TooltipTrigger>
<TooltipContent side="top" align="start">
{text}
</TooltipContent>
</Tooltip>
);
};
const CancelTaskButton = ({
taskId,
onConfirm,
@ -228,11 +264,7 @@ export const KanbanTaskCard = ({
#{task.id}
</Badge>
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
{!compact && (
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
)}
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
</div>
{task.needsClarification ? (
<span
@ -246,11 +278,7 @@ export const KanbanTaskCard = ({
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
</span>
) : null}
{compact && (
<h5 className="mt-1 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
)}
{compact && <TruncatedTitle text={task.subject} className="mt-1" />}
</div>
{hasBlockedBy ? (

View file

@ -32,7 +32,13 @@ export const MemberList = ({
onAssignTask,
onOpenTask,
}: MemberListProps): React.JSX.Element => {
const activeMembers = members.filter((m) => !m.removedAt);
const activeMembers = members
.filter((m) => !m.removedAt)
.sort((a, b) => {
if (a.agentType === 'team-lead') return -1;
if (b.agentType === 'team-lead') return 1;
return 0;
});
const removedMembers = members.filter((m) => m.removedAt);
const colorMap = buildMemberColorMap(members);

View file

@ -24,6 +24,8 @@ interface MemberLogsTabProps {
/** When viewing task logs: include owner's sessions when task is in_progress */
taskOwner?: string;
taskStatus?: string;
/** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */
taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
}
export const MemberLogsTab = ({
@ -32,6 +34,7 @@ export const MemberLogsTab = ({
taskId,
taskOwner,
taskStatus,
taskWorkIntervals,
}: MemberLogsTabProps): React.JSX.Element => {
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
const [loading, setLoading] = useState(true);
@ -61,6 +64,7 @@ export const MemberLogsTab = ({
? await api.teams.getLogsForTask(teamName, taskId, {
owner: taskOwner,
status: taskStatus,
intervals: taskWorkIntervals,
})
: await api.teams.getMemberLogs(teamName, memberName!);
if (!cancelled) {
@ -86,7 +90,7 @@ export const MemberLogsTab = ({
cancelled = true;
if (interval) clearInterval(interval);
};
}, [teamName, memberName, taskId, taskOwner, taskStatus]);
}, [teamName, memberName, taskId, taskOwner, taskStatus, taskWorkIntervals]);
const handleExpand = useCallback(
async (log: MemberLogSummary) => {

View file

@ -7,8 +7,12 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useAttachments } from '@renderer/hooks/useAttachments';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -50,7 +54,9 @@ export const MessageComposer = ({
const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const draft = useDraftPersistence({ key: `compose:${teamName}` });
const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`);
const {
attachments,
error: attachmentError,
@ -88,11 +94,23 @@ export const MessageComposer = ({
// Track whether we initiated a send — clear draft only on confirmed success
const pendingSendRef = useRef(false);
const handleChipRemove = useCallback(
(chipId: string) => {
const chip = chipDraft.chips.find((c) => c.id === chipId);
if (chip) {
draft.setValue(removeChipTokenFromText(draft.value, chip));
}
chipDraft.setChips(chipDraft.chips.filter((c) => c.id !== chipId));
},
[chipDraft, draft]
);
const handleSend = useCallback(() => {
if (!canSend) return;
pendingSendRef.current = true;
onSend(recipient, trimmed, trimmed, attachments.length > 0 ? attachments : undefined);
}, [canSend, recipient, trimmed, onSend, attachments]);
const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
onSend(recipient, serialized, serialized, attachments.length > 0 ? attachments : undefined);
}, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]);
// Clear draft only after send completes successfully (sending: true → false, no error)
useEffect(() => {
@ -100,10 +118,12 @@ export const MessageComposer = ({
pendingSendRef.current = false;
if (!sendError) {
draft.clearDraft();
chipDraft.clearChipDraft();
clearAttachments();
}
}
}, [sending, sendError, draft, clearAttachments]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearChipDraft is stable (useCallback with [])
}, [sending, sendError, draft, clearAttachments, chipDraft.clearChipDraft]);
const handleKeyDownCapture = useCallback(
(e: React.KeyboardEvent) => {
@ -304,6 +324,10 @@ export const MessageComposer = ({
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}
chips={chipDraft.chips}
onChipRemove={handleChipRemove}
projectPath={projectPath}
onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])}
minRows={2}
maxRows={6}
maxLength={MAX_MESSAGE_LENGTH}

View file

@ -1,17 +1,20 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { undo } from '@codemirror/commands';
import { goToNextChunk, rejectChunk } from '@codemirror/merge';
import { rejectChunk } from '@codemirror/merge';
import { isElectronMode } from '@renderer/api';
import { EditorSelectionMenu } from '@renderer/components/team/editor/EditorSelectionMenu';
import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav';
import { isLastChunkInFile, useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice';
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
import { ChevronDown, Clock, X } from 'lucide-react';
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
import { ContinuousScrollView } from './ContinuousScrollView';
import { FileEditTimeline } from './FileEditTimeline';
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
@ -22,6 +25,7 @@ import { ViewedProgressBar } from './ViewedProgressBar';
import type { EditorView } from '@codemirror/view';
import type { HunkDecision, TaskChangeSetV2 } from '@shared/types';
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
interface ChangeReviewDialogProps {
open: boolean;
@ -31,6 +35,8 @@ interface ChangeReviewDialogProps {
memberName?: string;
taskId?: string;
initialFilePath?: string;
projectPath?: string;
onEditorAction?: (action: EditorSelectionAction) => void;
}
function isTaskChangeSetV2(cs: { teamName: string }): cs is TaskChangeSetV2 {
@ -45,6 +51,8 @@ export const ChangeReviewDialog = ({
memberName,
taskId,
initialFilePath,
projectPath,
onEditorAction,
}: ChangeReviewDialogProps): React.ReactElement | null => {
const {
activeChangeSet,
@ -61,6 +69,7 @@ export const ChangeReviewDialog = ({
applying,
applyError,
setHunkDecision,
clearHunkDecisionByOriginalIndex,
setCollapseUnchanged,
fetchFileContent,
acceptAllFile,
@ -79,6 +88,7 @@ export const ChangeReviewDialog = ({
pushReviewUndoSnapshot,
undoBulkReview,
reviewUndoStack,
hunkContextHashesByFile,
} = useStore();
// Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll)
@ -87,11 +97,23 @@ export const ChangeReviewDialog = ({
const [timelineOpen, setTimelineOpen] = useState(false);
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
// Selection menu state
const [selectionInfo, setSelectionInfo] = useState<EditorSelectionInfo | null>(null);
const [containerRect, setContainerRect] = useState<DOMRect>(new DOMRect());
const diffContentRef = useRef<HTMLDivElement>(null);
const selectionTimerRef = useRef<ReturnType<typeof setTimeout>>();
const activeSelectionFileRef = useRef<string | null>(null);
// EditorView map for all visible file editors
const editorViewMapRef = useRef(new Map<string, EditorView>());
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Last focused CM editor — for Cmd+Z outside editor
const lastFocusedEditorRef = useRef<EditorView | null>(null);
// Timestamp of last bulk accept/reject-all operation (for Ctrl/Cmd+Z UX)
const lastBulkActionAtRef = useRef<number>(0);
// Track recent per-hunk actions so Ctrl/Cmd+Z can clear persisted decisions (reopen-safe)
const lastHunkActionAtRef = useRef<Record<string, number>>({});
const hunkDecisionUndoStackRef = useRef<Record<string, number[]>>({});
// Proxy ref for useDiffNavigation (points to active file's editor)
const activeEditorViewRef = useRef<EditorView | null>(null);
@ -155,6 +177,7 @@ export const ChangeReviewDialog = ({
const handleAcceptAll = useCallback(() => {
if (!activeChangeSet) return;
pushReviewUndoSnapshot();
lastBulkActionAtRef.current = Date.now();
for (const file of activeChangeSet.files) {
acceptAllFile(file.filePath);
}
@ -168,6 +191,7 @@ export const ChangeReviewDialog = ({
const handleRejectAll = useCallback(() => {
if (!activeChangeSet) return;
pushReviewUndoSnapshot();
lastBulkActionAtRef.current = Date.now();
for (const file of activeChangeSet.files) {
rejectAllFile(file.filePath);
}
@ -181,14 +205,24 @@ export const ChangeReviewDialog = ({
// Per-file callbacks for ContinuousScrollView
const handleHunkAccepted = useCallback(
(filePath: string, hunkIndex: number) => {
setHunkDecision(filePath, hunkIndex, 'accepted');
const originalIndex = setHunkDecision(filePath, hunkIndex, 'accepted');
lastHunkActionAtRef.current[filePath] = Date.now();
if (!hunkDecisionUndoStackRef.current[filePath]) {
hunkDecisionUndoStackRef.current[filePath] = [];
}
hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
},
[setHunkDecision]
);
const handleHunkRejected = useCallback(
(filePath: string, hunkIndex: number) => {
setHunkDecision(filePath, hunkIndex, 'rejected');
const originalIndex = setHunkDecision(filePath, hunkIndex, 'rejected');
lastHunkActionAtRef.current[filePath] = Date.now();
if (!hunkDecisionUndoStackRef.current[filePath]) {
hunkDecisionUndoStackRef.current[filePath] = [];
}
hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
if (REVIEW_INSTANT_APPLY) {
void applySingleFileDecision(teamName, filePath, taskId, memberName);
}
@ -214,9 +248,18 @@ export const ChangeReviewDialog = ({
const handleSaveFile = useCallback(
(filePath: string) => {
void saveEditedFile(filePath);
void saveEditedFile(filePath, projectPath);
},
[saveEditedFile]
[saveEditedFile, projectPath]
);
const handleRestoreMissingFile = useCallback(
(filePath: string, content: string) => {
updateEditedContent(filePath, content);
// Ensure editedContents is set before saveEditedFile reads it.
void Promise.resolve().then(() => saveEditedFile(filePath, projectPath));
},
[updateEditedContent, saveEditedFile, projectPath]
);
const handleDiscardFile = useCallback(
@ -242,10 +285,72 @@ export const ChangeReviewDialog = ({
}
}, [undoBulkReview, activeChangeSet]);
// Save active file (for Cmd+Enter keyboard shortcut)
// Selection change handler (debounced for non-empty, immediate for clear)
const handleSelectionChange = useCallback((info: EditorSelectionInfo | null) => {
if (!info) {
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
setSelectionInfo(null);
return;
}
activeSelectionFileRef.current = info.filePath;
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
selectionTimerRef.current = setTimeout(() => {
setSelectionInfo(info);
}, SELECTION_DEBOUNCE_MS);
}, []);
// Scroll repositioning — re-query coords when parent scrolls (rAF-throttled)
const hasData = !changeSetLoading && !changeSetError && !!activeChangeSet;
useEffect(() => {
if (!hasData) return;
const container = scrollContainerRef.current;
if (!container) return;
let rafId = 0;
const onScroll = (): void => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const fp = activeSelectionFileRef.current;
if (!fp) return;
const view = editorViewMapRef.current.get(fp);
if (!view) return;
const sel = view.state.selection.main;
if (sel.empty) {
setSelectionInfo(null);
return;
}
const info = buildSelectionInfo(view, sel);
if (info) {
setSelectionInfo({ ...info, filePath: fp });
} else {
setSelectionInfo(null);
}
});
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
cancelAnimationFrame(rafId);
container.removeEventListener('scroll', onScroll);
};
}, [hasData]);
// Track container rect for menu positioning
useEffect(() => {
const el = diffContentRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
setContainerRect(el.getBoundingClientRect());
});
observer.observe(el);
setContainerRect(el.getBoundingClientRect());
return () => observer.disconnect();
}, [hasData]);
// Save active file (for Cmd+S keyboard shortcut)
const handleSaveActiveFile = useCallback(() => {
if (activeFilePath) void saveEditedFile(activeFilePath);
}, [activeFilePath, saveEditedFile]);
if (activeFilePath) void saveEditedFile(activeFilePath, projectPath);
}, [activeFilePath, saveEditedFile, projectPath]);
// Continuous navigation options for cross-file hunk navigation
const continuousOptions = useMemo(
@ -268,7 +373,9 @@ export const ChangeReviewDialog = ({
handleHunkRejected,
() => onOpenChange(false),
handleSaveActiveFile,
continuousOptions
continuousOptions,
(filePath, fallbackSnippetsLength) =>
getFileHunkCount(filePath, fallbackSnippetsLength, fileChunkCounts)
);
// Load data on open
@ -342,6 +449,23 @@ export const ChangeReviewDialog = ({
});
}, [activeChangeSet, initialFilePath, scrollToFile]);
// Clear selection state on close (React-approved setState-during-render pattern)
const [prevOpen, setPrevOpen] = useState(open);
if (prevOpen !== open) {
setPrevOpen(open);
if (!open) {
setSelectionInfo(null);
}
}
// Cleanup refs/timers on close
useEffect(() => {
if (!open) {
activeSelectionFileRef.current = null;
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
}
}, [open]);
// Escape to close
useEffect(() => {
if (!open) return;
@ -379,53 +503,74 @@ export const ChangeReviewDialog = ({
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
// Don't intercept if focus is inside a CM editor — let CM handle its own undo
if (document.activeElement?.closest('.cm-editor')) return;
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyZ' && !e.shiftKey) {
// Don't intercept native undo in input/textarea
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
// Try to undo in the last focused CM editor
// Prefer bulk undo (Accept All / Reject All) shortly after bulk action,
// even if focus is inside a CM editor (focus often remains there after clicking buttons).
const now = Date.now();
const bulkRecently = now - lastBulkActionAtRef.current < 10_000;
if (bulkRecently && useStore.getState().reviewUndoStack.length > 0) {
e.preventDefault();
e.stopPropagation();
handleUndoBulk();
return;
}
// If the last action was a hunk keep/undo (accept/reject) and we're undoing immediately,
// we must also clear the persisted decision. Otherwise reopening the dialog will replay it.
if (document.activeElement?.closest('.cm-editor')) {
const lastView = lastFocusedEditorRef.current;
const fp = activeFilePathRef.current;
const stack = fp ? hunkDecisionUndoStackRef.current[fp] : undefined;
const lastAt = fp ? (lastHunkActionAtRef.current[fp] ?? 0) : 0;
const hunkRecently = fp ? now - lastAt < 5_000 : false;
if (fp && stack && stack.length > 0 && hunkRecently && lastView?.dom.isConnected) {
e.preventDefault();
e.stopPropagation();
undo(lastView);
const originalIndex = stack.pop()!;
clearHunkDecisionByOriginalIndex(fp, originalIndex);
return;
}
// Otherwise, let CM handle its own undo
return;
}
// Otherwise try to undo in the last focused CM editor
const lastView = lastFocusedEditorRef.current;
if (lastView?.dom.isConnected) {
e.preventDefault();
e.stopPropagation();
undo(lastView);
return;
}
// Fall back to bulk undo (Accept All / Reject All)
if (useStore.getState().reviewUndoStack.length > 0) {
e.preventDefault();
e.stopPropagation();
handleUndoBulk();
}
}
};
document.addEventListener('keydown', handler, true);
return () => document.removeEventListener('keydown', handler, true);
}, [open, handleUndoBulk]);
}, [open, handleUndoBulk, clearHunkDecisionByOriginalIndex]);
// Cmd+N IPC listener (forwarded from main process)
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
const fp = activeFilePathRef.current;
const view = fp ? editorViewMapRef.current.get(fp) : null;
if (view) {
rejectChunk(view);
requestAnimationFrame(() => {
if (isLastChunkInFile(view)) {
diffNav.goToNextFile();
} else {
goToNextChunk(view);
}
});
}
if (!fp) return;
const view = editorViewMapRef.current.get(fp);
if (!view) return;
const cursorPos = view.state.selection.main.head;
const idx = computeChunkIndexAtPos(view.state, cursorPos);
handleHunkRejected(fp, idx);
rejectChunk(view);
requestAnimationFrame(() => diffNav.goToNextHunk());
});
return cleanup ?? undefined;
}, [open, diffNav]);
}, [open, diffNav, handleHunkRejected]);
// Compute toolbar stats using actual CM chunk count (not snippet count)
const reviewStats = useMemo(() => {
@ -636,33 +781,55 @@ export const ChangeReviewDialog = ({
)}
</div>
{/* Continuous scroll diff content */}
<ContinuousScrollView
files={activeChangeSet.files}
fileContents={fileContents}
fileContentsLoading={fileContentsLoading}
viewedSet={viewedSet}
editedContents={editedContents}
hunkDecisions={hunkDecisions}
fileDecisions={fileDecisions}
collapseUnchanged={collapseUnchanged}
applying={applying}
autoViewed={autoViewed}
discardCounters={discardCounters}
onHunkAccepted={handleHunkAccepted}
onHunkRejected={handleHunkRejected}
onFullyViewed={handleFullyViewed}
onContentChanged={handleContentChanged}
onDiscard={handleDiscardFile}
onSave={handleSaveFile}
onVisibleFileChange={handleVisibleFileChange}
scrollContainerRef={scrollContainerRef}
editorViewMapRef={editorViewMapRef}
isProgrammaticScroll={isProgrammaticScroll}
teamName={teamName}
memberName={memberName}
fetchFileContent={fetchFileContent}
/>
{/* Continuous scroll diff content with selection menu */}
<div
ref={diffContentRef}
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
>
<ContinuousScrollView
files={activeChangeSet.files}
fileContents={fileContents}
fileContentsLoading={fileContentsLoading}
viewedSet={viewedSet}
editedContents={editedContents}
hunkDecisions={hunkDecisions}
fileDecisions={fileDecisions}
hunkContextHashesByFile={hunkContextHashesByFile}
collapseUnchanged={collapseUnchanged}
applying={applying}
autoViewed={autoViewed}
discardCounters={discardCounters}
onHunkAccepted={handleHunkAccepted}
onHunkRejected={handleHunkRejected}
onFullyViewed={handleFullyViewed}
onContentChanged={handleContentChanged}
onDiscard={handleDiscardFile}
onSave={handleSaveFile}
onRestoreMissingFile={handleRestoreMissingFile}
onVisibleFileChange={handleVisibleFileChange}
scrollContainerRef={scrollContainerRef}
editorViewMapRef={editorViewMapRef}
isProgrammaticScroll={isProgrammaticScroll}
teamName={teamName}
memberName={memberName}
fetchFileContent={fetchFileContent}
onSelectionChange={onEditorAction ? handleSelectionChange : undefined}
/>
{selectionInfo && onEditorAction && (
<EditorSelectionMenu
info={selectionInfo}
containerRect={containerRect}
onSendMessage={() => {
onEditorAction(buildSelectionAction('sendMessage', selectionInfo));
setSelectionInfo(null);
}}
onCreateTask={() => {
onEditorAction(buildSelectionAction('createTask', selectionInfo));
setSelectionInfo(null);
}}
/>
)}
</div>
</>
)}

View file

@ -9,6 +9,8 @@ import {
} from '@codemirror/merge';
import { ChangeSet, type ChangeSpec, EditorState, type StateEffect } from '@codemirror/state';
import { type EditorView } from '@codemirror/view';
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import { structuredPatch } from 'diff';
/**
* Teaches CM history to undo acceptChunk operations (updateOriginalDoc effects).
@ -40,15 +42,13 @@ export function acceptAllChunks(view: EditorView): boolean {
if (!result || result.chunks.length === 0) return false;
const orig = getOriginalDoc(view.state);
const specs: ChangeSpec[] = [];
for (const chunk of result.chunks) {
specs.push({
from: chunk.fromA,
to: chunk.toA,
insert: view.state.doc.sliceString(chunk.fromB, chunk.toB),
});
}
const changes = ChangeSet.of(specs, orig.length);
// More robust than per-chunk: merge chunk ranges can be inconsistent for "empty" originals
// (e.g. whitespace-only or reconstruction edge cases), which can throw RangeError.
// Accept-all semantics are simply: make original equal to current modified doc.
const changes = ChangeSet.of(
[{ from: 0, to: orig.length, insert: view.state.doc.toString() }],
orig.length
);
view.dispatch({
effects: updateOriginalDoc.of({ doc: changes.apply(orig), changes }),
});
@ -61,15 +61,11 @@ export function rejectAllChunks(view: EditorView): boolean {
if (!result || result.chunks.length === 0) return false;
const orig = getOriginalDoc(view.state);
const specs: ChangeSpec[] = [];
for (const chunk of result.chunks) {
specs.push({
from: chunk.fromB,
to: chunk.toB,
insert: orig.sliceString(chunk.fromA, chunk.toA),
});
}
view.dispatch({ changes: specs });
// Same robustness principle as acceptAllChunks: reject-all semantics are simply
// "restore the current doc to the original baseline" in one edit.
view.dispatch({
changes: [{ from: 0, to: view.state.doc.length, insert: orig.toString() }],
});
return true;
}
@ -130,4 +126,116 @@ export function replayHunkDecisions(
}
}
/**
* Replay persisted decisions, attempting to map original hunk indices to the current
* CodeMirror chunk indices using context hashes when available.
*
* Falls back to index-based replay when hashes are missing or ambiguous.
*/
export function replayHunkDecisionsSmart(
view: EditorView,
filePath: string,
hunkDecisions: Record<string, string>,
hunkContextHashes?: Record<number, string>
): void {
const result = getChunks(view.state);
if (!result || result.chunks.length === 0) return;
const chunkCount = result.chunks.length;
// Build current hunk hash -> indices map (only if we can build a patch that matches chunk count)
let hashToIndices: Map<string, number[]> | null = null;
if (hunkContextHashes && Object.keys(hunkContextHashes).length > 0) {
const original = getOriginalDoc(view.state).toString();
const modified = view.state.doc.toString();
const patch = structuredPatch('file', 'file', original, modified);
const hunks = patch.hunks ?? [];
if (hunks.length === chunkCount) {
hashToIndices = new Map<string, number[]>();
for (let i = 0; i < hunks.length; i++) {
const hunk = hunks[i];
const oldSideContent = hunk.lines
.filter((l) => !l.startsWith('+'))
.map((l) => l.slice(1))
.join('\n');
const newSideContent = hunk.lines
.filter((l) => !l.startsWith('-'))
.map((l) => l.slice(1))
.join('\n');
const hash = computeDiffContextHash(oldSideContent, newSideContent);
const arr = hashToIndices.get(hash);
if (arr) arr.push(i);
else hashToIndices.set(hash, [i]);
}
}
}
// Collect all decided indices from the decision map (don't assume contiguous 0..N)
const prefix = `${filePath}:`;
const decided: { mappedIndex: number; decision: 'accepted' | 'rejected' }[] = [];
const usedMapped = new Set<number>();
for (const [key, val] of Object.entries(hunkDecisions)) {
if (!key.startsWith(prefix)) continue;
if (val !== 'accepted' && val !== 'rejected') continue;
const raw = key.slice(prefix.length);
const origIndex = Number.parseInt(raw, 10);
if (Number.isNaN(origIndex)) continue;
let mappedIndex = origIndex;
const hash = hunkContextHashes?.[origIndex];
if (hash && hashToIndices) {
const candidates = hashToIndices.get(hash);
if (candidates?.length === 1) {
mappedIndex = candidates[0];
}
}
if (mappedIndex < 0 || mappedIndex >= chunkCount) continue;
if (usedMapped.has(mappedIndex)) continue;
usedMapped.add(mappedIndex);
decided.push({ mappedIndex, decision: val });
}
if (decided.length === 0) return;
// Replay from later to earlier indices so chunk removals don't shift earlier ones.
decided.sort((a, b) => b.mappedIndex - a.mappedIndex);
for (const { mappedIndex, decision } of decided) {
const currentChunks = getChunks(view.state);
if (!currentChunks || mappedIndex >= currentChunks.chunks.length) continue;
const chunk = currentChunks.chunks[mappedIndex];
if (decision === 'accepted') {
acceptChunk(view, chunk.fromB);
} else {
rejectChunk(view, chunk.fromB);
}
}
}
/**
* Compute the chunk index at a given position in the modified document (B-side).
* Returns the index of the chunk containing pos, or the nearest chunk when pos is outside.
*/
export function computeChunkIndexAtPos(state: EditorState, pos: number): number {
const chunks = getChunks(state);
if (!chunks || chunks.chunks.length === 0) return 0;
let nearestIndex = 0;
let nearestDist = Infinity;
for (let i = 0; i < chunks.chunks.length; i++) {
const chunk = chunks.chunks[i];
if (pos >= chunk.fromB && pos <= chunk.toB) return i;
const dist = Math.min(Math.abs(pos - chunk.fromB), Math.abs(pos - chunk.toB));
if (dist < nearestDist) {
nearestDist = dist;
nearestIndex = i;
}
}
return nearestIndex;
}
export { acceptChunk, getChunks, rejectChunk };

View file

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { indentUnit, syntaxHighlighting } from '@codemirror/language';
import { foldGutter, foldKeymap, indentUnit, syntaxHighlighting } from '@codemirror/language';
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
import { Compartment, EditorState, type Extension } from '@codemirror/state';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
@ -10,6 +10,7 @@ import {
getAsyncLanguageDesc,
getSyncLanguageExtension,
} from '@renderer/utils/codemirrorLanguages';
import { buildSelectionInfo } from '@renderer/utils/codemirrorSelectionInfo';
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
import {
@ -21,6 +22,8 @@ import {
} from './CodeMirrorDiffUtils';
import { portionCollapseExtension } from './portionCollapse';
import type { EditorSelectionInfo } from '@shared/types/editor';
interface CodeMirrorDiffViewProps {
original: string;
modified: string;
@ -46,6 +49,8 @@ interface CodeMirrorDiffViewProps {
usePortionCollapse?: boolean;
/** Lines per "Expand N" click (only with usePortionCollapse). Default: 100 */
portionSize?: number;
/** Called when text selection changes (for floating action menu) */
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
}
/** Compute hunk index for the chunk at a given position (B-side / modified doc).
@ -162,6 +167,17 @@ const diffSpecificTheme = EditorView.theme({
},
});
/** When original is empty (all additions), avoid showing a stray "deleted" block at the top. */
const emptyOriginalOverrideTheme = EditorView.theme({
'.cm-deletedChunk': {
backgroundColor: 'transparent !important',
paddingLeft: '0 !important',
},
'.cm-deletedLine': {
backgroundColor: 'transparent !important',
},
});
export const CodeMirrorDiffView = ({
original,
modified,
@ -180,6 +196,7 @@ export const CodeMirrorDiffView = ({
initialState,
usePortionCollapse = false,
portionSize = 100,
onSelectionChange,
}: CodeMirrorDiffViewProps): React.ReactElement => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
@ -192,14 +209,23 @@ export const CodeMirrorDiffView = ({
const onRejectRef = useRef(onHunkRejected);
const onContentChangedRef = useRef(onContentChanged);
const onViewChangeRef = useRef(onViewChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
onAcceptRef.current = onHunkAccepted;
onRejectRef.current = onHunkRejected;
onContentChangedRef.current = onContentChanged;
onViewChangeRef.current = onViewChange;
onSelectionChangeRef.current = onSelectionChange;
externalViewRefHolder.current = externalViewRef;
}, [onHunkAccepted, onHunkRejected, onContentChanged, onViewChange, externalViewRef]);
}, [
onHunkAccepted,
onHunkRejected,
onContentChanged,
onViewChange,
onSelectionChange,
externalViewRef,
]);
// Auto-scroll to next chunk after accept/reject (deferred to let CM recalculate)
const scrollToNextChunk = useCallback(() => {
@ -231,6 +257,13 @@ export const CodeMirrorDiffView = ({
syntaxHighlightDeletions: true,
};
// IMPORTANT: @codemirror/merge shows accept/reject buttons by default.
// When our UI chooses to hide merge controls (e.g. "Missing on disk" preview),
// explicitly disable them rather than relying on default behavior.
if (!showMergeControls) {
mergeConfig.mergeControls = false;
}
if (collapse && !usePortionCollapse) {
mergeConfig.collapseUnchanged = {
margin,
@ -355,11 +388,14 @@ export const CodeMirrorDiffView = ({
);
const buildExtensions = useCallback(() => {
const isEffectivelyEmptyOriginal = original.trim().length === 0;
const extensions: Extension[] = [
baseEditorTheme,
diffSpecificTheme,
...(isEffectivelyEmptyOriginal ? [emptyOriginalOverrideTheme] : []),
lineNumbers(),
syntaxHighlighting(oneDarkHighlightStyle),
foldGutter(),
EditorView.editable.of(!readOnly),
EditorState.readOnly.of(readOnly),
];
@ -370,7 +406,9 @@ export const CodeMirrorDiffView = ({
extensions.push(mergeUndoSupport);
extensions.push(mirrorEditsAfterResolve);
extensions.push(indentUnit.of(' '));
extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]));
extensions.push(
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...foldKeymap])
);
}
// Language placeholder — actual language injected async via compartment reconfigure
@ -391,6 +429,7 @@ export const CodeMirrorDiffView = ({
key: 'Ctrl-Alt-ArrowUp',
run: goToPreviousChunk,
},
...foldKeymap,
])
);
@ -408,6 +447,20 @@ export const CodeMirrorDiffView = ({
);
}
// Selection change listener (for floating action menu)
extensions.push(
EditorView.updateListener.of((update) => {
if (update.selectionSet || update.docChanged) {
const sel = update.state.selection.main;
if (sel.empty) {
onSelectionChangeRef.current?.(null);
} else {
onSelectionChangeRef.current?.(buildSelectionInfo(update.view, sel));
}
}
})
);
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
if (showMergeControls) {
// Helper: position a chunkButtons container so it's below the change block,
@ -566,7 +619,7 @@ export const CodeMirrorDiffView = ({
);
return extensions;
}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize]);
}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize, original]);
useEffect(() => {
if (!containerRef.current) return;

View file

@ -8,7 +8,7 @@ import {
acceptAllChunks,
getChunks,
rejectAllChunks,
replayHunkDecisions,
replayHunkDecisionsSmart,
} from './CodeMirrorDiffUtils';
import { FileSectionDiff } from './FileSectionDiff';
import { FileSectionHeader } from './FileSectionHeader';
@ -16,6 +16,7 @@ import { FileSectionPlaceholder } from './FileSectionPlaceholder';
import type { EditorView } from '@codemirror/view';
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
import type { EditorSelectionInfo } from '@shared/types/editor';
import type { FileChangeSummary } from '@shared/types/review';
interface ContinuousScrollViewProps {
@ -26,6 +27,7 @@ interface ContinuousScrollViewProps {
editedContents: Record<string, string>;
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile: Record<string, Record<number, string>>;
collapseUnchanged: boolean;
applying: boolean;
autoViewed: boolean;
@ -36,6 +38,7 @@ interface ContinuousScrollViewProps {
onContentChanged: (filePath: string, content: string) => void;
onDiscard: (filePath: string) => void;
onSave: (filePath: string) => void;
onRestoreMissingFile?: (filePath: string, content: string) => void;
onVisibleFileChange: (filePath: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>;
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
@ -47,6 +50,7 @@ interface ContinuousScrollViewProps {
memberName: string | undefined,
filePath: string
) => Promise<void>;
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
}
export const ContinuousScrollView = ({
@ -57,6 +61,7 @@ export const ContinuousScrollView = ({
editedContents,
hunkDecisions,
fileDecisions,
hunkContextHashesByFile,
collapseUnchanged,
applying,
autoViewed,
@ -67,6 +72,7 @@ export const ContinuousScrollView = ({
onContentChanged,
onDiscard,
onSave,
onRestoreMissingFile,
onVisibleFileChange,
scrollContainerRef,
editorViewMapRef,
@ -74,6 +80,7 @@ export const ContinuousScrollView = ({
teamName,
memberName,
fetchFileContent,
onSelectionChange,
}: ContinuousScrollViewProps): React.ReactElement => {
const setFileChunkCount = useStore((s) => s.setFileChunkCount);
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
@ -126,9 +133,11 @@ export const ContinuousScrollView = ({
// Refs to avoid stale closures — decisions change frequently
const fileDecisionsRef = useRef(fileDecisions);
const hunkDecisionsRef = useRef(hunkDecisions);
const hunkHashesRef = useRef(hunkContextHashesByFile);
useEffect(() => {
fileDecisionsRef.current = fileDecisions;
hunkDecisionsRef.current = hunkDecisions;
hunkHashesRef.current = hunkContextHashesByFile;
});
// Track which views have already had decisions replayed to prevent
@ -166,7 +175,12 @@ export const ContinuousScrollView = ({
} else {
// Replay individual per-hunk decisions persisted from previous session
requestAnimationFrame(() => {
replayHunkDecisions(view, filePath, hunkDecisionsRef.current);
replayHunkDecisionsSmart(
view,
filePath,
hunkDecisionsRef.current,
hunkHashesRef.current[filePath]
);
});
}
} else {
@ -210,6 +224,7 @@ export const ContinuousScrollView = ({
onToggleCollapse={handleToggleCollapse}
onDiscard={onDiscard}
onSave={onSave}
onRestoreMissingFile={onRestoreMissingFile}
/>
{!isCollapsed &&
@ -227,6 +242,7 @@ export const ContinuousScrollView = ({
discardCounter={discardCounters[filePath] ?? 0}
autoViewed={autoViewed}
isViewed={isViewed}
onSelectionChange={onSelectionChange}
/>
) : (
<FileSectionPlaceholder fileName={file.relativePath} />

View file

@ -7,6 +7,7 @@ import { ReviewDiffContent } from './ReviewDiffContent';
import type { EditorView } from '@codemirror/view';
import type { FileChangeWithContent } from '@shared/types';
import type { EditorSelectionInfo } from '@shared/types/editor';
import type { FileChangeSummary } from '@shared/types/review';
interface FileSectionDiffProps {
@ -22,6 +23,7 @@ interface FileSectionDiffProps {
discardCounter: number;
autoViewed: boolean;
isViewed: boolean;
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
}
export const FileSectionDiff = ({
@ -37,6 +39,7 @@ export const FileSectionDiff = ({
discardCounter,
autoViewed,
isViewed,
onSelectionChange,
}: FileSectionDiffProps): React.ReactElement => {
const localEditorViewRef = useRef<EditorView | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
@ -89,10 +92,17 @@ export const FileSectionDiff = ({
return writeSnippets[writeSnippets.length - 1].newString;
})();
const hasCodeMirrorContent =
fileContent && fileContent.contentSource !== 'unavailable' && resolvedModified !== null;
const resolvedOriginal = fileContent?.originalFullContent ?? null;
const isMissingOnDisk = fileContent?.contentSource === 'unavailable';
if (!hasCodeMirrorContent) {
// Show CodeMirror only when we have a trustworthy original baseline:
// - new files: original is legitimately empty
// - otherwise: original must be known (non-null). If original is unknown, do not
// pretend it's empty — fall back to snippet-level diff.
const canRenderCodeMirror =
resolvedModified !== null && (file.isNewFile || resolvedOriginal !== null);
if (!canRenderCodeMirror) {
return (
<div className="overflow-auto">
<ReviewDiffContent file={file} />
@ -101,20 +111,28 @@ export const FileSectionDiff = ({
);
}
const originalForDiff = file.isNewFile ? '' : (resolvedOriginal ?? '');
return (
<div className="overflow-auto">
{isMissingOnDisk && (
<div className="border-b border-border bg-red-500/10 px-4 py-2 text-xs text-red-200">
File is missing on disk. This diff may be only a preview from agent logs. Use{' '}
<span className="font-medium text-red-100">Restore</span> to create the file on disk.
</div>
)}
<DiffErrorBoundary
filePath={file.filePath}
oldString={fileContent.originalFullContent ?? ''}
oldString={originalForDiff}
newString={resolvedModified}
>
<CodeMirrorDiffView
key={`${file.filePath}:${discardCounter}`}
original={fileContent.originalFullContent ?? ''}
original={originalForDiff}
modified={resolvedModified}
fileName={file.relativePath}
readOnly={false}
showMergeControls={true}
showMergeControls={!isMissingOnDisk}
collapseUnchanged={collapseUnchanged}
usePortionCollapse={true}
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
@ -122,6 +140,11 @@ export const FileSectionDiff = ({
onContentChanged={(content) => onContentChanged(file.filePath, content)}
editorViewRef={localEditorViewRef}
onViewChange={handleViewChange}
onSelectionChange={
onSelectionChange
? (info) => onSelectionChange(info ? { ...info, filePath: file.filePath } : null)
: undefined
}
/>
</DiffErrorBoundary>
<div ref={sentinelRef} className="h-1 shrink-0" />

View file

@ -1,7 +1,8 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { ChevronDown, ChevronRight, Loader2, Save, Undo2 } from 'lucide-react';
import { shortcutLabel } from '@renderer/utils/platformKeys';
import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react';
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
import type { FileChangeSummary } from '@shared/types/review';
@ -11,7 +12,7 @@ const CONTENT_SOURCE_LABELS: Record<string, string> = {
'snippet-reconstruction': 'Reconstructed',
'disk-current': 'Current Disk',
'git-fallback': 'Git Fallback',
unavailable: 'Unavailable',
unavailable: 'Missing on disk',
};
interface FileSectionHeaderProps {
@ -24,6 +25,7 @@ interface FileSectionHeaderProps {
onToggleCollapse: (filePath: string) => void;
onDiscard: (filePath: string) => void;
onSave: (filePath: string) => void;
onRestoreMissingFile?: (filePath: string, content: string) => void;
}
export const FileSectionHeader = ({
@ -36,7 +38,21 @@ export const FileSectionHeader = ({
onToggleCollapse,
onDiscard,
onSave,
onRestoreMissingFile,
}: FileSectionHeaderProps): React.ReactElement => {
const isMissingOnDisk = fileContent?.contentSource === 'unavailable';
const restoreContent =
fileContent?.modifiedFullContent ??
(() => {
const writeSnippets = file.snippets.filter(
(s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update')
);
if (writeSnippets.length === 0) return null;
return writeSnippets[writeSnippets.length - 1].newString;
})();
const canRestore =
!!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent != null;
const handleHeaderClick = (e: React.MouseEvent): void => {
// Don't collapse when clicking action buttons
if ((e.target as HTMLElement).closest('[data-no-collapse]')) return;
@ -70,9 +86,44 @@ export const FileSectionHeader = ({
)}
{fileContent?.contentSource && (
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-text-muted">
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={[
'rounded px-1.5 py-0.5 text-[10px]',
fileContent.contentSource === 'unavailable'
? 'bg-red-500/20 text-red-300'
: 'bg-surface-raised text-text-muted',
].join(' ')}
>
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
{fileContent.contentSource === 'unavailable' ? (
<div className="space-y-1">
<div className="font-medium text-text">File is missing on disk</div>
<div className="text-text-muted">
We can still show a preview from agent logs, but your filesystem is out of sync.
</div>
{restoreContent != null ? (
<div className="text-text-muted">
Use <span className="font-medium text-text">Restore</span> to write the preview
content back to disk.
</div>
) : (
<div className="text-text-muted">
Full file content is not available to restore automatically.
</div>
)}
</div>
) : (
<span>
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
</span>
)}
</TooltipContent>
</Tooltip>
)}
{fileDecision && (
@ -90,6 +141,23 @@ export const FileSectionHeader = ({
)}
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
{canRestore && restoreContent != null && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onRestoreMissingFile?.(file.filePath, restoreContent)}
disabled={applying}
className="flex items-center gap-1 rounded bg-blue-500/15 px-2 py-1 text-xs text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
>
<FilePlus className="size-3" />
Restore
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
Create/restore this file on disk from the preview
</TooltipContent>
</Tooltip>
)}
{hasEdits && (
<>
<Tooltip>
@ -122,7 +190,7 @@ export const FileSectionHeader = ({
<TooltipContent side="bottom">
<span>Save file to disk</span>
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
{shortcutLabel('⌘ S', 'Ctrl+S')}
</kbd>
</TooltipContent>
</Tooltip>

View file

@ -1,5 +1,6 @@
import React from 'react';
import { IS_MAC } from '@renderer/utils/platformKeys';
import { X } from 'lucide-react';
interface KeyboardShortcutsHelpProps {
@ -7,16 +8,20 @@ interface KeyboardShortcutsHelpProps {
onOpenChange: (open: boolean) => void;
}
const mod = IS_MAC ? '\u2318' : 'Ctrl';
const alt = IS_MAC ? '\u2325' : 'Alt';
const shift = IS_MAC ? '\u21E7' : 'Shift';
const shortcuts = [
{ keys: ['\u2325+J'], action: 'Next change' },
{ keys: ['\u2325+K'], action: 'Previous change' },
{ keys: ['\u2325+\u2193'], action: 'Next file' },
{ keys: ['\u2325+\u2191'], action: 'Previous file' },
{ keys: ['\u2318+Y'], action: 'Accept change' },
{ keys: ['\u2318+N'], action: 'Reject change' },
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
{ keys: ['\u2318+Z'], action: 'Undo' },
{ keys: ['\u2318+\u21E7+Z'], action: 'Redo' },
{ keys: [`${alt}+J`], action: 'Next change' },
{ keys: [`${alt}+K`], action: 'Previous change' },
{ keys: [`${alt}+\u2193`], action: 'Next file' },
{ keys: [`${alt}+\u2191`], action: 'Previous file' },
{ keys: [`${mod}+Y`], action: 'Accept change' },
{ keys: [`${mod}+N`], action: 'Reject change' },
{ keys: [`${mod}+S`], action: 'Save file' },
{ keys: [`${mod}+Z`], action: 'Undo' },
{ keys: [`${mod}+${shift}+Z`], action: 'Redo' },
{ keys: ['?'], action: 'Toggle shortcuts' },
{ keys: ['Esc'], action: 'Close dialog' },
];

View file

@ -1,25 +1,80 @@
import { useMemo } from 'react';
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
import { diffLines } from 'diff';
import type { FileChangeSummary, SnippetDiff } from '@shared/types/review';
// =============================================================================
// Types
// =============================================================================
interface ReviewDiffContentProps {
file: FileChangeSummary;
}
const SnippetDiffView = ({ snippet, index }: { snippet: SnippetDiff; index: number }) => {
const diffResult = useMemo(() => {
if (snippet.type === 'write-new') {
// Весь файл — новый
return diffLines('', snippet.newString);
interface DiffLine {
type: 'added' | 'removed' | 'unchanged';
html: string;
}
// =============================================================================
// Helpers
// =============================================================================
/** Build highlighted diff lines by mapping diff parts onto pre-highlighted old/new lines. */
function buildHighlightedDiffLines(snippet: SnippetDiff, fileName: string): DiffLine[] {
const isFullNew = snippet.type === 'write-new' || snippet.type === 'write-update';
const oldCode = isFullNew ? '' : snippet.oldString;
const diffResult = diffLines(oldCode, snippet.newString);
const oldHighlighted = highlightLines(oldCode, fileName);
const newHighlighted = highlightLines(snippet.newString, fileName);
const result: DiffLine[] = [];
let oldIdx = 0;
let newIdx = 0;
for (const part of diffResult) {
const lineCount = part.value.replace(/\n$/, '').split('\n').length;
for (let i = 0; i < lineCount; i++) {
if (part.removed) {
result.push({
type: 'removed',
html: oldHighlighted[oldIdx++] ?? '',
});
} else if (part.added) {
result.push({
type: 'added',
html: newHighlighted[newIdx++] ?? '',
});
} else {
result.push({
type: 'unchanged',
html: oldHighlighted[oldIdx++] ?? '',
});
newIdx++;
}
}
if (snippet.type === 'write-update') {
// Полная перезапись
return diffLines('', snippet.newString);
}
return diffLines(snippet.oldString, snippet.newString);
}, [snippet]);
}
return result;
}
// =============================================================================
// SnippetDiffView
// =============================================================================
const SnippetDiffView = ({
snippet,
index,
fileName,
}: {
snippet: SnippetDiff;
index: number;
fileName: string;
}) => {
const lines = useMemo(() => buildHighlightedDiffLines(snippet, fileName), [snippet, fileName]);
const toolLabel =
snippet.type === 'write-new'
@ -32,7 +87,7 @@ const SnippetDiffView = ({ snippet, index }: { snippet: SnippetDiff; index: numb
return (
<div className="overflow-hidden rounded-lg border border-border">
{/* Заголовок snippet */}
{/* Snippet header */}
<div className="flex items-center justify-between border-b border-border bg-surface-raised px-3 py-1.5">
<span className="text-xs text-text-muted">
#{index + 1} {toolLabel}
@ -42,61 +97,54 @@ const SnippetDiffView = ({ snippet, index }: { snippet: SnippetDiff; index: numb
</span>
</div>
{/* Строки диффа */}
{/* Diff lines with syntax highlighting (hljs HTML — safe, all input is escaped) */}
<div className="overflow-x-auto font-mono text-xs leading-5">
{diffResult.map((part, i) => {
const lines = part.value.replace(/\n$/, '').split('\n');
return lines.map((line, j) => {
let bgClass = '';
let prefix = ' ';
let textClass = 'text-text-secondary';
{lines.map((line, i) => {
let bgClass = '';
let prefix = ' ';
if (part.added) {
bgClass = 'bg-[var(--diff-added-bg,rgba(46,160,67,0.15))]';
prefix = '+';
textClass = 'text-[var(--diff-added-text,#3fb950)]';
} else if (part.removed) {
bgClass = 'bg-[var(--diff-removed-bg,rgba(248,81,73,0.15))]';
prefix = '-';
textClass = 'text-[var(--diff-removed-text,#f85149)]';
}
if (line.type === 'added') {
bgClass = 'bg-[var(--diff-added-bg,rgba(46,160,67,0.15))]';
prefix = '+';
} else if (line.type === 'removed') {
bgClass = 'bg-[var(--diff-removed-bg,rgba(248,81,73,0.15))]';
prefix = '-';
}
return (
<div key={`${i}-${j}`} className={`px-3 ${bgClass} ${textClass}`}>
<span className="inline-block w-4 select-none text-text-muted opacity-50">
{prefix}
</span>
<span className="whitespace-pre">{line}</span>
</div>
);
});
return (
<div key={i} className={`flex px-3 ${bgClass}`}>
<span className="inline-block w-4 shrink-0 select-none text-text-muted opacity-50">
{prefix}
</span>
{/* highlight.js escapes all input text — only produces <span class="hljs-*"> tags */}
<span
className="whitespace-pre text-text-secondary"
dangerouslySetInnerHTML={{ __html: line.html }}
/>
</div>
);
})}
</div>
</div>
);
};
// =============================================================================
// ReviewDiffContent
// =============================================================================
export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => {
const nonErrorSnippets = useMemo(() => file.snippets.filter((s) => !s.isError), [file.snippets]);
return (
<div className="space-y-4 p-4">
{/* Заголовок файла */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">{file.relativePath}</span>
{file.isNewFile && (
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
NEW
</span>
)}
<span className="ml-auto text-xs text-text-muted">
{nonErrorSnippets.length} change{nonErrorSnippets.length !== 1 ? 's' : ''}
</span>
</div>
{/* Snippets */}
{nonErrorSnippets.map((snippet, index) => (
<SnippetDiffView key={snippet.toolUseId} snippet={snippet} index={index} />
<SnippetDiffView
key={snippet.toolUseId}
snippet={snippet}
index={index}
fileName={file.relativePath}
/>
))}
{nonErrorSnippets.length === 0 && (

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
@ -11,7 +12,6 @@ import {
Circle,
CircleDot,
Eye,
File,
Folder,
FolderOpen,
X as XIcon,
@ -139,7 +139,7 @@ const TreeItem = ({
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<FileStatusIcon status={status} />
<File className="size-3.5 shrink-0" />
<FileIcon fileName={node.name} className="size-3.5" />
{viewedSet && viewedSet.has(node.data.filePath) && (
<Tooltip>
<TooltipTrigger asChild>

View file

@ -281,29 +281,53 @@ const portionCollapseTheme = EditorView.theme({
// CM recognizes it's the same field → keeps accumulated state (expanded regions) → no create() call.
// Config is read from portionCollapseConfigFacet (controlled by compartment).
const portionCollapseField = StateField.define<DecorationSet>({
create(state: EditorState): DecorationSet {
interface PortionCollapseState {
decorations: DecorationSet;
/**
* Once the user expands everything (i.e. removes the last collapse widget),
* we should NOT re-initialize collapse on harmless transactions like cursor moves.
*/
userExpandedAll: boolean;
}
const portionCollapseField = StateField.define<PortionCollapseState>({
create(state: EditorState): PortionCollapseState {
const cfg = state.facet(portionCollapseConfigFacet);
return buildPortionRanges(state, cfg.margin, cfg.minSize, cfg.portionSize);
return {
decorations: buildPortionRanges(state, cfg.margin, cfg.minSize, cfg.portionSize),
userExpandedAll: false,
};
},
update(deco: DecorationSet, tr: Transaction): DecorationSet {
update(value: PortionCollapseState, tr: Transaction): PortionCollapseState {
const cfg = tr.state.facet(portionCollapseConfigFacet);
// 1. Expand effects
let result = deco;
let nextDeco = value.decorations;
let userExpandedAll = value.userExpandedAll;
let hasExpandEffect = false;
for (const effect of tr.effects) {
if (effect.is(expandPortion)) {
hasExpandEffect = true;
result = handleExpandPortion(result, effect.value, tr.state, cfg.minSize, cfg.portionSize);
nextDeco = handleExpandPortion(
nextDeco,
effect.value,
tr.state,
cfg.minSize,
cfg.portionSize
);
}
if (effect.is(expandAllAtPos)) {
hasExpandEffect = true;
result = handleExpandAll(result, effect.value);
nextDeco = handleExpandAll(nextDeco, effect.value);
}
}
if (hasExpandEffect) return result;
if (hasExpandEffect) {
if (nextDeco === Decoration.none) {
userExpandedAll = true;
}
return { decorations: nextDeco, userExpandedAll };
}
// 2. Accept chunk (updateOriginalDoc) — editor doc unchanged, keep decorations.
// Full rebuild here would destroy user's expanded state (Expand All / Expand N).
@ -311,27 +335,47 @@ const portionCollapseField = StateField.define<DecorationSet>({
// When mirrorEditsAfterResolve adds updateOriginalDoc to a docChanged transaction,
// we must NOT short-circuit — fall through to docChanged handler for proper rebuild.
if (tr.effects.some((e) => e.is(updateOriginalDoc)) && !tr.docChanged) {
return deco;
return value;
}
// 3. Document changed (reject, user edit) → full rebuild
// 3. Document changed (reject, user edit)
//
// Rebuilding from scratch here causes a bad UX: after the user expands (or when a hunk is
// applied) the editor can suddenly re-collapse unchanged regions, hiding the code the user
// was actively looking at. Instead, keep the user's current collapsed/expanded state stable
// by mapping existing decorations through the document changes.
if (tr.docChanged) {
return buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize);
const mapped = value.decorations.map(tr.changes);
// If we previously had no collapse decoration but chunks are now available, initialize once.
// BUT: if the user explicitly expanded everything, never re-collapse automatically.
if (!value.userExpandedAll && value.decorations === Decoration.none) {
const chunks = getChunks(tr.state);
if (chunks) {
return {
decorations: buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize),
userExpandedAll: false,
};
}
}
return { decorations: mapped, userExpandedAll: value.userExpandedAll };
}
// 4. Lazy init
if (deco === Decoration.none) {
if (!value.userExpandedAll && value.decorations === Decoration.none) {
const chunks = getChunks(tr.state);
if (chunks) {
return buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize);
return {
decorations: buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize),
userExpandedAll: false,
};
}
}
return deco;
return value;
},
provide(f) {
return EditorView.decorations.from(f);
return EditorView.decorations.from(f, (v) => v.decorations);
},
});

Some files were not shown because too many files have changed in this diff Show more