feat: enhance task management features and improve messaging components

- Updated README to include new 'Solo mode' feature for single-agent task management.
- Refactored message handling in TeamDataService and TeamProvisioningService to improve deduplication of lead messages.
- Enhanced linkification in chat components to support team mentions.
- Introduced AnimatedHeightReveal for smoother task item animations in the sidebar.
- Improved task comment input to support chip draft persistence and team suggestions.
- Cleaned up CSS by removing unused animations related to task item entry.
This commit is contained in:
iliya 2026-03-10 21:54:53 +02:00
parent 4214427b38
commit d6a0f4c3a1
26 changed files with 600 additions and 211 deletions

View file

@ -25,13 +25,14 @@
A new approach to task management with AI agent teams. A new approach to task management with AI agent teams.
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel - **Assemble your team** — create agent teams with different roles that work autonomously in parallel
- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments - **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments
- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams - **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own - **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment - **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task - **Full tool visibility** — inspect exactly which tools an agent used to complete each task
- **Live process section** — see which agents are running processes and open URLs directly in the browser - **Live process section** — see which agents are running processes and open URLs directly in the browser
- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work - **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
- **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime
<details> <details>
<summary><strong>More features</strong></summary> <summary><strong>More features</strong></summary>
@ -39,7 +40,6 @@ A new approach to task management with AI agent teams.
<br /> <br />
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses - **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
- **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime
- **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks - **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks
- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size. - **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place - **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place

View file

@ -429,13 +429,21 @@ async function handleGetData(
} }
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
const getLeadThoughtFingerprint = (msg: {
from: string;
text: string;
leadSessionId?: string;
}): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
// Collect text fingerprints from ALL non-live messages (inbox, lead_session, sentMessages) // Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
// so we can dedup lead_process live messages against them. // repeated thought in a new session does not get collapsed into an old session's history.
const existingTextFingerprints = new Set<string>(); const existingTextFingerprints = new Set<string>();
for (const msg of data.messages) { for (const msg of data.messages) {
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
existingTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); if (!isLeadThoughtLike(msg)) continue;
existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
} }
const keyFor = (m: { const keyFor = (m: {
@ -450,20 +458,20 @@ async function handleGetData(
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
}; };
// Text-based fingerprints for lead_process messages to catch duplicates // Text-based fingerprints for live lead thoughts to catch duplicates with different
// with different messageIds (e.g. lead-turn-* vs lead-sendmsg-* with same text) // messageIds inside the same session (e.g. lead-turn-* re-emits).
const leadProcessTextFingerprints = new Set<string>(); const leadProcessTextFingerprints = new Set<string>();
const merged: typeof data.messages = []; const merged: typeof data.messages = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const msg of [...data.messages, ...live]) { for (const msg of [...data.messages, ...live]) {
if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) { if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`; const fp = getLeadThoughtFingerprint(msg);
// Skip if same text already exists from any source (inbox, lead_session, etc.) // Skip if the same thought already exists in persisted history for the same session.
if (existingTextFingerprints.has(fp)) { if (existingTextFingerprints.has(fp)) {
continue; continue;
} }
// Dedup lead_process messages with same text but different messageIds // Dedup live lead_process thoughts with the same text in the same session.
if (leadProcessTextFingerprints.has(fp)) { if (leadProcessTextFingerprints.has(fp)) {
continue; continue;
} }

View file

@ -319,18 +319,21 @@ export class TeamDataService {
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session. // Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL. // This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
// Exception: lead_process messages with `to` field are captured SendMessage — never dedup those. // Exception: lead_process messages with `to` field are captured SendMessage — never dedup those.
if (leadTexts.length > 0 && sentMessages.length > 0) { if (leadTexts.length > 0) {
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const getLeadThoughtFingerprint = (
msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>
) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
const leadSessionFingerprints = new Set<string>(); const leadSessionFingerprints = new Set<string>();
for (const msg of leadTexts) { for (const msg of leadTexts) {
if (msg.source !== 'lead_session') continue; if (msg.source !== 'lead_session') continue;
leadSessionFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); leadSessionFingerprints.add(getLeadThoughtFingerprint(msg));
} }
messages = messages.filter((m) => { messages = messages.filter((m) => {
if (m.source !== 'lead_process') return true; if (m.source !== 'lead_process') return true;
// Captured SendMessage messages (with recipient) are real messages — never dedup // Captured SendMessage messages (with recipient) are real messages — never dedup
if (m.to) return true; if (m.to) return true;
const fp = `${m.from}\0${normalizeText(m.text ?? '')}`; const fp = getLeadThoughtFingerprint(m);
return !leadSessionFingerprints.has(fp); return !leadSessionFingerprints.has(fp);
}); });
} }
@ -1195,39 +1198,70 @@ export class TeamDataService {
}); });
} }
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> { private getLeadProjectDirCandidates(projectPath: string): string[] {
if (!config.leadSessionId || !config.projectPath) { const projectId = encodePath(projectPath);
return [];
}
const projectId = encodePath(config.projectPath);
const baseDir = extractBaseDir(projectId); const baseDir = extractBaseDir(projectId);
let jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`); const candidateDirs = [
path.join(getProjectsBasePath(), baseDir),
try {
await fs.promises.access(jsonlPath, fs.constants.F_OK);
} catch {
// Claude Code encodes underscores as hyphens in project directory names; // Claude Code encodes underscores as hyphens in project directory names;
// our encodePath only handles slashes. Try the underscore-to-hyphen variant. // our encodePath only handles slashes. Try the underscore-to-hyphen variant.
const altBaseDir = baseDir.replace(/_/g, '-'); ...(baseDir.includes('_')
if (altBaseDir !== baseDir) { ? [path.join(getProjectsBasePath(), baseDir.replace(/_/g, '-'))]
const altPath = path.join( : []),
getProjectsBasePath(), ];
altBaseDir,
`${config.leadSessionId}.jsonl` return [...new Set(candidateDirs)];
); }
try {
await fs.promises.access(altPath, fs.constants.F_OK); private async getLeadSessionJsonlPaths(projectPath: string): Promise<Map<string, string>> {
jsonlPath = altPath; const jsonlPaths = new Map<string, string>();
} catch { for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) {
return []; let entries: fs.Dirent[];
} try {
} else { entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
return []; } catch {
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
if (!sessionId || jsonlPaths.has(sessionId)) continue;
jsonlPaths.set(sessionId, path.join(dirPath, entry.name));
} }
} }
const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead'; return jsonlPaths;
}
private getRecentLeadSessionIds(config: TeamConfig): string[] {
const sessionIds: string[] = [];
const seen = new Set<string>();
const pushSessionId = (value: unknown): void => {
if (typeof value !== 'string') return;
const sessionId = value.trim();
if (!sessionId || seen.has(sessionId)) return;
seen.add(sessionId);
sessionIds.push(sessionId);
};
pushSessionId(config.leadSessionId);
if (Array.isArray(config.sessionHistory)) {
for (let i = config.sessionHistory.length - 1; i >= 0; i--) {
pushSessionId(config.sessionHistory[i]);
}
}
return sessionIds;
}
private async extractLeadSessionTextsFromJsonl(
jsonlPath: string,
leadName: string,
leadSessionId: string,
maxTexts: number
): Promise<InboxMessage[]> {
if (maxTexts <= 0) return [];
// Optimization: read from the end of the JSONL file (we only need the last N texts). // Optimization: read from the end of the JSONL file (we only need the last N texts).
// The full file can be huge; scanning from the start causes long stalls on Windows. // The full file can be huge; scanning from the start causes long stalls on Windows.
@ -1242,7 +1276,7 @@ export class TeamDataService {
const fileSize = stat.size; const fileSize = stat.size;
let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize); let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize);
while (textsReversed.length < MAX_LEAD_TEXTS && scanBytes <= MAX_SCAN_BYTES) { while (textsReversed.length < maxTexts && scanBytes <= MAX_SCAN_BYTES) {
const start = Math.max(0, fileSize - scanBytes); const start = Math.max(0, fileSize - scanBytes);
const buffer = Buffer.alloc(scanBytes); const buffer = Buffer.alloc(scanBytes);
await handle.read(buffer, 0, scanBytes, start); await handle.read(buffer, 0, scanBytes, start);
@ -1314,13 +1348,22 @@ export class TeamDataService {
const toolCalls = toolCallsList.length > 0 ? toolCallsList : undefined; const toolCalls = toolCallsList.length > 0 ? toolCallsList : undefined;
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined; const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
// Stable messageId: timestamp + text prefix (survives tail-scan range changes) const entryUuid = typeof msg.uuid === 'string' ? msg.uuid.trim() : '';
const assistantMessageId = typeof message.id === 'string' ? message.id.trim() : '';
const stableMessageId = entryUuid
? `lead-thought-${entryUuid}`
: assistantMessageId
? `lead-thought-msg-${assistantMessageId}`
: null;
// Fallback messageId: timestamp + text prefix (survives tail-scan range changes)
const textPrefix = combined const textPrefix = combined
.slice(0, 50) .slice(0, 50)
.replace(/[^\p{L}\p{N}]/gu, '') .replace(/[^\p{L}\p{N}]/gu, '')
.slice(0, 20); .slice(0, 20);
const messageId = `lead-session-${timestamp}-${textPrefix}`; const messageId =
stableMessageId ?? `lead-session-${leadSessionId}-${timestamp}-${textPrefix}`;
if (seenMessageIds.has(messageId)) continue; if (seenMessageIds.has(messageId)) continue;
seenMessageIds.add(messageId); seenMessageIds.add(messageId);
@ -1330,15 +1373,15 @@ export class TeamDataService {
timestamp, timestamp,
read: true, read: true,
source: 'lead_session', source: 'lead_session',
leadSessionId: config.leadSessionId, leadSessionId,
messageId, messageId,
toolSummary, toolSummary,
toolCalls, toolCalls,
}); });
if (textsReversed.length >= MAX_LEAD_TEXTS) break; if (textsReversed.length >= maxTexts) break;
} }
if (textsReversed.length >= MAX_LEAD_TEXTS) break; if (textsReversed.length >= maxTexts) break;
if (scanBytes === fileSize) break; if (scanBytes === fileSize) break;
scanBytes = Math.min(fileSize, scanBytes * 2); scanBytes = Math.min(fileSize, scanBytes * 2);
} }
@ -1349,6 +1392,42 @@ export class TeamDataService {
// Convert back to chronological order (old behavior) and keep the last N texts. // Convert back to chronological order (old behavior) and keep the last N texts.
textsReversed.reverse(); textsReversed.reverse();
const texts = textsReversed; const texts = textsReversed;
return texts.length > maxTexts ? texts.slice(-maxTexts) : texts;
}
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
if (!config.projectPath) {
return [];
}
const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead';
const sessionIds = this.getRecentLeadSessionIds(config);
if (sessionIds.length === 0) {
return [];
}
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(config.projectPath);
if (availableJsonlPaths.size === 0) {
return [];
}
const texts: InboxMessage[] = [];
for (const sessionId of sessionIds) {
if (texts.length >= MAX_LEAD_TEXTS) break;
const jsonlPath = availableJsonlPaths.get(sessionId);
if (!jsonlPath) continue;
const remaining = MAX_LEAD_TEXTS - texts.length;
const sessionTexts = await this.extractLeadSessionTextsFromJsonl(
jsonlPath,
leadName,
sessionId,
remaining
);
if (sessionTexts.length > 0) {
texts.push(...sessionTexts);
}
}
texts.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
return texts.length > MAX_LEAD_TEXTS ? texts.slice(-MAX_LEAD_TEXTS) : texts; return texts.length > MAX_LEAD_TEXTS ? texts.slice(-MAX_LEAD_TEXTS) : texts;
} }

View file

@ -557,7 +557,12 @@ function buildPersistentLeadContext(opts: {
`\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` + `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
`\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
`\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` + `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` + `\n - TASK BOARD FIRST (MANDATORY): Do NOT do substantial work silently or off-board.` +
`\n - Before you start meaningful implementation, debugging, research, review, or follow-up work, make sure there is a visible team-board task for it and that task is assigned to you.` +
`\n - If the user asks for new work, your first move is to create/update the relevant board task(s), then start work from those tasks.` +
`\n - If scope changes mid-task, update the existing task or create a follow-up task before continuing.` +
`\n - If you notice you already began meaningful work without a task, stop, put it on the board, then continue.` +
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed, but keep the board as the source of truth.` +
`\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` + `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
`\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` + `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
`\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` + `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
@ -741,6 +746,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
const step3Block = isSolo const step3Block = isSolo
? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` + ? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` +
` - Prefer fewer, broader tasks over many micro-tasks.\n` + ` - Prefer fewer, broader tasks over many micro-tasks.\n` +
` - Every substantial item that may be worked later must exist on the board; do NOT keep implicit/off-board work.\n` +
` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` + ` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` +
` - The tasks will be executed after the team is launched separately.` ` - The tasks will be executed after the team is launched separately.`
: `3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board. : `3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
@ -844,6 +850,7 @@ function buildLaunchPrompt(
- Execute tasks sequentially and keep the board + user updated: - Execute tasks sequentially and keep the board + user updated:
- Identify the next READY task (pending, not blocked by incomplete dependencies). - Identify the next READY task (pending, not blocked by incomplete dependencies).
- If the task is unassigned, set yourself ("${leadName}") as owner. - If the task is unassigned, set yourself ("${leadName}") as owner.
- If the work you are about to do is not represented on the board yet, create/update the task first before continuing.
- BEFORE doing any work on a task: mark it started (in_progress). - BEFORE doing any work on a task: mark it started (in_progress).
- Immediately SendMessage "user" that you started task #<id> (what you're doing + next step). - Immediately SendMessage "user" that you started task #<id> (what you're doing + next step).
- While working: after each meaningful milestone/decision/blocker, add a task comment on #<id>. If the milestone is user-relevant, also SendMessage "user". - While working: after each meaningful milestone/decision/blocker, add a task comment on #<id>. If the milestone is user-relevant, also SendMessage "user".
@ -3646,10 +3653,29 @@ export class TeamProvisioningService {
* Used for both pre-ready (provisioning) and post-ready assistant text. * Used for both pre-ready (provisioning) and post-ready assistant text.
* Emits a coalesced `lead-message` event for renderer refresh. * Emits a coalesced `lead-message` event for renderer refresh.
*/ */
private pushLiveLeadTextMessage(run: ProvisioningRun, cleanText: string): void { private getStableLeadThoughtMessageId(msg: Record<string, unknown>): string | null {
const entryUuid = typeof msg.uuid === 'string' ? msg.uuid.trim() : '';
if (entryUuid) {
return `lead-thought-${entryUuid}`;
}
const message = (msg.message ?? msg) as Record<string, unknown>;
const assistantMessageId = typeof message.id === 'string' ? message.id.trim() : '';
if (assistantMessageId) {
return `lead-thought-msg-${assistantMessageId}`;
}
return null;
}
private pushLiveLeadTextMessage(
run: ProvisioningRun,
cleanText: string,
stableMessageId?: string
): void {
run.leadMsgSeq += 1; run.leadMsgSeq += 1;
const leadName = this.getRunLeadName(run); const leadName = this.getRunLeadName(run);
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`; const messageId = stableMessageId || `lead-turn-${run.runId}-${run.leadMsgSeq}`;
// Attach accumulated tool call details from preceding tool_use messages, then reset. // Attach accumulated tool call details from preceding tool_use messages, then reset.
const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined; const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined; const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
@ -3778,7 +3804,11 @@ export class TeamProvisioningService {
) { ) {
const cleanText = stripAgentBlocks(text).trim(); const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length > 0) { if (cleanText.length > 0) {
this.pushLiveLeadTextMessage(run, cleanText); this.pushLiveLeadTextMessage(
run,
cleanText,
this.getStableLeadThoughtMessageId(msg) ?? undefined
);
} }
} }
} else { } else {
@ -3787,7 +3817,11 @@ export class TeamProvisioningService {
if (!run.silentUserDmForward && !hasCapturedSendMessage) { if (!run.silentUserDmForward && !hasCapturedSendMessage) {
const cleanText = stripAgentBlocks(text).trim(); const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length > 0) { if (cleanText.length > 0) {
this.pushLiveLeadTextMessage(run, cleanText); this.pushLiveLeadTextMessage(
run,
cleanText,
this.getStableLeadThoughtMessageId(msg) ?? undefined
);
} }
} }
} }

View file

@ -9,7 +9,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -394,6 +394,13 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
[members] [members]
); );
// Get team names for @team linkification
const teams = useStore((s) => s.teams);
const teamNames = useMemo(
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
[teams]
);
// Get search state for highlighting // Get search state for highlighting
const { searchQuery, searchMatches, currentSearchIndex } = useStore( const { searchQuery, searchMatches, currentSearchIndex } = useStore(
useShallow((s) => ({ useShallow((s) => ({
@ -490,8 +497,8 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
// Pre-process: convert @memberName to mention:// markdown links // Pre-process: convert @memberName to mention:// markdown links
const displayText = useMemo( const displayText = useMemo(
() => linkifyMentionsInMarkdown(baseDisplayText, memberColorMap), () => linkifyAllMentionsInMarkdown(baseDisplayText, memberColorMap, teamNames),
[baseDisplayText, memberColorMap] [baseDisplayText, memberColorMap, teamNames]
); );
return ( return (

View file

@ -13,7 +13,7 @@ import { useStore } from '@renderer/store';
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
import { formatTokensCompact } from '@renderer/utils/formatters'; import { formatTokensCompact } from '@renderer/utils/formatters';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -91,6 +91,13 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
[members] [members]
); );
// Get team names for @team linkification
const teams = useStore((s) => s.teams);
const teamNames = useMemo(
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
[teams]
);
// Detect operational noise // Detect operational noise
const noiseLabel = useMemo( const noiseLabel = useMemo(
() => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId), () => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
@ -114,8 +121,8 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
const displayContent = useMemo(() => { const displayContent = useMemo(() => {
const stripped = stripAgentBlocks(teammateMessage.content); const stripped = stripAgentBlocks(teammateMessage.content);
return linkifyMentionsInMarkdown(stripped, memberColorMap); return linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames);
}, [teammateMessage.content, memberColorMap]); }, [teammateMessage.content, memberColorMap, teamNames]);
// Noise: minimal inline row (no card, no expand) // Noise: minimal inline row (no card, no expand)
if (noiseLabel) { if (noiseLabel) {

View file

@ -246,31 +246,6 @@ function createViewerMarkdownComponents(
} }
return badge; return badge;
} }
if (href?.startsWith('team://')) {
let teamName = '';
try {
teamName = decodeURIComponent(href.slice('team://'.length));
} catch {
// malformed percent-encoding
}
return (
<span
className="inline-flex items-center gap-0.5"
style={{
backgroundColor: 'rgba(168, 85, 247, 0.15)',
color: '#c084fc',
borderRadius: '3px',
boxShadow: '0 0 0 1.5px rgba(168, 85, 247, 0.15)',
fontSize: 'inherit',
cursor: 'default',
}}
title={teamName ? `Team: ${teamName}` : undefined}
>
<UsersRound size={11} className="shrink-0" style={{ opacity: 0.8 }} />
{children}
</span>
);
}
if (href?.startsWith('task://')) { if (href?.startsWith('task://')) {
const taskId = href.slice('task://'.length); const taskId = href.slice('task://'.length);
return ( return (

View file

@ -19,7 +19,7 @@ interface TabConfig {
const tabs: TabConfig[] = [ const tabs: TabConfig[] = [
{ id: 'general', label: 'General', icon: Settings }, { id: 'general', label: 'General', icon: Settings },
{ id: 'connection', label: 'Connection', icon: Server, electronOnly: true }, // { id: 'connection', label: 'Connection', icon: Server, electronOnly: true },
{ id: 'notifications', label: 'Notifications', icon: Bell }, { id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'advanced', label: 'Advanced', icon: Wrench }, { id: 'advanced', label: 'Advanced', icon: Wrench },
]; ];

View file

@ -28,6 +28,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal';
import { Combobox, type ComboboxOption } from '../ui/combobox'; import { Combobox, type ComboboxOption } from '../ui/combobox';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
@ -513,15 +514,16 @@ export const GlobalTaskList = ({
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)} onDelete={() => handleDeleteTask(task.teamName, task.id)}
> >
<SidebarTaskItem <AnimatedHeightReveal animate={isNewTask(task)}>
task={task} <SidebarTaskItem
showTeamName task={task}
isNew={isNewTask(task)} showTeamName
renamingKey={renamingTaskKey} renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete} onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel} onRenameCancel={handleRenameCancel}
getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)} getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)}
/> />
</AnimatedHeightReveal>
</TaskContextMenu> </TaskContextMenu>
))} ))}
</div> </div>
@ -611,15 +613,16 @@ export const GlobalTaskList = ({
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)} onDelete={() => handleDeleteTask(task.teamName, task.id)}
> >
<SidebarTaskItem <AnimatedHeightReveal animate={isNewTask(task)}>
task={task} <SidebarTaskItem
showTeamName task={task}
isNew={isNewTask(task)} showTeamName
renamingKey={renamingTaskKey} renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete} onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel} onRenameCancel={handleRenameCancel}
getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)} getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)}
/> />
</AnimatedHeightReveal>
</TaskContextMenu> </TaskContextMenu>
))} ))}
@ -677,17 +680,18 @@ export const GlobalTaskList = ({
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)} onDelete={() => handleDeleteTask(task.teamName, task.id)}
> >
<SidebarTaskItem <AnimatedHeightReveal animate={isNewTask(task)}>
task={task} <SidebarTaskItem
hideTeamName task={task}
isNew={isNewTask(task)} hideTeamName
renamingKey={renamingTaskKey} renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete} onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel} onRenameCancel={handleRenameCancel}
getDisplaySubject={(t) => getDisplaySubject={(t) =>
taskLocalState.getRenamedSubject(t.teamName, t.id) taskLocalState.getRenamedSubject(t.teamName, t.id)
} }
/> />
</AnimatedHeightReveal>
</TaskContextMenu> </TaskContextMenu>
</div> </div>
); );
@ -744,16 +748,17 @@ export const GlobalTaskList = ({
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)} onDelete={() => handleDeleteTask(task.teamName, task.id)}
> >
<SidebarTaskItem <AnimatedHeightReveal animate={isNewTask(task)}>
task={task} <SidebarTaskItem
isNew={isNewTask(task)} task={task}
renamingKey={renamingTaskKey} renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete} onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel} onRenameCancel={handleRenameCancel}
getDisplaySubject={(t) => getDisplaySubject={(t) =>
taskLocalState.getRenamedSubject(t.teamName, t.id) taskLocalState.getRenamedSubject(t.teamName, t.id)
} }
/> />
</AnimatedHeightReveal>
</TaskContextMenu> </TaskContextMenu>
</div> </div>
); );

View file

@ -58,8 +58,6 @@ interface SidebarTaskItemProps {
task: GlobalTask; task: GlobalTask;
hideTeamName?: boolean; hideTeamName?: boolean;
showTeamName?: boolean; showTeamName?: boolean;
/** When true, the item plays an enter animation */
isNew?: boolean;
/** The composite key "teamName:taskId" of the task being renamed, or null */ /** The composite key "teamName:taskId" of the task being renamed, or null */
renamingKey?: string | null; renamingKey?: string | null;
/** Called when rename is completed with Enter or blur */ /** Called when rename is completed with Enter or blur */
@ -74,7 +72,6 @@ export const SidebarTaskItem = ({
task, task,
hideTeamName, hideTeamName,
showTeamName, showTeamName,
isNew,
renamingKey, renamingKey,
onRenameComplete, onRenameComplete,
onRenameCancel, onRenameCancel,
@ -147,12 +144,10 @@ export const SidebarTaskItem = ({
const showTeamRow = showTeamName && !hideTeamName; const showTeamRow = showTeamName && !hideTeamName;
const enterClass = isNew ? 'task-item-enter-animate' : '';
return ( return (
<button <button
type="button" type="button"
className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''} ${enterClass}`} className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }} style={{ borderColor: 'var(--color-border)' }}
onClick={() => { onClick={() => {
if (!isRenaming) { if (!isRenaming) {

View file

@ -15,6 +15,7 @@ import {
} from '@renderer/constants/cssVariables'; } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { import {
getMessageTypeLabel, getMessageTypeLabel,
getStructuredMessageSummary, getStructuredMessageSummary,
@ -22,7 +23,7 @@ import {
parseStructuredAgentMessage, parseStructuredAgentMessage,
} from '@renderer/utils/agentMessageFormatting'; } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { linkifyMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { import {
CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SENT_SOURCE,
@ -305,6 +306,12 @@ export const ActivityItem = ({
const { isLight } = useTheme(); const { isLight } = useTheme();
const formattedRole = formatAgentRole(memberRole); const formattedRole = formatAgentRole(memberRole);
const teams = useStore((s) => s.teams);
const teamNames = useMemo(
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
[teams]
);
const timestamp = Number.isNaN(Date.parse(message.timestamp)) const timestamp = Number.isNaN(Date.parse(message.timestamp))
? message.timestamp ? message.timestamp
: new Date(message.timestamp).toLocaleString(); : new Date(message.timestamp).toLocaleString();
@ -374,7 +381,7 @@ export const ActivityItem = ({
return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
}, [structured, message.text, isCrossTeamAny]); }, [structured, message.text, isCrossTeamAny]);
// Parse reply BEFORE linkification — linkifyMentionsInMarkdown transforms @name // Parse reply BEFORE linkification — linkifyAllMentionsInMarkdown transforms @name
// into markdown links which breaks the reply regex matcher // into markdown links which breaks the reply regex matcher
const parsedReply = useMemo( const parsedReply = useMemo(
() => (strippedText ? parseMessageReply(strippedText) : null), () => (strippedText ? parseMessageReply(strippedText) : null),
@ -386,10 +393,10 @@ export const ActivityItem = ({
if (!strippedText) return null; if (!strippedText) return null;
let result = highlightSystemLabels(strippedText, !!systemLabel); let result = highlightSystemLabels(strippedText, !!systemLabel);
result = linkifyTaskIdsInMarkdown(result); result = linkifyTaskIdsInMarkdown(result);
if (memberColorMap && memberColorMap.size > 0) if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0)
result = linkifyMentionsInMarkdown(result, memberColorMap); result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames);
return result; return result;
}, [strippedText, memberColorMap, systemLabel]); }, [strippedText, memberColorMap, teamNames, systemLabel]);
const rawSummary = const rawSummary =
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';

View file

@ -38,6 +38,11 @@ export const AnimatedHeightReveal = ({
const [isExpanded, setIsExpanded] = useState( const [isExpanded, setIsExpanded] = useState(
() => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches () => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches
); );
// Overflow must stay hidden during the height transition so the grid clip
// actually works. Switch to visible only after the animation completes.
const [overflowVisible, setOverflowVisible] = useState(
() => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
const setWrapperRef = useCallback( const setWrapperRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
@ -66,8 +71,15 @@ export const AnimatedHeightReveal = ({
}); });
}); });
// Switch overflow to visible after the height transition finishes
// so popovers/tooltips inside can render outside bounds.
const overflowTimer = setTimeout(() => {
setOverflowVisible(true);
}, ENTRY_REVEAL_ANIMATION_MS + 50);
return () => { return () => {
clearPendingAnimation(); clearPendingAnimation();
clearTimeout(overflowTimer);
}; };
}, [clearPendingAnimation, shouldAnimateOnMount, prefersReducedMotion]); }, [clearPendingAnimation, shouldAnimateOnMount, prefersReducedMotion]);
@ -97,7 +109,7 @@ export const AnimatedHeightReveal = ({
...style, ...style,
}} }}
> >
<div style={{ minHeight: 0, minWidth: 0, overflow: isExpanded ? 'visible' : 'hidden' }}> <div style={{ minHeight: 0, minWidth: 0, overflow: overflowVisible ? 'visible' : 'hidden' }}>
{children} {children}
</div> </div>
</div> </div>

View file

@ -14,7 +14,7 @@ import {
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { linkifyMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
@ -227,15 +227,23 @@ const LeadThoughtItem = ({
const previousHeightRef = useRef<number | null>(null); const previousHeightRef = useRef<number | null>(null);
const animationFrameRef = useRef<number | null>(null); const animationFrameRef = useRef<number | null>(null);
const cleanupTimerRef = useRef<number | null>(null); const cleanupTimerRef = useRef<number | null>(null);
const initialAnimationCompletedRef = useRef(!shouldAnimate);
const [shouldAnimateOnMount] = useState(() => shouldAnimate);
const teams = useStore((s) => s.teams);
const teamNames = useMemo(
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
[teams]
);
const displayContent = useMemo(() => { const displayContent = useMemo(() => {
let text = thought.text.replace(/\n/g, ' \n'); let text = thought.text.replace(/\n/g, ' \n');
text = linkifyTaskIdsInMarkdown(text); text = linkifyTaskIdsInMarkdown(text);
if (memberColorMap && memberColorMap.size > 0) { if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
text = linkifyMentionsInMarkdown(text, memberColorMap); text = linkifyAllMentionsInMarkdown(text, memberColorMap ?? new Map(), teamNames);
} }
return text; return text;
}, [thought.text, memberColorMap]); }, [thought.text, memberColorMap, teamNames]);
const clearPendingAnimation = useCallback(() => { const clearPendingAnimation = useCallback(() => {
if (animationFrameRef.current !== null) { if (animationFrameRef.current !== null) {
@ -283,6 +291,7 @@ const LeadThoughtItem = ({
startHeight: number, startHeight: number,
startOpacity: number startOpacity: number
): void => { ): void => {
initialAnimationCompletedRef.current = false;
clearPendingAnimation(); clearPendingAnimation();
wrapper.style.transition = 'none'; wrapper.style.transition = 'none';
wrapper.style.overflow = 'hidden'; wrapper.style.overflow = 'hidden';
@ -299,6 +308,7 @@ const LeadThoughtItem = ({
cleanupTimerRef.current = window.setTimeout(() => { cleanupTimerRef.current = window.setTimeout(() => {
resetWrapperStyles(); resetWrapperStyles();
initialAnimationCompletedRef.current = true;
cleanupTimerRef.current = null; cleanupTimerRef.current = null;
}, THOUGHT_HEIGHT_ANIMATION_MS + 40); }, THOUGHT_HEIGHT_ANIMATION_MS + 40);
}; };
@ -307,7 +317,8 @@ const LeadThoughtItem = ({
const previousHeight = previousHeightRef.current; const previousHeight = previousHeightRef.current;
previousHeightRef.current = nextHeight; previousHeightRef.current = nextHeight;
if (!shouldAnimate) { if (!shouldAnimateOnMount) {
initialAnimationCompletedRef.current = true;
resetWrapperStyles(); resetWrapperStyles();
return; return;
} }
@ -316,6 +327,7 @@ const LeadThoughtItem = ({
if (nextHeight > 0 && animateFromZero) { if (nextHeight > 0 && animateFromZero) {
animateHeight(nextHeight, 0, 0); animateHeight(nextHeight, 0, 0);
} else { } else {
initialAnimationCompletedRef.current = true;
resetWrapperStyles(); resetWrapperStyles();
} }
return; return;
@ -323,6 +335,13 @@ const LeadThoughtItem = ({
if (Math.abs(nextHeight - previousHeight) < 1) return; if (Math.abs(nextHeight - previousHeight) < 1) return;
// Only the first reveal should animate. Late content growth (for example when
// tool summary metadata appears after the text) should resize naturally.
if (initialAnimationCompletedRef.current) {
resetWrapperStyles();
return;
}
const renderedHeight = wrapper.getBoundingClientRect().height; const renderedHeight = wrapper.getBoundingClientRect().height;
animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1); animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1);
}; };
@ -338,9 +357,10 @@ const LeadThoughtItem = ({
return () => { return () => {
observer.disconnect(); observer.disconnect();
clearPendingAnimation(); clearPendingAnimation();
initialAnimationCompletedRef.current = true;
resetWrapperStyles(); resetWrapperStyles();
}; };
}, [clearPendingAnimation, resetWrapperStyles, shouldAnimate]); }, [clearPendingAnimation, resetWrapperStyles, shouldAnimateOnMount]);
useEffect( useEffect(
() => () => { () => () => {

View file

@ -118,23 +118,31 @@ export const SendMessageDialog = ({
const selectedMember = members.find((m) => m.name === member); const selectedMember = members.find((m) => m.name === member);
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const hasTeammates = members.length > 1;
const canDelegate = hasTeammates && isLeadRecipient;
const shouldAutoDelegate = canDelegate;
const supportsAttachments = isLeadRecipient && !!isTeamAlive; const supportsAttachments = isLeadRecipient && !!isTeamAlive;
const canAttach = supportsAttachments && canAddMore; const canAttach = supportsAttachments && canAddMore;
// Auto-switch to delegate when lead recipient is selected, but don't // Auto-switch to delegate when lead recipient is selected, but don't
// override user's explicit choice on dialog open. // override user's explicit choice on dialog open.
const prevIsLeadRef = useRef(isLeadRecipient); const prevShouldAutoDelegateRef = useRef(shouldAutoDelegate);
useEffect(() => { useEffect(() => {
// Skip the initial mount — honour the sticky mode if (!canDelegate && actionMode === 'delegate') {
if (prevIsLeadRef.current === isLeadRecipient) return; setActionMode('do');
prevIsLeadRef.current = isLeadRecipient; return;
}
if (isLeadRecipient) { // Skip the initial mount — honour the sticky mode
if (prevShouldAutoDelegateRef.current === shouldAutoDelegate) return;
prevShouldAutoDelegateRef.current = shouldAutoDelegate;
if (shouldAutoDelegate) {
setActionMode('delegate'); setActionMode('delegate');
} else { } else {
setActionModeState((prev) => (prev === 'delegate' ? 'do' : prev)); setActionModeState((prev) => (prev === 'delegate' ? 'do' : prev));
} }
}, [isLeadRecipient, setActionMode]); }, [actionMode, canDelegate, setActionMode, shouldAutoDelegate]);
const [pendingAutoClose, setPendingAutoClose] = useState(false); const [pendingAutoClose, setPendingAutoClose] = useState(false);
// Reset form on open transition (avoid setState in render) // Reset form on open transition (avoid setState in render)
@ -470,7 +478,7 @@ export const SendMessageDialog = ({
<ActionModeSelector <ActionModeSelector
value={actionMode} value={actionMode}
onChange={setActionMode} onChange={setActionMode}
showDelegate={isLeadRecipient} showDelegate={canDelegate}
/> />
} }
cornerAction={ cornerAction={

View file

@ -3,11 +3,14 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { MAX_TEXT_LENGTH } from '@shared/constants'; import { MAX_TEXT_LENGTH } from '@shared/constants';
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
@ -49,7 +52,9 @@ export const TaskCommentInput = ({
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]); const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [attachError, setAttachError] = useState<string | null>(null); const [attachError, setAttachError] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@ -120,9 +125,10 @@ export const TaskCommentInput = ({
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (!canSubmit) return; if (!canSubmit) return;
try { try {
const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
const text = replyTo const text = replyTo
? buildReplyBlock(replyTo.author, replyTo.text, trimmed || '(image)') ? buildReplyBlock(replyTo.author, replyTo.text, serialized || '(image)')
: trimmed || '(image)'; : serialized || '(image)';
const attachments: CommentAttachmentPayload[] | undefined = const attachments: CommentAttachmentPayload[] | undefined =
pendingAttachments.length > 0 pendingAttachments.length > 0
? pendingAttachments.map((a) => ({ ? pendingAttachments.map((a) => ({
@ -134,6 +140,7 @@ export const TaskCommentInput = ({
: undefined; : undefined;
await addTaskComment(teamName, taskId, text, attachments); await addTaskComment(teamName, taskId, text, attachments);
draft.clearDraft(); draft.clearDraft();
chipDraft.clearChipDraft();
setPendingAttachments([]); setPendingAttachments([]);
setAttachError(null); setAttachError(null);
onClearReply(); onClearReply();
@ -147,6 +154,7 @@ export const TaskCommentInput = ({
taskId, taskId,
trimmed, trimmed,
draft, draft,
chipDraft,
replyTo, replyTo,
onClearReply, onClearReply,
pendingAttachments, pendingAttachments,
@ -270,7 +278,11 @@ export const TaskCommentInput = ({
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
teamSuggestions={teamMentionSuggestions}
projectPath={projectPath} projectPath={projectPath}
chips={chipDraft.chips}
onFileChipInsert={chipDraft.addChip}
onChipRemove={chipDraft.removeChip}
onModEnter={() => void handleSubmit()} onModEnter={() => void handleSubmit()}
minRows={2} minRows={2}
maxRows={8} maxRows={8}

View file

@ -10,14 +10,17 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { MAX_TEXT_LENGTH } from '@shared/constants'; import { MAX_TEXT_LENGTH } from '@shared/constants';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@ -77,6 +80,7 @@ export const TaskCommentsSection = ({
}: TaskCommentsSectionProps): React.JSX.Element => { }: TaskCommentsSectionProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment); const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment); const addingComment = useStore((s) => s.addingComment);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const commentsRef = useMarkCommentsRead(teamName, taskId, comments); const commentsRef = useMarkCommentsRead(teamName, taskId, comments);
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null); const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
@ -96,7 +100,13 @@ export const TaskCommentsSection = ({
} }
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const teamNamesForLinkify = useMemo(
() => teamMentionSuggestions.map((t) => t.name),
[teamMentionSuggestions]
);
const cappedComments = useMemo(() => { const cappedComments = useMemo(() => {
if (comments.length <= MAX_COMMENTS_TO_RENDER) return comments; if (comments.length <= MAX_COMMENTS_TO_RENDER) return comments;
@ -139,19 +149,24 @@ export const TaskCommentsSection = ({
const trimmed = draft.value.trim(); const trimmed = draft.value.trim();
const remaining = MAX_TEXT_LENGTH - trimmed.length; const remaining = MAX_TEXT_LENGTH - trimmed.length;
const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_TEXT_LENGTH && !addingComment; const canSubmit =
(trimmed.length > 0 || chipDraft.chips.length > 0) &&
trimmed.length <= MAX_TEXT_LENGTH &&
!addingComment;
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (!canSubmit) return; if (!canSubmit) return;
try { try {
const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, trimmed) : trimmed; const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, serialized) : serialized;
await addTaskComment(teamName, taskId, text); await addTaskComment(teamName, taskId, text);
draft.clearDraft(); draft.clearDraft();
chipDraft.clearChipDraft();
setReplyTo(null); setReplyTo(null);
} catch { } catch {
// Error is stored in addCommentError via store // Error is stored in addCommentError via store
} }
}, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, replyTo]); }, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, chipDraft, replyTo]);
return ( return (
<div ref={commentsRef}> <div ref={commentsRef}>
@ -288,7 +303,12 @@ export const TaskCommentsSection = ({
<MarkdownViewer <MarkdownViewer
content={(() => { content={(() => {
let t = linkifyTaskIdsInMarkdown(displayText); let t = linkifyTaskIdsInMarkdown(displayText);
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); if (colorMap.size > 0 || teamNamesForLinkify.length > 0)
t = linkifyAllMentionsInMarkdown(
t,
colorMap,
teamNamesForLinkify
);
return t; return t;
})()} })()}
maxHeight="max-h-none" maxHeight="max-h-none"
@ -373,6 +393,11 @@ export const TaskCommentsSection = ({
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
teamSuggestions={teamMentionSuggestions}
projectPath={projectPath}
chips={chipDraft.chips}
onFileChipInsert={chipDraft.addChip}
onChipRemove={chipDraft.removeChip}
onModEnter={() => void handleSubmit()} onModEnter={() => void handleSubmit()}
minRows={2} minRows={2}
maxRows={8} maxRows={8}

View file

@ -139,7 +139,9 @@ export const MessageComposer = ({
const selectedMember = members.find((m) => m.name === recipient); const selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const canDelegate = isCrossTeam || isLeadRecipient; const hasTeammates = members.length > 1;
const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient);
const shouldAutoDelegate = isLeadRecipient && canDelegate;
const { actionMode, setActionMode, isLoaded: draftLoaded } = draft; const { actionMode, setActionMode, isLoaded: draftLoaded } = draft;
@ -148,28 +150,32 @@ export const MessageComposer = ({
// so we don't overwrite the persisted actionMode during initialization. // so we don't overwrite the persisted actionMode during initialization.
// After draft loads, only auto-switch on subsequent recipient changes. // After draft loads, only auto-switch on subsequent recipient changes.
const isInitializedRef = useRef(false); const isInitializedRef = useRef(false);
const prevIsLeadRef = useRef(isLeadRecipient); const prevShouldAutoDelegateRef = useRef(shouldAutoDelegate);
useEffect(() => { useEffect(() => {
if (!draftLoaded) return; if (!draftLoaded) return;
if (!canDelegate && actionMode === 'delegate') {
setActionMode('do');
return;
}
// On first run after load, just record the baseline — don't overwrite // On first run after load, just record the baseline — don't overwrite
if (!isInitializedRef.current) { if (!isInitializedRef.current) {
isInitializedRef.current = true; isInitializedRef.current = true;
prevIsLeadRef.current = isLeadRecipient; prevShouldAutoDelegateRef.current = shouldAutoDelegate;
return; return;
} }
// Only react when isLeadRecipient actually changes // Only react when delegate availability actually changes
if (isLeadRecipient === prevIsLeadRef.current) return; if (shouldAutoDelegate === prevShouldAutoDelegateRef.current) return;
prevIsLeadRef.current = isLeadRecipient; prevShouldAutoDelegateRef.current = shouldAutoDelegate;
if (isLeadRecipient) { if (shouldAutoDelegate) {
setActionMode('delegate'); setActionMode('delegate');
} else if (actionMode === 'delegate') { } else if (actionMode === 'delegate') {
setActionMode('do'); setActionMode('do');
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [actionMode, canDelegate, draftLoaded, setActionMode, shouldAutoDelegate]);
}, [isLeadRecipient, draftLoaded]);
// NOTE: lead context ring disabled — usage formula is inaccurate // NOTE: lead context ring disabled — usage formula is inaccurate
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead'; // const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
// const leadContext = useStore((s) => // const leadContext = useStore((s) =>

View file

@ -33,6 +33,12 @@ import type {
} from '@shared/types'; } from '@shared/types';
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor'; import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
type RecentHunkUndoAction = {
filePath: string;
originalIndex: number;
at: number;
};
interface ChangeReviewDialogProps { interface ChangeReviewDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@ -145,6 +151,8 @@ export const ChangeReviewDialog = ({
// Track recent per-hunk actions so Ctrl/Cmd+Z can clear persisted decisions (reopen-safe) // Track recent per-hunk actions so Ctrl/Cmd+Z can clear persisted decisions (reopen-safe)
const lastHunkActionAtRef = useRef<Record<string, number>>({}); const lastHunkActionAtRef = useRef<Record<string, number>>({});
const hunkDecisionUndoStackRef = useRef<Record<string, number[]>>({}); const hunkDecisionUndoStackRef = useRef<Record<string, number[]>>({});
const recentHunkUndoActionsRef = useRef<RecentHunkUndoAction[]>([]);
const lastEditorInteractionAtRef = useRef<Record<string, number>>({});
const newFileApplyInFlightRef = useRef(new Set<string>()); const newFileApplyInFlightRef = useRef(new Set<string>());
const lastFileActionAtRef = useRef<number>(0); const lastFileActionAtRef = useRef<number>(0);
const removedNewFileUndoStackRef = useRef< const removedNewFileUndoStackRef = useRef<
@ -156,6 +164,16 @@ export const ChangeReviewDialog = ({
const activeEditorViewRef = useRef<EditorView | null>(null); const activeEditorViewRef = useRef<EditorView | null>(null);
const activeFilePathRef = useRef<string | null>(null); const activeFilePathRef = useRef<string | null>(null);
const getEditorFilePathForTarget = useCallback((target: Element | null): string | null => {
if (!target) return null;
for (const [filePath, view] of editorViewMapRef.current.entries()) {
if (view.dom.contains(target)) {
return filePath;
}
}
return null;
}, []);
// Keep refs in sync with activeFilePath // Keep refs in sync with activeFilePath
useEffect(() => { useEffect(() => {
activeFilePathRef.current = activeFilePath; activeFilePathRef.current = activeFilePath;
@ -435,6 +453,11 @@ export const ChangeReviewDialog = ({
hunkDecisionUndoStackRef.current[filePath] = []; hunkDecisionUndoStackRef.current[filePath] = [];
} }
hunkDecisionUndoStackRef.current[filePath].push(originalIndex); hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
recentHunkUndoActionsRef.current.push({
filePath,
originalIndex,
at: Date.now(),
});
}, },
[setHunkDecision] [setHunkDecision]
); );
@ -447,6 +470,11 @@ export const ChangeReviewDialog = ({
hunkDecisionUndoStackRef.current[filePath] = []; hunkDecisionUndoStackRef.current[filePath] = [];
} }
hunkDecisionUndoStackRef.current[filePath].push(originalIndex); hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
recentHunkUndoActionsRef.current.push({
filePath,
originalIndex,
at: Date.now(),
});
if (REVIEW_INSTANT_APPLY) { if (REVIEW_INSTANT_APPLY) {
void applySingleFileDecision(teamName, filePath, taskId, memberName).then((result) => { void applySingleFileDecision(teamName, filePath, taskId, memberName).then((result) => {
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath); const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
@ -757,11 +785,12 @@ export const ChangeReviewDialog = ({
const target = e.target as Element | null; const target = e.target as Element | null;
if (!target?.closest?.('.cm-editor')) return; if (!target?.closest?.('.cm-editor')) return;
for (const view of editorViewMapRef.current.values()) { const filePath = getEditorFilePathForTarget(target);
if (view.dom.contains(target)) { if (!filePath) return;
lastFocusedEditorRef.current = view;
return; const view = editorViewMapRef.current.get(filePath);
} if (view) {
lastFocusedEditorRef.current = view;
} }
}; };
@ -770,7 +799,34 @@ export const ChangeReviewDialog = ({
document.removeEventListener('focusin', handleFocusIn); document.removeEventListener('focusin', handleFocusIn);
lastFocusedEditorRef.current = null; lastFocusedEditorRef.current = null;
}; };
}, [open]); }, [open, getEditorFilePathForTarget]);
useEffect(() => {
if (!open) return;
const markEditorInteraction = (target: EventTarget | null): void => {
const element = target instanceof Element ? target : null;
if (!element?.closest?.('.cm-editor')) return;
const filePath = getEditorFilePathForTarget(element);
if (!filePath) return;
lastEditorInteractionAtRef.current[filePath] = Date.now();
};
const handleMouseDown = (e: MouseEvent): void => {
markEditorInteraction(e.target);
};
const handleKeyDown = (e: KeyboardEvent): void => {
markEditorInteraction(e.target);
};
document.addEventListener('mousedown', handleMouseDown, true);
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('mousedown', handleMouseDown, true);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [open, getEditorFilePathForTarget]);
// Cmd+Z: undo in last focused editor, or fall back to bulk review undo // Cmd+Z: undo in last focused editor, or fall back to bulk review undo
useEffect(() => { useEffect(() => {
@ -827,6 +883,35 @@ export const ChangeReviewDialog = ({
return; return;
} }
const recentHunkAction =
recentHunkUndoActionsRef.current[recentHunkUndoActionsRef.current.length - 1];
const hunkOutsideEditor =
recentHunkAction &&
!isInEditor &&
now - recentHunkAction.at < 5_000 &&
(lastEditorInteractionAtRef.current[recentHunkAction.filePath] ?? 0) <=
recentHunkAction.at &&
!!editorViewMapRef.current.get(recentHunkAction.filePath)?.dom.isConnected;
if (hunkOutsideEditor) {
const action = recentHunkUndoActionsRef.current.pop()!;
const view = editorViewMapRef.current.get(action.filePath)!;
const fileStack = hunkDecisionUndoStackRef.current[action.filePath];
if (fileStack) {
const stackIndex = fileStack.lastIndexOf(action.originalIndex);
if (stackIndex !== -1) {
fileStack.splice(stackIndex, 1);
}
if (fileStack.length === 0) {
delete hunkDecisionUndoStackRef.current[action.filePath];
}
}
e.preventDefault();
e.stopPropagation();
undo(view);
clearHunkDecisionByOriginalIndex(action.filePath, action.originalIndex);
return;
}
// If the last action was a hunk keep/undo (accept/reject) and we're undoing immediately, // If the last action was a hunk keep/undo (accept/reject) and we're undoing immediately,
// we must also clear the persisted decision. Otherwise reopening the dialog will replay it. // we must also clear the persisted decision. Otherwise reopening the dialog will replay it.
if (document.activeElement?.closest('.cm-editor')) { if (document.activeElement?.closest('.cm-editor')) {
@ -841,6 +926,13 @@ export const ChangeReviewDialog = ({
e.stopPropagation(); e.stopPropagation();
undo(lastView); undo(lastView);
const originalIndex = stack.pop()!; const originalIndex = stack.pop()!;
for (let i = recentHunkUndoActionsRef.current.length - 1; i >= 0; i--) {
const action = recentHunkUndoActionsRef.current[i];
if (action.filePath === fp && action.originalIndex === originalIndex) {
recentHunkUndoActionsRef.current.splice(i, 1);
break;
}
}
clearHunkDecisionByOriginalIndex(fp, originalIndex); clearHunkDecisionByOriginalIndex(fp, originalIndex);
return; return;
} }

View file

@ -58,17 +58,33 @@ const MAX_PREVIEW_LINES = 12;
const ChipFilePreview = ({ const ChipFilePreview = ({
chip, chip,
onOpenInEditor, onOpenInEditor,
onRevealFolder,
}: { }: {
chip: InlineChip; chip: InlineChip;
onOpenInEditor?: (filePath: string) => void; onOpenInEditor?: (filePath: string) => void;
onRevealFolder?: (folderPath: string) => void;
}): React.JSX.Element => { }): React.JSX.Element => {
const displayPath = chip.displayPath ?? chip.filePath; const displayPath = chip.displayPath ?? chip.filePath;
const isFolder = chip.isFolder === true;
return ( return (
<div className="max-w-md overflow-hidden rounded-md"> <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"> <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-[11px] font-medium text-[var(--color-text)]">{chip.fileName}</span>
<span className="flex-1 text-[10px] text-[var(--color-text-muted)]">{displayPath}</span> <span className="flex-1 text-[10px] text-[var(--color-text-muted)]">{displayPath}</span>
{onOpenInEditor ? ( {isFolder && onRevealFolder ? (
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRevealFolder(chip.filePath);
}}
>
<ExternalLink size={10} />
Reveal
</button>
) : !isFolder && onOpenInEditor ? (
<button <button
type="button" type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
@ -161,6 +177,7 @@ export const ChipInteractionLayer = ({
}: ChipInteractionLayerProps): React.JSX.Element | null => { }: ChipInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<ChipPosition[]>([]); const [positions, setPositions] = React.useState<ChipPosition[]>([]);
const revealFileInEditor = useStore((s) => s.revealFileInEditor); const revealFileInEditor = useStore((s) => s.revealFileInEditor);
const revealFolderInEditor = useStore((s) => s.revealFolderInEditor);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (chips.length === 0) { if (chips.length === 0) {
@ -179,6 +196,7 @@ export const ChipInteractionLayer = ({
<div style={{ transform: `translateY(-${scrollTop}px)` }}> <div style={{ transform: `translateY(-${scrollTop}px)` }}>
{positions.map((pos) => { {positions.map((pos) => {
const isFileChip = pos.chip.fromLine == null; const isFileChip = pos.chip.fromLine == null;
const isFolderChip = pos.chip.isFolder === true;
return ( return (
<Tooltip key={pos.chip.id}> <Tooltip key={pos.chip.id}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -195,7 +213,11 @@ export const ChipInteractionLayer = ({
? (e) => { ? (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
revealFileInEditor(pos.chip.filePath); if (isFolderChip) {
revealFolderInEditor(pos.chip.filePath);
} else {
revealFileInEditor(pos.chip.filePath);
}
} }
: undefined : undefined
} }
@ -215,7 +237,11 @@ export const ChipInteractionLayer = ({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-md p-0"> <TooltipContent side="top" className="max-w-md p-0">
{isFileChip ? ( {isFileChip ? (
<ChipFilePreview chip={pos.chip} onOpenInEditor={revealFileInEditor} /> <ChipFilePreview
chip={pos.chip}
onOpenInEditor={revealFileInEditor}
onRevealFolder={revealFolderInEditor}
/>
) : ( ) : (
<ChipCodePreview chip={pos.chip} /> <ChipCodePreview chip={pos.chip} />
)} )}

View file

@ -3,12 +3,15 @@
* Uses the same text as the textarea (transparent) to maintain pixel-perfect alignment. * Uses the same text as the textarea (transparent) to maintain pixel-perfect alignment.
* *
* Purple color scheme to distinguish from @mention badges (blue). * Purple color scheme to distinguish from @mention badges (blue).
* Folder chips use a teal color scheme to distinguish from file chips.
*/ */
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
const CHIP_BG = 'rgba(139, 92, 246, 0.15)'; const CHIP_BG = 'rgba(139, 92, 246, 0.15)';
const CHIP_TEXT = '#a78bfa'; const CHIP_TEXT = '#a78bfa';
const FOLDER_CHIP_BG = 'rgba(45, 212, 191, 0.15)';
const FOLDER_CHIP_TEXT = '#5eead4';
interface CodeChipBadgeProps { interface CodeChipBadgeProps {
chip: InlineChip; chip: InlineChip;
@ -16,14 +19,16 @@ interface CodeChipBadgeProps {
tokenText: string; tokenText: string;
} }
export const CodeChipBadge = ({ tokenText }: CodeChipBadgeProps): React.JSX.Element => { export const CodeChipBadge = ({ chip, tokenText }: CodeChipBadgeProps): React.JSX.Element => {
const bg = chip.isFolder ? FOLDER_CHIP_BG : CHIP_BG;
const text = chip.isFolder ? FOLDER_CHIP_TEXT : CHIP_TEXT;
return ( return (
<span <span
style={{ style={{
backgroundColor: CHIP_BG, backgroundColor: bg,
color: CHIP_TEXT, color: text,
borderRadius: '4px', borderRadius: '4px',
boxShadow: `0 0 0 1.5px ${CHIP_BG}`, boxShadow: `0 0 0 1.5px ${bg}`,
}} }}
> >
{tokenText} {tokenText}

View file

@ -377,7 +377,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
[getTriggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss] [getTriggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss]
); );
// --- Folder selection handler (inserts folder path with trailing slash) --- // --- Folder selection handler (inserts folder as chip with folder icon) ---
const handleFolderSelect = React.useCallback( const handleFolderSelect = React.useCallback(
(s: MentionSuggestion) => { (s: MentionSuggestion) => {
const textarea = internalRef.current; const textarea = internalRef.current;
@ -389,18 +389,51 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
const before = value.slice(0, replaceStart); const before = value.slice(0, replaceStart);
const after = value.slice(replaceEnd); const after = value.slice(replaceEnd);
const displayPath = s.relativePath ?? s.name; if (onFileChipInsert && onChipRemove) {
const insertion = `\`${displayPath}\` `; // Chip mode: create folder InlineChip
const newValue = before + insertion + after; const chip = createChipFromSelection(
onValueChange(newValue); {
dismiss(); type: 'sendMessage',
filePath: s.filePath ?? '',
fromLine: null,
toLine: null,
selectedText: '',
formattedContext: '',
displayPath: s.relativePath,
isFolder: true,
},
chips
);
requestAnimationFrame(() => { if (chip) {
const cursor = before.length + insertion.length; const token = chipToken(chip);
textarea.setSelectionRange(cursor, cursor); const newValue = before + token + after;
}); onValueChange(newValue);
onFileChipInsert(chip);
dismiss();
requestAnimationFrame(() => {
const cursor = before.length + token.length;
textarea.setSelectionRange(cursor, cursor);
});
} else {
dismiss();
}
} else {
// Text mode fallback: insert backtick-wrapped relative path
const displayPath = s.relativePath ?? s.name;
const insertion = `\`${displayPath}\` `;
const newValue = before + insertion + after;
onValueChange(newValue);
dismiss();
requestAnimationFrame(() => {
const cursor = before.length + insertion.length;
textarea.setSelectionRange(cursor, cursor);
});
}
}, },
[getTriggerIndex, query, value, onValueChange, dismiss] [getTriggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss]
); );
// --- Merged selection handler --- // --- Merged selection handler ---

View file

@ -709,24 +709,6 @@ body {
animation: chat-message-enter 350ms ease-out both; animation: chat-message-enter 350ms ease-out both;
} }
@keyframes task-item-enter {
from {
opacity: 0;
max-height: 0;
transform: translateY(-4px);
overflow: hidden;
}
to {
opacity: 1;
max-height: 80px;
transform: translateY(0);
}
}
.task-item-enter-animate {
animation: task-item-enter 280ms ease-out both;
}
@keyframes thought-expand { @keyframes thought-expand {
from { from {
max-height: 0; max-height: 0;

View file

@ -248,6 +248,8 @@ export interface EditorSlice {
editorPendingRevealFile: string | null; editorPendingRevealFile: string | null;
/** Request to reveal a file in the editor. Opens editor overlay if needed. */ /** Request to reveal a file in the editor. Opens editor overlay if needed. */
revealFileInEditor: (filePath: string) => void; revealFileInEditor: (filePath: string) => void;
/** Request to reveal a folder in the editor tree. Expands parent dirs + the folder itself. */
revealFolderInEditor: (folderPath: string) => void;
/** Process the pending reveal: expand parent dirs and open the file tab. */ /** Process the pending reveal: expand parent dirs and open the file tab. */
revealAndOpenFile: (filePath: string) => Promise<void>; revealAndOpenFile: (filePath: string) => Promise<void>;
clearPendingRevealFile: () => void; clearPendingRevealFile: () => void;
@ -313,6 +315,39 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
set({ editorPendingRevealFile: filePath }); set({ editorPendingRevealFile: filePath });
}, },
revealFolderInEditor: (folderPath: string) => {
// Set pending reveal so EditorFileTree scrolls to the folder
set({ editorPendingRevealFile: folderPath });
// Expand parent dirs + the folder itself
const { editorProjectPath, editorFileTree, expandDirectory } = get();
if (!editorProjectPath || !editorFileTree) return;
const root = stripTrailingSeparators(editorProjectPath);
const rootParts = splitPath(root);
const folderParts = splitPath(folderPath);
const win = isWindowsishPath(root);
const eq = (a: string, b: string): boolean =>
win ? a.toLowerCase() === b.toLowerCase() : a === b;
const hasPrefix =
folderParts.length >= rootParts.length &&
rootParts.every((seg, i) => eq(seg, folderParts[i]));
if (hasPrefix) {
const segments = folderParts.slice(rootParts.length);
let currentDir = root;
// Expand each segment including the folder itself
const doExpand = async (): Promise<void> => {
for (const seg of segments) {
currentDir = joinPath(currentDir, seg);
await expandDirectory(currentDir);
}
set({ editorPendingRevealFile: null });
};
void doExpand();
}
},
clearPendingRevealFile: () => { clearPendingRevealFile: () => {
set({ editorPendingRevealFile: null }); set({ editorPendingRevealFile: null });
}, },

View file

@ -27,6 +27,8 @@ export interface InlineChip {
language: string; language: string;
/** Relative display path for file-level mentions */ /** Relative display path for file-level mentions */
displayPath?: string; displayPath?: string;
/** Whether this chip represents a folder (not a file) */
isFolder?: boolean;
} }
// ============================================================================= // =============================================================================
@ -35,6 +37,8 @@ export interface InlineChip {
/** Unicode marker character used as chip prefix in textarea text */ /** Unicode marker character used as chip prefix in textarea text */
export const CHIP_MARKER = '\u{1F4C4}'; // 📄 export const CHIP_MARKER = '\u{1F4C4}'; // 📄
/** Unicode marker for folder chips */
export const FOLDER_CHIP_MARKER = '\u{1F4C1}'; // 📁
// ============================================================================= // =============================================================================
// Pure functions // Pure functions
@ -42,9 +46,12 @@ export const CHIP_MARKER = '\u{1F4C4}'; // 📄
/** /**
* Display label for a chip: "auth.ts:10-15", "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. * "auth.ts" for file-level mentions, or "docker/" for folder mentions.
*/ */
export function chipDisplayLabel(chip: InlineChip): string { export function chipDisplayLabel(chip: InlineChip): string {
if (chip.isFolder) {
return chip.displayPath ?? chip.fileName;
}
if (chip.fromLine == null || chip.toLine == null) { if (chip.fromLine == null || chip.toLine == null) {
return chip.fileName; return chip.fileName;
} }
@ -59,13 +66,19 @@ export function chipDisplayLabel(chip: InlineChip): string {
* Must match EXACTLY in textarea and overlay for pixel-perfect alignment. * Must match EXACTLY in textarea and overlay for pixel-perfect alignment.
*/ */
export function chipToken(chip: InlineChip): string { export function chipToken(chip: InlineChip): string {
return `${CHIP_MARKER}${chipDisplayLabel(chip)}`; const marker = chip.isFolder ? FOLDER_CHIP_MARKER : CHIP_MARKER;
return `${marker}${chipDisplayLabel(chip)}`;
} }
/** /**
* Converts a chip to markdown: code fence for code chips, file reference for file mentions. * Converts a chip to markdown: code fence for code chips, file/folder reference for mentions.
*/ */
export function chipToMarkdown(chip: InlineChip): string { export function chipToMarkdown(chip: InlineChip): string {
// Folder mention
if (chip.isFolder) {
const path = chip.displayPath ?? chip.filePath;
return `**${path}** (folder)`;
}
// File-level mention — no code fence // File-level mention — no code fence
if (chip.fromLine == null || chip.toLine == null) { if (chip.fromLine == null || chip.toLine == null) {
const path = chip.displayPath ?? chip.filePath; const path = chip.displayPath ?? chip.filePath;

View file

@ -26,13 +26,13 @@ export function createChipFromSelection(
const isFileMention = !action.selectedText || action.fromLine == null || action.toLine == null; const isFileMention = !action.selectedText || action.fromLine == null || action.toLine == null;
if (isFileMention) { if (isFileMention) {
// File-level mention: deduplicate by filePath + null lines // File/folder-level mention: deduplicate by filePath + null lines
const isDuplicate = existingChips.some( const isDuplicate = existingChips.some(
(c) => c.filePath === action.filePath && c.fromLine == null (c) => c.filePath === action.filePath && c.fromLine == null
); );
if (isDuplicate) return null; if (isDuplicate) return null;
const fileName = getBasename(action.filePath) || 'file'; const fileName = getBasename(action.filePath) || (action.isFolder ? 'folder' : 'file');
return { return {
id: `chip-${++chipCounter}-${Date.now()}`, id: `chip-${++chipCounter}-${Date.now()}`,
filePath: action.filePath, filePath: action.filePath,
@ -40,8 +40,9 @@ export function createChipFromSelection(
fromLine: null, fromLine: null,
toLine: null, toLine: null,
codeText: '', codeText: '',
language: getCodeFenceLanguage(fileName), language: action.isFolder ? '' : getCodeFenceLanguage(fileName),
displayPath: action.displayPath, displayPath: action.displayPath,
isFolder: action.isFolder,
}; };
} }

View file

@ -254,4 +254,6 @@ export interface EditorSelectionAction {
formattedContext: string; formattedContext: string;
/** Relative display path for file-level mentions */ /** Relative display path for file-level mentions */
displayPath?: string; displayPath?: string;
/** Whether this action represents a folder (not a file) */
isFolder?: boolean;
} }