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:
parent
6aec33ae33
commit
80034542ec
39 changed files with 673 additions and 141 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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\`\`\``;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
94
test/renderer/utils/buildSelectionAction.test.ts
Normal file
94
test/renderer/utils/buildSelectionAction.test.ts
Normal 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`)');
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue