diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts index e125fe2c..6447d4bb 100644 --- a/src/main/ipc/editor.ts +++ b/src/main/ipc/editor.ts @@ -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 diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 98b3bef9..4d2e9109 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -339,7 +339,8 @@ export class ProjectScanner { // Group sessions by cwd const cwdGroups = new Map(); - 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({ diff --git a/src/main/services/editor/EditorFileWatcher.ts b/src/main/services/editor/EditorFileWatcher.ts index ae0c7a2b..fbb9c306 100644 --- a/src/main/services/editor/EditorFileWatcher.ts +++ b/src/main/services/editor/EditorFileWatcher.ts @@ -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; diff --git a/src/main/services/editor/GitStatusService.ts b/src/main/services/editor/GitStatusService.ts index d0eb5a52..3b65b017 100644 --- a/src/main/services/editor/GitStatusService.ts +++ b/src/main/services/editor/GitStatusService.ts @@ -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 | 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; + } + } } // ============================================================================= diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 7d89e324..04a66b13 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -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 { + + private async checkAuthStatus(binaryPath: string, result: CliInstallationStatus): Promise { const doCheck = async (): Promise => { 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 { + private async fetchLatestVersion(result: CliInstallationStatus): Promise { 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) { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 7e5a75ea..27df5d34 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 "" --notify --from "${leadName}"`, `- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner --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}`; }); diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index e96d81cf..8dee48d6 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -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 { - // 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); diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 93b30be2..243a635e 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -224,6 +224,46 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { return map; }, [conversation]); + // --- New-item animation tracking --- + const knownGroupIdsRef = useRef>(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(); + } + + // 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(); + } + + // Subsequent updates: detect new items + const newIds = new Set(); + 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} diff --git a/src/renderer/components/chat/ChatHistoryItem.tsx b/src/renderer/components/chat/ChatHistoryItem.tsx index 8e049138..60238de8 100644 --- a/src/renderer/components/chat/ChatHistoryItem.tsx +++ b/src/renderer/components/chat/ChatHistoryItem.tsx @@ -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 (
@@ -88,7 +93,7 @@ const ChatHistoryItemInner = ({ return (
@@ -110,7 +115,7 @@ const ChatHistoryItemInner = ({ return (
; + return isNew ? ( +
+ +
+ ) : ( + + ); default: return null; } diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 745bc528..1c730df2 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -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)?.className; if (Array.isArray(cls) && cls.some((c) => String(c) === 'language-mermaid')) { - return ; + return ; } } diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 88df02d8..08b10417 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -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); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index a321b6a4..b0dc3852 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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(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); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index a8604b4e..83c8b338 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -66,10 +66,24 @@ export const LaunchTeamDialog = ({ const [prepareMessage, setPrepareMessage] = useState(null); const [prepareWarnings, setPrepareWarnings] = useState([]); 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); }; diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index ad35b64b..4897eec1 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -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'; diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index a978c96f..055df8ee 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -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>(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(); + } + + // 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(); + } + + // 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(); + } + + // Normal update: unknown IDs are new comments + const newIds = new Set(); + 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( () => members.map((m) => ({ @@ -156,7 +205,7 @@ export const TaskCommentsSection = ({ {visibleComments.map((comment) => (
)} - { - const rc = colorMap.get(comment.author); - return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)'; - })(), - }} - > - {comment.author} - + {comment.type === 'review_approved' && ( Approved @@ -323,19 +362,9 @@ export const TaskCommentsSection = ({ {replyTo ? (
-
- Replying to{' '} - { - const rc = colorMap.get(replyTo.author); - return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)'; - })(), - }} - > - @{replyTo.author} - +
+ Replying to +
{replyTo.text} diff --git a/src/renderer/components/team/editor/EditorBreadcrumb.tsx b/src/renderer/components/team/editor/EditorBreadcrumb.tsx index 104994ae..d3735a55 100644 --- a/src/renderer/components/team/editor/EditorBreadcrumb.tsx +++ b/src/renderer/components/team/editor/EditorBreadcrumb.tsx @@ -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 (
{segments.map((segment, idx) => { diff --git a/src/renderer/components/team/editor/EditorContextMenu.tsx b/src/renderer/components/team/editor/EditorContextMenu.tsx index 8c956daf..4f18608f 100644 --- a/src/renderer/components/team/editor/EditorContextMenu.tsx +++ b/src/renderer/components/team/editor/EditorContextMenu.tsx @@ -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(null); const triggerRef = useRef(null); @@ -164,6 +179,31 @@ export const EditorContextMenu = ({ )} + + {/* Team actions — file only */} + {target && !target.isDir && (onCreateTask || onSendMessage) && ( + <> + + {onSendMessage && ( + onSendMessage(target.path)} + > + + Write Teammate + + )} + {onCreateTask && ( + onCreateTask(target.path)} + > + + Create Task + + )} + + )} diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx index 6ed744c5..0e7deb60 100644 --- a/src/renderer/components/team/editor/EditorFileTree.tsx +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -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} > (null); const imgRef = useRef(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) diff --git a/src/renderer/components/team/editor/MarkdownPreviewPane.tsx b/src/renderer/components/team/editor/MarkdownPreviewPane.tsx index 4de17436..ea871ba2 100644 --- a/src/renderer/components/team/editor/MarkdownPreviewPane.tsx +++ b/src/renderer/components/team/editor/MarkdownPreviewPane.tsx @@ -34,10 +34,14 @@ export const MarkdownPreviewPane = React.memo(function MarkdownPreviewPane({ baseDir, }: MarkdownPreviewPaneProps): React.ReactElement { // Callback ref to wire scrollRef (RefObject) to the div + const internalRef = React.useRef(null); const setRef = React.useCallback( (el: HTMLDivElement | null) => { + internalRef.current = el; if (scrollRef && 'current' in scrollRef) { - (scrollRef as React.MutableRefObject).current = el; + // Forward ref — the mutable cast is the standard pattern for forwarding refs + const mutableRef = scrollRef as React.MutableRefObject; + mutableRef.current = el; } }, [scrollRef] diff --git a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx index b979fc77..c77c1b43 100644 --- a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +++ b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx @@ -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 = ({
- + + onEditorAction(buildFileAction('createTask', filePath, projectPath)) + : undefined + } + onSendMessage={ + onEditorAction + ? (filePath: string) => + onEditorAction(buildFileAction('sendMessage', filePath, projectPath)) + : undefined + } + />
)} diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx index 4ad9a6c3..fec5986d 100644 --- a/src/renderer/components/team/editor/QuickOpenDialog.tsx +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -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 => { try { const files = await window.electronAPI.editor.listFiles(); diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index cfa9e521..89c3f149 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -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]); diff --git a/src/renderer/components/team/review/FileSectionHeader.tsx b/src/renderer/components/team/review/FileSectionHeader.tsx index 4de28ddc..318ab17b 100644 --- a/src/renderer/components/team/review/FileSectionHeader.tsx +++ b/src/renderer/components/team/review/FileSectionHeader.tsx @@ -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 = ({
We can still show a preview from agent logs, but your filesystem is out of sync.
- {restoreContent !== null ? ( + {restoreContent != null ? (
Use Restore to write the preview content back to disk. @@ -140,7 +141,7 @@ export const FileSectionHeader = ({ )}
- {canRestore && restoreContent !== null && ( + {canRestore && restoreContent != null && (
- + {pos.chip.fromLine == null ? ( + + ) : ( + + )} ))} diff --git a/src/renderer/hooks/useChipDraftPersistence.ts b/src/renderer/hooks/useChipDraftPersistence.ts index f6e155f6..16f16ad8 100644 --- a/src/renderer/hooks/useChipDraftPersistence.ts +++ b/src/renderer/hooks/useChipDraftPersistence.ts @@ -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; + 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 | null>(null); const pendingRef = useRef(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 diff --git a/src/renderer/hooks/useDiffNavigation.ts b/src/renderer/hooks/useDiffNavigation.ts index a761d858..8c2f46b1 100644 --- a/src/renderer/hooks/useDiffNavigation.ts +++ b/src/renderer/hooks/useDiffNavigation.ts @@ -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; diff --git a/src/renderer/hooks/useEditorKeyboardShortcuts.ts b/src/renderer/hooks/useEditorKeyboardShortcuts.ts index 97ffe083..f4ecf217 100644 --- a/src/renderer/hooks/useEditorKeyboardShortcuts.ts +++ b/src/renderer/hooks/useEditorKeyboardShortcuts.ts @@ -227,6 +227,7 @@ export function useEditorKeyboardShortcuts({ // Store all deps in a ref so the keydown handler has a stable identity const depsRef = useRef(null!); + // eslint-disable-next-line react-hooks/refs -- sync ref with deps for stable keydown handler depsRef.current = { activeTabId, openTabs, diff --git a/src/renderer/index.css b/src/renderer/index.css index c3d7d5d5..9a9a9bf1 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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; diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts index be3a2999..156a08bd 100644 --- a/src/renderer/store/slices/editorSlice.ts +++ b/src/renderer/store/slices/editorSlice.ts @@ -51,8 +51,6 @@ const SAVE_COOLDOWN_MS = 2000; */ let gitStatusThrottleTimer: ReturnType | null = null; const GIT_STATUS_THROTTLE_MS = 1500; -const gitStatusChangeDebounceTimer: ReturnType | null = null; -const GIT_STATUS_CHANGE_DEBOUNCE_MS = 6000; const dirRefreshDebounceTimers = new Map>(); 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 = (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; diff --git a/src/renderer/types/inlineChip.ts b/src/renderer/types/inlineChip.ts index 84836156..74d15c7b 100644 --- a/src/renderer/types/inlineChip.ts +++ b/src/renderer/types/inlineChip.ts @@ -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\`\`\``; } /** diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index bc1599cb..27ee8f82 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -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; } diff --git a/src/renderer/utils/buildSelectionAction.ts b/src/renderer/utils/buildSelectionAction.ts index fc4adedc..b2833527 100644 --- a/src/renderer/utils/buildSelectionAction.ts +++ b/src/renderer/utils/buildSelectionAction.ts @@ -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'], diff --git a/src/renderer/utils/chipUtils.ts b/src/renderer/utils/chipUtils.ts index ec3e69cd..e4012c46 100644 --- a/src/renderer/utils/chipUtils.ts +++ b/src/renderer/utils/chipUtils.ts @@ -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; diff --git a/src/shared/types/editor.ts b/src/shared/types/editor.ts index 704329ec..9e3ab46d 100644 --- a/src/shared/types/editor.ts +++ b/src/shared/types/editor.ts @@ -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; } diff --git a/test/renderer/types/inlineChip.test.ts b/test/renderer/types/inlineChip.test.ts index 6707a278..26df025b 100644 --- a/test/renderer/types/inlineChip.test.ts +++ b/test/renderer/types/inlineChip.test.ts @@ -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); + }); }); diff --git a/test/renderer/utils/buildSelectionAction.test.ts b/test/renderer/utils/buildSelectionAction.test.ts new file mode 100644 index 00000000..70a13811 --- /dev/null +++ b/test/renderer/utils/buildSelectionAction.test.ts @@ -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`)'); + }); +}); diff --git a/test/renderer/utils/chipUtils.test.ts b/test/renderer/utils/chipUtils.test.ts index a2b9a4ad..6e55be71 100644 --- a/test/renderer/utils/chipUtils.test.ts +++ b/test/renderer/utils/chipUtils.test.ts @@ -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', () => {