feat: enhance editor file handling and task management features

- Improved EditorFileWatcher to debounce content change events, optimizing git status cache invalidation for rapid file saves.
- Updated ProjectScanner to derive project and display names from actual paths, enhancing reliability in project identification.
- Enhanced IPC methods for creating tasks and sending messages directly from the editor context menu, streamlining team collaboration.
- Refactored task relationship handling to ensure accurate linking of tasks based on user actions.
- Introduced animations for new chat messages and comments, improving user experience in chat history and task comments sections.
This commit is contained in:
iliya 2026-03-02 20:08:03 +02:00
parent 6aec33ae33
commit 80034542ec
39 changed files with 673 additions and 141 deletions

View file

@ -336,11 +336,12 @@ async function handleEditorWatchDir(
if (enable) {
editorFileWatcher.start(activeProjectRoot, (event) => {
// Git invalidation can be expensive: invalidating on every "change" causes us to
// re-run git status repeatedly during editor activity or build bursts.
// Instead, invalidate only on structural changes (create/delete).
// 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

View file

@ -339,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) {
@ -371,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,
@ -392,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__');
@ -417,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

@ -72,7 +72,7 @@ export class EditorFileWatcher {
.filter((p): p is string => typeof p === 'string' && p.length > 0)
.filter((p) => isPathWithinRoot(p, this.projectRoot!));
normalized.sort();
normalized.sort((a, b) => a.localeCompare(b));
const key = normalized.join('\n');
if (key === this.watchedFilesKey) return;
this.watchedFilesKey = key;
@ -129,7 +129,7 @@ export class EditorFileWatcher {
.filter((p): p is string => typeof p === 'string' && p.length > 0)
.filter((p) => isPathWithinRoot(p, this.projectRoot!));
normalized.sort();
normalized.sort((a, b) => a.localeCompare(b));
const key = normalized.join('\n');
if (key === this.watchedDirsKey) return;
this.watchedDirsKey = key;

View file

@ -20,6 +20,7 @@ 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'];
// =============================================================================
@ -33,6 +34,7 @@ export class GitStatusService {
// Cache
private cachedResult: GitStatusResult | null = null;
private cacheTimestamp = 0;
private changeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Initialize service for a project root.
@ -51,6 +53,7 @@ export class GitStatusService {
* Reset service state.
*/
destroy(): void {
this.clearDebounceTimer();
this.git = null;
this.projectRoot = null;
this.cachedResult = null;
@ -58,13 +61,29 @@ export class GitStatusService {
}
/**
* 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.
@ -74,6 +93,12 @@ export class GitStatusService {
return { files: [], isGitRepo: false, branch: null };
}
// 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');
@ -89,7 +114,7 @@ export class GitStatusService {
const files = mapStatusResult(statusResult);
const branch = statusResult.current ?? null;
log.info(
`[perf] gitStatus: git=${gitMs.toFixed(1)}ms, files=${files.length}, branch=${branch}, untracked=off`
`[perf] gitStatus: git=${gitMs.toFixed(1)}ms, files=${files.length}, branch=${branch ?? 'detached'}, untracked=off`
);
const result: GitStatusResult = { files, isGitRepo: true, branch };
@ -108,6 +133,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

@ -309,7 +309,8 @@ export class CliInstallerService {
* 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, r: CliInstallationStatus): Promise<void> {
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 {
@ -321,10 +322,10 @@ export class CliInstallerService {
loggedIn?: boolean;
authMethod?: string;
};
r.authLoggedIn = auth.loggedIn === true;
r.authMethod = auth.authMethod ?? null;
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=${r.authLoggedIn}, method=${r.authMethod ?? 'null'}` +
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` +
(authAttempt > 1 ? ` (attempt ${authAttempt})` : '')
);
return;
@ -339,7 +340,7 @@ export class CliInstallerService {
logger.warn(
`Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${getErrorMessage(err)}`
);
r.authLoggedIn = false;
result.authLoggedIn = false; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
}
}
}
@ -360,16 +361,18 @@ export class CliInstallerService {
/**
* Fetch latest CLI version from GCS and update the result object.
*/
private async fetchLatestVersion(r: CliInstallationStatus): Promise<void> {
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

@ -375,6 +375,13 @@ 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}"`,
@ -462,7 +469,7 @@ function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string
const lines = activeTasks.map((t) => {
const desc = t.description ? `${t.description.slice(0, 120)}` : '';
const deps = t.blockedBy?.length
? ` [blocked by: ${t.blockedBy.map((id) => `#${id}`).join(', ')}]`
? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]`
: '';
return ` - #${t.id} [${t.status}] ${t.subject}${deps}${desc}`;
});
@ -480,7 +487,7 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
const desc = t.description ? `${t.description.slice(0, 120)}` : '';
const deps = t.blockedBy?.length
? ` [blocked by: ${t.blockedBy.map((id) => `#${id}`).join(', ')}]`
? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]`
: '';
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${deps}${desc}`;
});

View file

@ -112,9 +112,11 @@ export class TeamTaskWriter {
throw new Error('Cannot link a task to itself');
}
// For 'blocks', delegate as reverse blockedBy
// For 'blocks', delegate as reverse blockedBy (swap task/target intentionally)
if (type === 'blocks') {
return this.addRelationship(teamName, targetId, taskId, 'blockedBy');
const swappedTask = targetId;
const swappedTarget = taskId;
return this.addRelationship(teamName, swappedTask, swappedTarget, 'blockedBy');
}
const tasksDir = path.join(getTasksBasePath(), teamName);
@ -172,9 +174,11 @@ export class TeamTaskWriter {
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> {
// For 'blocks', delegate as reverse blockedBy
// For 'blocks', delegate as reverse blockedBy (swap task/target intentionally)
if (type === 'blocks') {
return this.removeRelationship(teamName, targetId, taskId, 'blockedBy');
const swappedTask = targetId;
const swappedTarget = taskId;
return this.removeRelationship(teamName, swappedTask, swappedTarget, 'blockedBy');
}
const tasksDir = path.join(getTasksBasePath(), teamName);

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

@ -135,8 +135,13 @@ const LocalImage = React.memo(function LocalImage({
});
/** Extract plain text from a hast (HTML AST) node tree */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- hast node shape varies
function hastToText(node: any): string {
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 '';
@ -278,7 +283,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
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)} />;
return <MermaidDiagram code={hastToText(codeEl as unknown as HastNode)} />;
}
}

View file

@ -159,6 +159,7 @@ 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 -- intentional ref access during render for animation tracking */
const wasShowingAll = wasShowingAllRef.current;
if (wasShowingAll && hiddenCount > 0) {
@ -209,6 +210,7 @@ export const ActivityTimeline = ({
}
return newKeys;
}, [visibleMessages, visibleCount]);
/* eslint-enable react-hooks/refs */
const handleShowMore = (): void => {
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);

View file

@ -145,15 +145,21 @@ function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
/** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */
function sanitizeTeamName(name: string): string {
return name
let result = name
.replace(/[^a-zA-Z0-9]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+/g, '')
.replace(/-+$/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;
}
const MEMBER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
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();
@ -171,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;
@ -223,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: {
@ -279,12 +285,26 @@ export const CreateTeamDialog = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [extendedContext, setExtendedContext] = useState(false);
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({});
@ -304,8 +324,6 @@ export const CreateTeamDialog = ({
setSelectedProjectPath('');
setCustomCwd('');
setLaunchTeam(true);
setSelectedModel('');
setExtendedContext(false);
setJsonEditorOpen(false);
setJsonText('');
setJsonError(null);

View file

@ -66,10 +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 [extendedContext, setExtendedContext] = useState(false);
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);
@ -79,8 +93,6 @@ export const LaunchTeamDialog = ({
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
setSelectedModel('');
setExtendedContext(false);
setClearContext(false);
};

View file

@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -30,7 +31,6 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { X } from 'lucide-react';
import { MarkdownViewer } from '../../chat/viewers/MarkdownViewer';
import { MemberBadge } from '../MemberBadge';
import type { InlineChip } from '@renderer/types/inlineChip';

View file

@ -1,10 +1,10 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useStore } from '@renderer/store';
@ -66,11 +66,20 @@ export const TaskCommentsSection = ({
// Reset state when task changes (React-approved setState-during-render pattern)
const resetKey = teamIdKey(teamName, taskId);
const [prevResetKey, setPrevResetKey] = useState(resetKey);
// --- New-comment animation tracking (refs only, useMemo is after visibleComments) ---
const knownCommentIdsRef = useRef<Set<string>>(new Set());
const isCommentsInitializedRef = useRef(false);
const prevVisibleCountRef = useRef(visibleCount);
/* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
if (prevResetKey !== resetKey) {
setPrevResetKey(resetKey);
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
setExpandedCommentIds(new Set());
setReplyTo(null);
knownCommentIdsRef.current.clear();
isCommentsInitializedRef.current = false;
}
const toggleCommentExpanded = useCallback((commentId: string) => {
@ -103,6 +112,46 @@ export const TaskCommentsSection = ({
[sortedComments, visibleCount]
);
const newCommentIds = useMemo(() => {
if (visibleComments.length === 0) {
knownCommentIdsRef.current.clear();
isCommentsInitializedRef.current = false;
return new Set<string>();
}
// First render: seed all known IDs, no animations
if (!isCommentsInitializedRef.current) {
isCommentsInitializedRef.current = true;
for (const c of visibleComments) {
knownCommentIdsRef.current.add(c.id);
}
prevVisibleCountRef.current = visibleCount;
return new Set<string>();
}
// Pagination expansion ("Show more"): add IDs silently, no animations
const isPaginationExpansion = visibleCount > prevVisibleCountRef.current;
prevVisibleCountRef.current = visibleCount;
if (isPaginationExpansion) {
for (const c of visibleComments) {
knownCommentIdsRef.current.add(c.id);
}
return new Set<string>();
}
// Normal update: unknown IDs are new comments
const newIds = new Set<string>();
for (const c of visibleComments) {
if (!knownCommentIdsRef.current.has(c.id)) {
newIds.add(c.id);
knownCommentIdsRef.current.add(c.id);
}
}
return newIds;
}, [visibleComments, visibleCount]);
/* eslint-enable react-hooks/refs */
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
@ -156,7 +205,7 @@ export const TaskCommentsSection = ({
{visibleComments.map((comment) => (
<div
key={comment.id}
className={`group rounded-md p-2.5 ${
className={`group rounded-md p-2.5 ${newCommentIds.has(comment.id) ? 'message-enter-animate' : ''} ${
comment.type === 'review_approved'
? 'bg-emerald-500/8 border border-emerald-500/15'
: comment.type === 'review_request'
@ -171,17 +220,7 @@ export const TaskCommentsSection = ({
{comment.type === 'review_request' && (
<MessageCircleWarning size={12} className="shrink-0 text-amber-400" />
)}
<span
className="font-medium"
style={{
color: (() => {
const rc = colorMap.get(comment.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
{comment.author}
</span>
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
{comment.type === 'review_approved' && (
<span className="rounded-full bg-emerald-500/15 px-1.5 py-px text-[9px] font-medium text-emerald-400">
Approved
@ -323,19 +362,9 @@ export const TaskCommentsSection = ({
{replyTo ? (
<div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2">
<div className="min-w-0 flex-1">
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to{' '}
<span
className="font-semibold"
style={{
color: (() => {
const rc = colorMap.get(replyTo.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
@{replyTo.author}
</span>
<div className="mb-0.5 flex items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to
<MemberBadge name={replyTo.author} color={colorMap.get(replyTo.author)} />
</div>
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
{replyTo.text}

View file

@ -35,10 +35,6 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
return relativePath.split('/');
}, [activeTabId, projectPath]);
if (segments.length === 0) return null;
const fileName = segments[segments.length - 1];
const handleSegmentClick = useCallback(
(segmentIndex: number): void => {
if (!projectPath) return;
@ -49,6 +45,10 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
[segments, projectPath, expandDirectory]
);
if (segments.length === 0) return null;
const fileName = segments[segments.length - 1];
return (
<div className="flex items-center gap-0.5 overflow-x-auto px-3 py-1 text-xs text-text-muted">
{segments.map((segment, idx) => {

View file

@ -9,7 +9,16 @@
import React, { useCallback, useRef, useState } from 'react';
import * as ContextMenu from '@radix-ui/react-context-menu';
import { ClipboardCopy, FilePlus, FolderOpen, FolderPlus, Pencil, Trash2 } from 'lucide-react';
import {
ClipboardCopy,
FilePlus,
FolderOpen,
FolderPlus,
ListTodo,
MessageSquare,
Pencil,
Trash2,
} from 'lucide-react';
// =============================================================================
// Types
@ -28,6 +37,10 @@ interface EditorContextMenuProps {
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;
}
// =============================================================================
@ -41,6 +54,8 @@ export const EditorContextMenu = ({
onNewFolder,
onDelete,
onRename,
onCreateTask,
onSendMessage,
}: EditorContextMenuProps): React.ReactElement => {
const [target, setTarget] = useState<TargetEntry | null>(null);
const triggerRef = useRef<HTMLDivElement>(null);
@ -164,6 +179,31 @@ export const EditorContextMenu = ({
</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>
</ContextMenu.Root>

View file

@ -47,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 {
@ -80,6 +84,8 @@ let fileTreeRenderCount = 0;
export const EditorFileTree = ({
selectedFilePath,
onFileSelect,
onCreateTask,
onSendMessage,
}: EditorFileTreeProps): React.ReactElement => {
fileTreeRenderCount++;
if (fileTreeRenderCount % 5 === 0) {
@ -432,6 +438,8 @@ export const EditorFileTree = ({
onNewFolder={handleNewFolder}
onDelete={handleDelete}
onRename={handleRename}
onCreateTask={onCreateTask}
onSendMessage={onSendMessage}
>
<DndContext
sensors={sensors}

View file

@ -33,12 +33,18 @@ export const EditorImagePreview = ({
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
let cancelled = false;
// 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)

View file

@ -34,10 +34,14 @@ export const MarkdownPreviewPane = React.memo(function MarkdownPreviewPane({
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) {
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
// Forward ref — the mutable cast is the standard pattern for forwarding refs
const mutableRef = scrollRef as React.MutableRefObject<HTMLDivElement | null>;
mutableRef.current = el;
}
},
[scrollRef]

View file

@ -19,7 +19,7 @@ import {
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,
@ -216,7 +216,7 @@ export const ProjectEditorOverlay = ({
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()}`
`[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${filePath.split('/').pop() ?? ''}`
);
setFileContent(result);
@ -608,7 +608,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>
)}

View file

@ -50,16 +50,22 @@ export const QuickOpenDialog = ({
// Load all project files via backend API (with module-level cache)
useEffect(() => {
let cancelled = false;
// Use cache if fresh and for the same project
const cached = projectPath ? getQuickOpenCache(projectPath) : null;
if (cached) {
setAllFiles(cached.files);
setLoading(false);
return;
// Defer setState to avoid cascading render within the same effect cycle
queueMicrotask(() => {
if (cancelled) return;
setAllFiles(cached.files);
setLoading(false);
});
return () => {
cancelled = true;
};
}
let cancelled = false;
const fetchFiles = async (): Promise<void> => {
try {
const files = await window.electronAPI.editor.listFiles();

View file

@ -207,7 +207,10 @@ export const ChangeReviewDialog = ({
(filePath: string, hunkIndex: number) => {
const originalIndex = setHunkDecision(filePath, hunkIndex, 'accepted');
lastHunkActionAtRef.current[filePath] = Date.now();
(hunkDecisionUndoStackRef.current[filePath] ??= []).push(originalIndex);
if (!hunkDecisionUndoStackRef.current[filePath]) {
hunkDecisionUndoStackRef.current[filePath] = [];
}
hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
},
[setHunkDecision]
);
@ -216,7 +219,10 @@ export const ChangeReviewDialog = ({
(filePath: string, hunkIndex: number) => {
const originalIndex = setHunkDecision(filePath, hunkIndex, 'rejected');
lastHunkActionAtRef.current[filePath] = Date.now();
(hunkDecisionUndoStackRef.current[filePath] ??= []).push(originalIndex);
if (!hunkDecisionUndoStackRef.current[filePath]) {
hunkDecisionUndoStackRef.current[filePath] = [];
}
hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
if (REVIEW_INSTANT_APPLY) {
void applySingleFileDecision(teamName, filePath, taskId, memberName);
}
@ -341,7 +347,7 @@ export const ChangeReviewDialog = ({
return () => observer.disconnect();
}, [hasData]);
// Save active file (for Cmd+Enter keyboard shortcut)
// Save active file (for Cmd+S keyboard shortcut)
const handleSaveActiveFile = useCallback(() => {
if (activeFilePath) void saveEditedFile(activeFilePath, projectPath);
}, [activeFilePath, saveEditedFile, projectPath]);

View file

@ -1,6 +1,7 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { shortcutLabel } from '@renderer/utils/platformKeys';
import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react';
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
@ -50,7 +51,7 @@ export const FileSectionHeader = ({
return writeSnippets[writeSnippets.length - 1].newString;
})();
const canRestore =
!!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent !== null;
!!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent != null;
const handleHeaderClick = (e: React.MouseEvent): void => {
// Don't collapse when clicking action buttons
@ -105,7 +106,7 @@ export const FileSectionHeader = ({
<div className="text-text-muted">
We can still show a preview from agent logs, but your filesystem is out of sync.
</div>
{restoreContent !== null ? (
{restoreContent != null ? (
<div className="text-text-muted">
Use <span className="font-medium text-text">Restore</span> to write the preview
content back to disk.
@ -140,7 +141,7 @@ export const FileSectionHeader = ({
)}
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
{canRestore && restoreContent !== null && (
{canRestore && restoreContent != null && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -189,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

@ -53,6 +53,19 @@ const chipPreviewTheme = EditorView.theme({
const MAX_PREVIEW_LINES = 12;
/** Simple tooltip for file-level mention chips (no code preview). */
const ChipFilePreview = ({ chip }: { chip: InlineChip }): React.JSX.Element => {
const displayPath = chip.displayPath ?? chip.filePath;
return (
<div className="max-w-md overflow-hidden rounded-md">
<div className="flex items-center gap-2 bg-[var(--code-bg,#1e1e2e)] px-2.5 py-2">
<span className="text-[11px] font-medium text-[var(--color-text)]">{chip.fileName}</span>
<span className="text-[10px] text-[var(--color-text-muted)]">{displayPath}</span>
</div>
</div>
);
};
const ChipCodePreview = ({ chip }: { chip: InlineChip }): React.JSX.Element => {
const containerRef = React.useRef<HTMLDivElement>(null);
const allLines = chip.codeText.split('\n');
@ -61,8 +74,8 @@ const ChipCodePreview = ({ chip }: { chip: InlineChip }): React.JSX.Element => {
const label = chipDisplayLabel(chip);
const lineRef =
chip.fromLine === chip.toLine
? `line ${chip.fromLine}`
: `lines ${chip.fromLine}-${chip.toLine}`;
? `line ${String(chip.fromLine)}`
: `lines ${String(chip.fromLine)}-${String(chip.toLine)}`;
React.useEffect(() => {
const container = containerRef.current;
@ -168,7 +181,11 @@ export const ChipInteractionLayer = ({
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md p-0">
<ChipCodePreview chip={pos.chip} />
{pos.chip.fromLine == null ? (
<ChipFilePreview chip={pos.chip} />
) : (
<ChipCodePreview chip={pos.chip} />
)}
</TooltipContent>
</Tooltip>
))}

View file

@ -23,18 +23,19 @@ const DEBOUNCE_MS = 500;
function isValidChipArray(data: unknown): data is InlineChip[] {
if (!Array.isArray(data)) return false;
return data.every(
(item) =>
typeof item === 'object' &&
item !== null &&
return data.every((raw) => {
if (typeof raw !== 'object' || raw === null) return false;
const item = raw as Record<string, unknown>;
return (
typeof item.id === 'string' &&
typeof item.filePath === 'string' &&
typeof item.fileName === 'string' &&
typeof item.fromLine === 'number' &&
typeof item.toLine === 'number' &&
(typeof item.fromLine === 'number' || item.fromLine === null) &&
(typeof item.toLine === 'number' || item.toLine === null) &&
typeof item.codeText === 'string' &&
typeof item.language === 'string'
);
);
});
}
export function useChipDraftPersistence(key: string): UseChipDraftResult {
@ -43,6 +44,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<InlineChip[] | null>(null);
const keyRef = useRef(key);
// eslint-disable-next-line react-hooks/refs -- sync ref with prop for stable callbacks
keyRef.current = key;
// Load on mount

View file

@ -319,8 +319,8 @@ export function useDiffNavigation(
return;
}
// Cmd+Enter -> save file
if (isMeta && key === 'Enter') {
// Cmd+S -> save file
if (isMeta && key === 's' && !event.shiftKey) {
event.preventDefault();
onSaveFileRef.current?.();
return;

View file

@ -227,6 +227,7 @@ export function useEditorKeyboardShortcuts({
// Store all deps in a ref so the keydown handler has a stable identity
const depsRef = useRef<EditorKeyHandlerDeps>(null!);
// eslint-disable-next-line react-hooks/refs -- sync ref with deps for stable keydown handler
depsRef.current = {
activeTabId,
openTabs,

View file

@ -587,6 +587,21 @@ body {
animation: message-enter 300ms ease-out both;
}
@keyframes chat-message-enter {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chat-message-enter-animate {
animation: chat-message-enter 350ms ease-out both;
}
.skeleton-card {
animation: skeleton-fade-in 0.4s ease-out both;
position: relative;

View file

@ -51,8 +51,6 @@ const SAVE_COOLDOWN_MS = 2000;
*/
let gitStatusThrottleTimer: ReturnType<typeof setTimeout> | null = null;
const GIT_STATUS_THROTTLE_MS = 1500;
const gitStatusChangeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const GIT_STATUS_CHANGE_DEBOUNCE_MS = 6000;
const dirRefreshDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
const DIR_REFRESH_DEBOUNCE_MS = 350;
@ -81,7 +79,7 @@ function scheduleSyncWatchedFiles(get: () => AppState): void {
if (!projectPath) return;
const filePaths = state.editorOpenTabs.map((t) => t.filePath).filter(Boolean);
filePaths.sort();
filePaths.sort((a, b) => a.localeCompare(b));
const key = `${projectPath}\n${filePaths.join('\n')}`;
if (key === lastWatchedFilesKey) return;
lastWatchedFilesKey = key;
@ -107,7 +105,7 @@ function scheduleSyncWatchedDirs(get: () => AppState): void {
// Always include root (depth=0), plus expanded folders (depth=0).
// Cap to protect chokidar from too many watched paths if user expands a lot.
const dirs = [projectPath, ...expanded].slice(0, MAX_WATCHED_DIRS);
dirs.sort();
dirs.sort((a, b) => a.localeCompare(b));
const key = `${projectPath}\n${dirs.join('\n')}`;
if (key === lastWatchedDirsKey) return;
lastWatchedDirsKey = key;
@ -468,7 +466,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
},
expandDirectory: async (dirPath: string) => {
const { editorExpandedDirs, editorFileTree } = get();
const { editorExpandedDirs } = get();
// Skip set() if already expanded — prevents unnecessary re-render
const wasExpanded = !!editorExpandedDirs[dirPath];
@ -1240,7 +1238,7 @@ async function refreshDirectory(
const t0 = performance.now();
const result = await api.editor.readDir(dirPath);
log.info(
`[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${dirPath.split('/').pop()}`
`[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${dirPath.split('/').pop() ?? ''}`
);
const currentTree = get().editorFileTree;
if (!currentTree) return;

View file

@ -17,14 +17,16 @@ export interface InlineChip {
filePath: string;
/** Basename (e.g. "auth.ts") */
fileName: string;
/** 1-based start line */
fromLine: number;
/** 1-based end line */
toLine: number;
/** Selected source code text */
/** 1-based start line, or null for file-level mentions */
fromLine: number | null;
/** 1-based end line, or null for file-level mentions */
toLine: number | null;
/** Selected source code text (empty for file mentions) */
codeText: string;
/** Language identifier (e.g. "typescript", "python") */
language: string;
/** Relative display path for file-level mentions */
displayPath?: string;
}
// =============================================================================
@ -39,9 +41,13 @@ export const CHIP_MARKER = '\u{1F4C4}'; // 📄
// =============================================================================
/**
* Display label for a chip: "auth.ts:10-15" or "auth.ts:42" for single-line.
* Display label for a chip: "auth.ts:10-15", "auth.ts:42" for single-line,
* or just "auth.ts" for file-level mentions.
*/
export function chipDisplayLabel(chip: InlineChip): string {
if (chip.fromLine == null || chip.toLine == null) {
return chip.fileName;
}
if (chip.fromLine === chip.toLine) {
return `${chip.fileName}:${chip.fromLine}`;
}
@ -57,12 +63,20 @@ export function chipToken(chip: InlineChip): string {
}
/**
* Converts a chip to a markdown code fence block.
* Converts a chip to markdown: code fence for code chips, file reference for file mentions.
*/
export function chipToMarkdown(chip: InlineChip): string {
const label = chipDisplayLabel(chip);
// File-level mention — no code fence
if (chip.fromLine == null || chip.toLine == null) {
const path = chip.displayPath ?? chip.filePath;
return `**${chip.fileName}** (\`${path}\`)`;
}
const lang = chip.language || getCodeFenceLanguage(chip.fileName);
return `**${chip.fileName}** (${chip.fromLine === chip.toLine ? `line ${chip.fromLine}` : `lines ${chip.fromLine}-${chip.toLine}`}):\n\`\`\`${lang}\n${chip.codeText}\n\`\`\``;
const lineRef =
chip.fromLine === chip.toLine
? `line ${chip.fromLine}`
: `lines ${chip.fromLine}-${chip.toLine}`;
return `**${chip.fileName}** (${lineRef}):\n\`\`\`${lang}\n${chip.codeText}\n\`\`\``;
}
/**

View file

@ -7,4 +7,10 @@ export interface MentionSuggestion {
subtitle?: string;
/** Color name from TeamColorSet palette */
color?: string;
/** Suggestion type — 'member' (default) or 'file' */
type?: 'member' | 'file';
/** Absolute file path (file suggestions only) */
filePath?: string;
/** Relative display path (file suggestions only) */
relativePath?: string;
}

View file

@ -57,6 +57,31 @@ export function getCodeFenceLanguage(fileName: string): string {
return CODE_FENCE_LANG[ext] ?? '';
}
/**
* Builds a file-mention action (no code selection, just the file reference).
* Used when triggering "Create Task" / "Write Teammate" from the file tree context menu.
*/
export function buildFileAction(
type: EditorSelectionAction['type'],
filePath: string,
projectPath?: string | null
): EditorSelectionAction {
const fileName = filePath.split('/').pop() ?? 'file';
const displayPath =
projectPath && filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1)
: filePath;
return {
type,
filePath,
fromLine: null,
toLine: null,
selectedText: '',
formattedContext: `**${fileName}** (\`${displayPath}\`)`,
displayPath,
};
}
/** Builds a selection action with a formatted markdown code fence context. */
export function buildSelectionAction(
type: EditorSelectionAction['type'],

View file

@ -22,6 +22,29 @@ export function createChipFromSelection(
action: EditorSelectionAction,
existingChips: InlineChip[]
): InlineChip | null {
const isFileMention = !action.selectedText || action.fromLine == null || action.toLine == null;
if (isFileMention) {
// File-level mention: deduplicate by filePath + null lines
const isDuplicate = existingChips.some(
(c) => c.filePath === action.filePath && c.fromLine == null
);
if (isDuplicate) return null;
const fileName = action.filePath.split('/').pop() ?? 'file';
return {
id: `chip-${++chipCounter}-${Date.now()}`,
filePath: action.filePath,
fileName,
fromLine: null,
toLine: null,
codeText: '',
language: getCodeFenceLanguage(fileName),
displayPath: action.displayPath,
};
}
// Code selection chip
const isDuplicate = existingChips.some(
(c) =>
c.filePath === action.filePath && c.fromLine === action.fromLine && c.toLine === action.toLine
@ -165,7 +188,6 @@ export function calculateChipPositions(
mirror.style.textTransform = cs.textTransform;
mirror.style.tabSize = cs.tabSize;
mirror.style.whiteSpace = cs.whiteSpace;
mirror.style.wordWrap = cs.wordWrap;
mirror.style.overflowWrap = cs.overflowWrap;
mirror.style.paddingTop = cs.paddingTop;
mirror.style.paddingRight = cs.paddingRight;

View file

@ -240,9 +240,13 @@ export interface EditorSelectionInfo {
export interface EditorSelectionAction {
type: 'sendMessage' | 'createTask';
filePath: string;
fromLine: number;
toLine: number;
/** 1-based start line, or null for file-level mentions (no code selection) */
fromLine: number | null;
/** 1-based end line, or null for file-level mentions (no code selection) */
toLine: number | null;
selectedText: string;
/** Pre-formatted context block (markdown code fence) */
/** Pre-formatted context block (markdown code fence or file reference) */
formattedContext: string;
/** Relative display path for file-level mentions */
displayPath?: string;
}

View file

@ -38,6 +38,11 @@ describe('chipDisplayLabel', () => {
const chip = makeChip({ fileName: 'index.tsx', fromLine: 1, toLine: 3 });
expect(chipDisplayLabel(chip)).toBe('index.tsx:1-3');
});
it('returns just fileName for file-level mention (null lines)', () => {
const chip = makeChip({ fromLine: null, toLine: null, codeText: '' });
expect(chipDisplayLabel(chip)).toBe('auth.ts');
});
});
describe('chipToken', () => {
@ -50,6 +55,11 @@ describe('chipToken', () => {
const chip = makeChip({ fromLine: 42, toLine: 42 });
expect(chipToken(chip)).toBe(`${CHIP_MARKER}auth.ts:42`);
});
it('omits line range for file-level mention', () => {
const chip = makeChip({ fromLine: null, toLine: null, codeText: '' });
expect(chipToken(chip)).toBe(`${CHIP_MARKER}auth.ts`);
});
});
describe('chipToMarkdown', () => {
@ -83,6 +93,24 @@ describe('chipToMarkdown', () => {
const chip = makeChip({ language: 'python', fileName: 'script.py' });
expect(chipToMarkdown(chip)).toContain('```python');
});
it('produces file reference for file-level mention', () => {
const chip = makeChip({
fromLine: null,
toLine: null,
codeText: '',
displayPath: 'src/auth.ts',
});
const md = chipToMarkdown(chip);
expect(md).toBe('**auth.ts** (`src/auth.ts`)');
expect(md).not.toContain('```');
});
it('falls back to filePath when displayPath is missing', () => {
const chip = makeChip({ fromLine: null, toLine: null, codeText: '' });
const md = chipToMarkdown(chip);
expect(md).toBe('**auth.ts** (`/src/auth.ts`)');
});
});
describe('serializeChipsWithText', () => {
@ -119,4 +147,17 @@ describe('serializeChipsWithText', () => {
expect(result).toContain('Before ');
expect(result).toContain(' after');
});
it('serializes file-mention chip as file reference', () => {
const chip = makeChip({
fromLine: null,
toLine: null,
codeText: '',
displayPath: 'src/auth.ts',
});
const text = `Check ${chipToken(chip)} please`;
const result = serializeChipsWithText(text, [chip]);
expect(result).toBe('Check **auth.ts** (`src/auth.ts`) please');
expect(result).not.toContain(CHIP_MARKER);
});
});

View file

@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import {
buildFileAction,
buildSelectionAction,
getCodeFenceLanguage,
} from '@renderer/utils/buildSelectionAction';
import type { EditorSelectionInfo } from '@shared/types/editor';
describe('getCodeFenceLanguage', () => {
it('maps known extensions', () => {
expect(getCodeFenceLanguage('app.ts')).toBe('typescript');
expect(getCodeFenceLanguage('index.tsx')).toBe('tsx');
expect(getCodeFenceLanguage('main.py')).toBe('python');
expect(getCodeFenceLanguage('styles.css')).toBe('css');
});
it('returns empty string for unknown extension', () => {
expect(getCodeFenceLanguage('data.xyz')).toBe('');
expect(getCodeFenceLanguage('file')).toBe('');
});
});
describe('buildSelectionAction', () => {
const info: EditorSelectionInfo = {
text: 'const x = 1;',
filePath: '/project/src/auth.ts',
fromLine: 10,
toLine: 15,
screenRect: { top: 0, right: 0, bottom: 0 },
};
it('builds action with correct type and file info', () => {
const action = buildSelectionAction('createTask', info);
expect(action.type).toBe('createTask');
expect(action.filePath).toBe('/project/src/auth.ts');
expect(action.fromLine).toBe(10);
expect(action.toLine).toBe(15);
expect(action.selectedText).toBe('const x = 1;');
});
it('formats context with line range', () => {
const action = buildSelectionAction('sendMessage', info);
expect(action.formattedContext).toContain('**auth.ts**');
expect(action.formattedContext).toContain('lines 10-15');
expect(action.formattedContext).toContain('```typescript');
expect(action.formattedContext).toContain('const x = 1;');
});
it('uses singular "line" for single-line selection', () => {
const singleLine: EditorSelectionInfo = { ...info, fromLine: 42, toLine: 42 };
const action = buildSelectionAction('createTask', singleLine);
expect(action.formattedContext).toContain('line 42');
expect(action.formattedContext).not.toContain('lines');
});
});
describe('buildFileAction', () => {
it('builds action with null lines, empty selectedText, and displayPath', () => {
const action = buildFileAction('createTask', '/project/src/auth.ts', '/project');
expect(action.type).toBe('createTask');
expect(action.filePath).toBe('/project/src/auth.ts');
expect(action.fromLine).toBeNull();
expect(action.toLine).toBeNull();
expect(action.selectedText).toBe('');
expect(action.displayPath).toBe('src/auth.ts');
});
it('uses relative path when inside projectPath', () => {
const action = buildFileAction('sendMessage', '/project/src/utils/auth.ts', '/project');
expect(action.formattedContext).toBe('**auth.ts** (`src/utils/auth.ts`)');
});
it('uses absolute path when projectPath is null', () => {
const action = buildFileAction('sendMessage', '/project/src/auth.ts', null);
expect(action.formattedContext).toBe('**auth.ts** (`/project/src/auth.ts`)');
});
it('uses absolute path when projectPath is undefined', () => {
const action = buildFileAction('createTask', '/project/src/auth.ts');
expect(action.formattedContext).toBe('**auth.ts** (`/project/src/auth.ts`)');
});
it('uses absolute path when file is outside project', () => {
const action = buildFileAction('sendMessage', '/other/config.json', '/project');
expect(action.formattedContext).toBe('**config.json** (`/other/config.json`)');
});
it('handles file at project root', () => {
const action = buildFileAction('createTask', '/project/package.json', '/project');
expect(action.formattedContext).toBe('**package.json** (`package.json`)');
});
});

View file

@ -63,6 +63,35 @@ describe('createChipFromSelection', () => {
const action = makeAction({ fromLine: 10, toLine: 15 });
expect(createChipFromSelection(action, [existing])).not.toBeNull();
});
it('creates a file-mention chip when selectedText is empty and lines are null', () => {
const action = makeAction({
selectedText: '',
fromLine: null,
toLine: null,
displayPath: 'src/auth.ts',
});
const chip = createChipFromSelection(action, []);
expect(chip).not.toBeNull();
expect(chip!.fromLine).toBeNull();
expect(chip!.toLine).toBeNull();
expect(chip!.codeText).toBe('');
expect(chip!.displayPath).toBe('src/auth.ts');
expect(chip!.fileName).toBe('auth.ts');
});
it('deduplicates file-mention chips by filePath', () => {
const existing = makeChip({ fromLine: null, toLine: null, codeText: '' });
const action = makeAction({ selectedText: '', fromLine: null, toLine: null });
expect(createChipFromSelection(action, [existing])).toBeNull();
});
it('creates file-mention chip when fromLine is null', () => {
const action = makeAction({ fromLine: null, selectedText: 'code' });
const chip = createChipFromSelection(action, []);
expect(chip).not.toBeNull();
expect(chip!.fromLine).toBeNull();
});
});
describe('findChipBoundary', () => {