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:
parent
4214427b38
commit
d6a0f4c3a1
26 changed files with 600 additions and 211 deletions
|
|
@ -25,13 +25,14 @@
|
|||
A new approach to task management with AI agent teams.
|
||||
|
||||
- **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
|
||||
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
|
||||
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
|
||||
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
|
||||
- **Live process 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
|
||||
- **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>
|
||||
<summary><strong>More features</strong></summary>
|
||||
|
|
@ -39,7 +40,6 @@ A new approach to task management with AI agent teams.
|
|||
<br />
|
||||
|
||||
- **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
|
||||
- **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
|
||||
|
|
|
|||
|
|
@ -429,13 +429,21 @@ async function handleGetData(
|
|||
}
|
||||
|
||||
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)
|
||||
// so we can dedup lead_process live messages against them.
|
||||
// Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
|
||||
// repeated thought in a new session does not get collapsed into an old session's history.
|
||||
const existingTextFingerprints = new Set<string>();
|
||||
for (const msg of data.messages) {
|
||||
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: {
|
||||
|
|
@ -450,20 +458,20 @@ async function handleGetData(
|
|||
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
|
||||
};
|
||||
|
||||
// Text-based fingerprints for lead_process messages to catch duplicates
|
||||
// with different messageIds (e.g. lead-turn-* vs lead-sendmsg-* with same text)
|
||||
// Text-based fingerprints for live lead thoughts to catch duplicates with different
|
||||
// messageIds inside the same session (e.g. lead-turn-* re-emits).
|
||||
const leadProcessTextFingerprints = new Set<string>();
|
||||
|
||||
const merged: typeof data.messages = [];
|
||||
const seen = new Set<string>();
|
||||
for (const msg of [...data.messages, ...live]) {
|
||||
if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
|
||||
const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
// Skip if same text already exists from any source (inbox, lead_session, etc.)
|
||||
const fp = getLeadThoughtFingerprint(msg);
|
||||
// Skip if the same thought already exists in persisted history for the same session.
|
||||
if (existingTextFingerprints.has(fp)) {
|
||||
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)) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,18 +319,21 @@ export class TeamDataService {
|
|||
// 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.
|
||||
// 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 getLeadThoughtFingerprint = (
|
||||
msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>
|
||||
) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
const leadSessionFingerprints = new Set<string>();
|
||||
for (const msg of leadTexts) {
|
||||
if (msg.source !== 'lead_session') continue;
|
||||
leadSessionFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
|
||||
leadSessionFingerprints.add(getLeadThoughtFingerprint(msg));
|
||||
}
|
||||
messages = messages.filter((m) => {
|
||||
if (m.source !== 'lead_process') return true;
|
||||
// Captured SendMessage messages (with recipient) are real messages — never dedup
|
||||
if (m.to) return true;
|
||||
const fp = `${m.from}\0${normalizeText(m.text ?? '')}`;
|
||||
const fp = getLeadThoughtFingerprint(m);
|
||||
return !leadSessionFingerprints.has(fp);
|
||||
});
|
||||
}
|
||||
|
|
@ -1195,39 +1198,70 @@ export class TeamDataService {
|
|||
});
|
||||
}
|
||||
|
||||
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
|
||||
if (!config.leadSessionId || !config.projectPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const projectId = encodePath(config.projectPath);
|
||||
private getLeadProjectDirCandidates(projectPath: string): string[] {
|
||||
const projectId = encodePath(projectPath);
|
||||
const baseDir = extractBaseDir(projectId);
|
||||
let jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`);
|
||||
|
||||
try {
|
||||
await fs.promises.access(jsonlPath, fs.constants.F_OK);
|
||||
} catch {
|
||||
const candidateDirs = [
|
||||
path.join(getProjectsBasePath(), baseDir),
|
||||
// Claude Code encodes underscores as hyphens in project directory names;
|
||||
// our encodePath only handles slashes. Try the underscore-to-hyphen variant.
|
||||
const altBaseDir = baseDir.replace(/_/g, '-');
|
||||
if (altBaseDir !== baseDir) {
|
||||
const altPath = path.join(
|
||||
getProjectsBasePath(),
|
||||
altBaseDir,
|
||||
`${config.leadSessionId}.jsonl`
|
||||
);
|
||||
try {
|
||||
await fs.promises.access(altPath, fs.constants.F_OK);
|
||||
jsonlPath = altPath;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
...(baseDir.includes('_')
|
||||
? [path.join(getProjectsBasePath(), baseDir.replace(/_/g, '-'))]
|
||||
: []),
|
||||
];
|
||||
|
||||
return [...new Set(candidateDirs)];
|
||||
}
|
||||
|
||||
private async getLeadSessionJsonlPaths(projectPath: string): Promise<Map<string, string>> {
|
||||
const jsonlPaths = new Map<string, string>();
|
||||
for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
} 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).
|
||||
// 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;
|
||||
|
||||
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 buffer = Buffer.alloc(scanBytes);
|
||||
await handle.read(buffer, 0, scanBytes, start);
|
||||
|
|
@ -1314,13 +1348,22 @@ export class TeamDataService {
|
|||
const toolCalls = toolCallsList.length > 0 ? toolCallsList : 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
|
||||
.slice(0, 50)
|
||||
.replace(/[^\p{L}\p{N}]/gu, '')
|
||||
.slice(0, 20);
|
||||
|
||||
const messageId = `lead-session-${timestamp}-${textPrefix}`;
|
||||
const messageId =
|
||||
stableMessageId ?? `lead-session-${leadSessionId}-${timestamp}-${textPrefix}`;
|
||||
if (seenMessageIds.has(messageId)) continue;
|
||||
seenMessageIds.add(messageId);
|
||||
|
||||
|
|
@ -1330,15 +1373,15 @@ export class TeamDataService {
|
|||
timestamp,
|
||||
read: true,
|
||||
source: 'lead_session',
|
||||
leadSessionId: config.leadSessionId,
|
||||
leadSessionId,
|
||||
messageId,
|
||||
toolSummary,
|
||||
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;
|
||||
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.
|
||||
textsReversed.reverse();
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -557,7 +557,12 @@ function buildPersistentLeadContext(opts: {
|
|||
`\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 - 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 - 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.` +
|
||||
|
|
@ -741,6 +746,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
|||
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` +
|
||||
` - 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` +
|
||||
` - 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.
|
||||
|
|
@ -844,6 +850,7 @@ function buildLaunchPrompt(
|
|||
- Execute tasks sequentially and keep the board + user updated:
|
||||
- Identify the next READY task (pending, not blocked by incomplete dependencies).
|
||||
- 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).
|
||||
- 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".
|
||||
|
|
@ -3646,10 +3653,29 @@ export class TeamProvisioningService {
|
|||
* Used for both pre-ready (provisioning) and post-ready assistant text.
|
||||
* 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;
|
||||
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.
|
||||
const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
|
||||
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
|
||||
|
|
@ -3778,7 +3804,11 @@ export class TeamProvisioningService {
|
|||
) {
|
||||
const cleanText = stripAgentBlocks(text).trim();
|
||||
if (cleanText.length > 0) {
|
||||
this.pushLiveLeadTextMessage(run, cleanText);
|
||||
this.pushLiveLeadTextMessage(
|
||||
run,
|
||||
cleanText,
|
||||
this.getStableLeadThoughtMessageId(msg) ?? undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -3787,7 +3817,11 @@ export class TeamProvisioningService {
|
|||
if (!run.silentUserDmForward && !hasCapturedSendMessage) {
|
||||
const cleanText = stripAgentBlocks(text).trim();
|
||||
if (cleanText.length > 0) {
|
||||
this.pushLiveLeadTextMessage(run, cleanText);
|
||||
this.pushLiveLeadTextMessage(
|
||||
run,
|
||||
cleanText,
|
||||
this.getStableLeadThoughtMessageId(msg) ?? undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||
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 { createLogger } from '@shared/utils/logger';
|
||||
import { format } from 'date-fns';
|
||||
|
|
@ -394,6 +394,13 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
[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
|
||||
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
||||
useShallow((s) => ({
|
||||
|
|
@ -490,8 +497,8 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
|
||||
// Pre-process: convert @memberName to mention:// markdown links
|
||||
const displayText = useMemo(
|
||||
() => linkifyMentionsInMarkdown(baseDisplayText, memberColorMap),
|
||||
[baseDisplayText, memberColorMap]
|
||||
() => linkifyAllMentionsInMarkdown(baseDisplayText, memberColorMap, teamNames),
|
||||
[baseDisplayText, memberColorMap, teamNames]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { useStore } from '@renderer/store';
|
|||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||
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 { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||
import { format } from 'date-fns';
|
||||
|
|
@ -91,6 +91,13 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
[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
|
||||
const noiseLabel = useMemo(
|
||||
() => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
|
||||
|
|
@ -114,8 +121,8 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
|
||||
const displayContent = useMemo(() => {
|
||||
const stripped = stripAgentBlocks(teammateMessage.content);
|
||||
return linkifyMentionsInMarkdown(stripped, memberColorMap);
|
||||
}, [teammateMessage.content, memberColorMap]);
|
||||
return linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames);
|
||||
}, [teammateMessage.content, memberColorMap, teamNames]);
|
||||
|
||||
// Noise: minimal inline row (no card, no expand)
|
||||
if (noiseLabel) {
|
||||
|
|
|
|||
|
|
@ -246,31 +246,6 @@ function createViewerMarkdownComponents(
|
|||
}
|
||||
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://')) {
|
||||
const taskId = href.slice('task://'.length);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ interface TabConfig {
|
|||
|
||||
const tabs: TabConfig[] = [
|
||||
{ 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: 'advanced', label: 'Advanced', icon: Wrench },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal';
|
||||
import { Combobox, type ComboboxOption } from '../ui/combobox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
|
|
@ -513,15 +514,16 @@ export const GlobalTaskList = ({
|
|||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
showTeamName
|
||||
isNew={isNewTask(task)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)}
|
||||
/>
|
||||
<AnimatedHeightReveal animate={isNewTask(task)}>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
showTeamName
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)}
|
||||
/>
|
||||
</AnimatedHeightReveal>
|
||||
</TaskContextMenu>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -611,15 +613,16 @@ export const GlobalTaskList = ({
|
|||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
showTeamName
|
||||
isNew={isNewTask(task)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)}
|
||||
/>
|
||||
<AnimatedHeightReveal animate={isNewTask(task)}>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
showTeamName
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)}
|
||||
/>
|
||||
</AnimatedHeightReveal>
|
||||
</TaskContextMenu>
|
||||
))}
|
||||
|
||||
|
|
@ -677,17 +680,18 @@ export const GlobalTaskList = ({
|
|||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
hideTeamName
|
||||
isNew={isNewTask(task)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) =>
|
||||
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
||||
}
|
||||
/>
|
||||
<AnimatedHeightReveal animate={isNewTask(task)}>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
hideTeamName
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) =>
|
||||
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
||||
}
|
||||
/>
|
||||
</AnimatedHeightReveal>
|
||||
</TaskContextMenu>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -744,16 +748,17 @@ export const GlobalTaskList = ({
|
|||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
isNew={isNewTask(task)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) =>
|
||||
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
||||
}
|
||||
/>
|
||||
<AnimatedHeightReveal animate={isNewTask(task)}>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) =>
|
||||
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
||||
}
|
||||
/>
|
||||
</AnimatedHeightReveal>
|
||||
</TaskContextMenu>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,8 +58,6 @@ interface SidebarTaskItemProps {
|
|||
task: GlobalTask;
|
||||
hideTeamName?: 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 */
|
||||
renamingKey?: string | null;
|
||||
/** Called when rename is completed with Enter or blur */
|
||||
|
|
@ -74,7 +72,6 @@ export const SidebarTaskItem = ({
|
|||
task,
|
||||
hideTeamName,
|
||||
showTeamName,
|
||||
isNew,
|
||||
renamingKey,
|
||||
onRenameComplete,
|
||||
onRenameCancel,
|
||||
|
|
@ -147,12 +144,10 @@ export const SidebarTaskItem = ({
|
|||
|
||||
const showTeamRow = showTeamName && !hideTeamName;
|
||||
|
||||
const enterClass = isNew ? 'task-item-enter-animate' : '';
|
||||
|
||||
return (
|
||||
<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)' }}
|
||||
onClick={() => {
|
||||
if (!isRenaming) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getMessageTypeLabel,
|
||||
getStructuredMessageSummary,
|
||||
|
|
@ -22,7 +23,7 @@ import {
|
|||
parseStructuredAgentMessage,
|
||||
} from '@renderer/utils/agentMessageFormatting';
|
||||
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 {
|
||||
CROSS_TEAM_SENT_SOURCE,
|
||||
|
|
@ -305,6 +306,12 @@ export const ActivityItem = ({
|
|||
const { isLight } = useTheme();
|
||||
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))
|
||||
? message.timestamp
|
||||
: new Date(message.timestamp).toLocaleString();
|
||||
|
|
@ -374,7 +381,7 @@ export const ActivityItem = ({
|
|||
return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||
}, [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
|
||||
const parsedReply = useMemo(
|
||||
() => (strippedText ? parseMessageReply(strippedText) : null),
|
||||
|
|
@ -386,10 +393,10 @@ export const ActivityItem = ({
|
|||
if (!strippedText) return null;
|
||||
let result = highlightSystemLabels(strippedText, !!systemLabel);
|
||||
result = linkifyTaskIdsInMarkdown(result);
|
||||
if (memberColorMap && memberColorMap.size > 0)
|
||||
result = linkifyMentionsInMarkdown(result, memberColorMap);
|
||||
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0)
|
||||
result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames);
|
||||
return result;
|
||||
}, [strippedText, memberColorMap, systemLabel]);
|
||||
}, [strippedText, memberColorMap, teamNames, systemLabel]);
|
||||
|
||||
const rawSummary =
|
||||
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ export const AnimatedHeightReveal = ({
|
|||
const [isExpanded, setIsExpanded] = useState(
|
||||
() => !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(
|
||||
(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 () => {
|
||||
clearPendingAnimation();
|
||||
clearTimeout(overflowTimer);
|
||||
};
|
||||
}, [clearPendingAnimation, shouldAnimateOnMount, prefersReducedMotion]);
|
||||
|
||||
|
|
@ -97,7 +109,7 @@ export const AnimatedHeightReveal = ({
|
|||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ minHeight: 0, minWidth: 0, overflow: isExpanded ? 'visible' : 'hidden' }}>
|
||||
<div style={{ minHeight: 0, minWidth: 0, overflow: overflowVisible ? 'visible' : 'hidden' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
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 { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||
|
|
@ -227,15 +227,23 @@ const LeadThoughtItem = ({
|
|||
const previousHeightRef = useRef<number | null>(null);
|
||||
const animationFrameRef = 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(() => {
|
||||
let text = thought.text.replace(/\n/g, ' \n');
|
||||
text = linkifyTaskIdsInMarkdown(text);
|
||||
if (memberColorMap && memberColorMap.size > 0) {
|
||||
text = linkifyMentionsInMarkdown(text, memberColorMap);
|
||||
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
|
||||
text = linkifyAllMentionsInMarkdown(text, memberColorMap ?? new Map(), teamNames);
|
||||
}
|
||||
return text;
|
||||
}, [thought.text, memberColorMap]);
|
||||
}, [thought.text, memberColorMap, teamNames]);
|
||||
|
||||
const clearPendingAnimation = useCallback(() => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
|
|
@ -283,6 +291,7 @@ const LeadThoughtItem = ({
|
|||
startHeight: number,
|
||||
startOpacity: number
|
||||
): void => {
|
||||
initialAnimationCompletedRef.current = false;
|
||||
clearPendingAnimation();
|
||||
wrapper.style.transition = 'none';
|
||||
wrapper.style.overflow = 'hidden';
|
||||
|
|
@ -299,6 +308,7 @@ const LeadThoughtItem = ({
|
|||
|
||||
cleanupTimerRef.current = window.setTimeout(() => {
|
||||
resetWrapperStyles();
|
||||
initialAnimationCompletedRef.current = true;
|
||||
cleanupTimerRef.current = null;
|
||||
}, THOUGHT_HEIGHT_ANIMATION_MS + 40);
|
||||
};
|
||||
|
|
@ -307,7 +317,8 @@ const LeadThoughtItem = ({
|
|||
const previousHeight = previousHeightRef.current;
|
||||
previousHeightRef.current = nextHeight;
|
||||
|
||||
if (!shouldAnimate) {
|
||||
if (!shouldAnimateOnMount) {
|
||||
initialAnimationCompletedRef.current = true;
|
||||
resetWrapperStyles();
|
||||
return;
|
||||
}
|
||||
|
|
@ -316,6 +327,7 @@ const LeadThoughtItem = ({
|
|||
if (nextHeight > 0 && animateFromZero) {
|
||||
animateHeight(nextHeight, 0, 0);
|
||||
} else {
|
||||
initialAnimationCompletedRef.current = true;
|
||||
resetWrapperStyles();
|
||||
}
|
||||
return;
|
||||
|
|
@ -323,6 +335,13 @@ const LeadThoughtItem = ({
|
|||
|
||||
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;
|
||||
animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1);
|
||||
};
|
||||
|
|
@ -338,9 +357,10 @@ const LeadThoughtItem = ({
|
|||
return () => {
|
||||
observer.disconnect();
|
||||
clearPendingAnimation();
|
||||
initialAnimationCompletedRef.current = true;
|
||||
resetWrapperStyles();
|
||||
};
|
||||
}, [clearPendingAnimation, resetWrapperStyles, shouldAnimate]);
|
||||
}, [clearPendingAnimation, resetWrapperStyles, shouldAnimateOnMount]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
|
|
|
|||
|
|
@ -118,23 +118,31 @@ export const SendMessageDialog = ({
|
|||
|
||||
const selectedMember = members.find((m) => m.name === member);
|
||||
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 canAttach = supportsAttachments && canAddMore;
|
||||
|
||||
// Auto-switch to delegate when lead recipient is selected, but don't
|
||||
// override user's explicit choice on dialog open.
|
||||
const prevIsLeadRef = useRef(isLeadRecipient);
|
||||
const prevShouldAutoDelegateRef = useRef(shouldAutoDelegate);
|
||||
useEffect(() => {
|
||||
// Skip the initial mount — honour the sticky mode
|
||||
if (prevIsLeadRef.current === isLeadRecipient) return;
|
||||
prevIsLeadRef.current = isLeadRecipient;
|
||||
if (!canDelegate && actionMode === 'delegate') {
|
||||
setActionMode('do');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLeadRecipient) {
|
||||
// Skip the initial mount — honour the sticky mode
|
||||
if (prevShouldAutoDelegateRef.current === shouldAutoDelegate) return;
|
||||
prevShouldAutoDelegateRef.current = shouldAutoDelegate;
|
||||
|
||||
if (shouldAutoDelegate) {
|
||||
setActionMode('delegate');
|
||||
} else {
|
||||
setActionModeState((prev) => (prev === 'delegate' ? 'do' : prev));
|
||||
}
|
||||
}, [isLeadRecipient, setActionMode]);
|
||||
}, [actionMode, canDelegate, setActionMode, shouldAutoDelegate]);
|
||||
|
||||
const [pendingAutoClose, setPendingAutoClose] = useState(false);
|
||||
// Reset form on open transition (avoid setState in render)
|
||||
|
|
@ -470,7 +478,7 @@ export const SendMessageDialog = ({
|
|||
<ActionModeSelector
|
||||
value={actionMode}
|
||||
onChange={setActionMode}
|
||||
showDelegate={isLeadRecipient}
|
||||
showDelegate={canDelegate}
|
||||
/>
|
||||
}
|
||||
cornerAction={
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
|||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
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 draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||
const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`);
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [attachError, setAttachError] = useState<string | null>(null);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
|
@ -120,9 +125,10 @@ export const TaskCommentInput = ({
|
|||
const handleSubmit = useCallback(async () => {
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
|
||||
const text = replyTo
|
||||
? buildReplyBlock(replyTo.author, replyTo.text, trimmed || '(image)')
|
||||
: trimmed || '(image)';
|
||||
? buildReplyBlock(replyTo.author, replyTo.text, serialized || '(image)')
|
||||
: serialized || '(image)';
|
||||
const attachments: CommentAttachmentPayload[] | undefined =
|
||||
pendingAttachments.length > 0
|
||||
? pendingAttachments.map((a) => ({
|
||||
|
|
@ -134,6 +140,7 @@ export const TaskCommentInput = ({
|
|||
: undefined;
|
||||
await addTaskComment(teamName, taskId, text, attachments);
|
||||
draft.clearDraft();
|
||||
chipDraft.clearChipDraft();
|
||||
setPendingAttachments([]);
|
||||
setAttachError(null);
|
||||
onClearReply();
|
||||
|
|
@ -147,6 +154,7 @@ export const TaskCommentInput = ({
|
|||
taskId,
|
||||
trimmed,
|
||||
draft,
|
||||
chipDraft,
|
||||
replyTo,
|
||||
onClearReply,
|
||||
pendingAttachments,
|
||||
|
|
@ -270,7 +278,11 @@ export const TaskCommentInput = ({
|
|||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
teamSuggestions={teamMentionSuggestions}
|
||||
projectPath={projectPath}
|
||||
chips={chipDraft.chips}
|
||||
onFileChipInsert={chipDraft.addChip}
|
||||
onChipRemove={chipDraft.removeChip}
|
||||
onModEnter={() => void handleSubmit()}
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,17 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
|||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
||||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
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 { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
|
@ -77,6 +80,7 @@ export const TaskCommentsSection = ({
|
|||
}: TaskCommentsSectionProps): React.JSX.Element => {
|
||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||
const addingComment = useStore((s) => s.addingComment);
|
||||
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||
const commentsRef = useMarkCommentsRead(teamName, taskId, comments);
|
||||
|
||||
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 chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`);
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
||||
const teamNamesForLinkify = useMemo(
|
||||
() => teamMentionSuggestions.map((t) => t.name),
|
||||
[teamMentionSuggestions]
|
||||
);
|
||||
|
||||
const cappedComments = useMemo(() => {
|
||||
if (comments.length <= MAX_COMMENTS_TO_RENDER) return comments;
|
||||
|
|
@ -139,19 +149,24 @@ export const TaskCommentsSection = ({
|
|||
|
||||
const trimmed = draft.value.trim();
|
||||
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 () => {
|
||||
if (!canSubmit) return;
|
||||
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);
|
||||
draft.clearDraft();
|
||||
chipDraft.clearChipDraft();
|
||||
setReplyTo(null);
|
||||
} catch {
|
||||
// Error is stored in addCommentError via store
|
||||
}
|
||||
}, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, replyTo]);
|
||||
}, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, chipDraft, replyTo]);
|
||||
|
||||
return (
|
||||
<div ref={commentsRef}>
|
||||
|
|
@ -288,7 +303,12 @@ export const TaskCommentsSection = ({
|
|||
<MarkdownViewer
|
||||
content={(() => {
|
||||
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;
|
||||
})()}
|
||||
maxHeight="max-h-none"
|
||||
|
|
@ -373,6 +393,11 @@ export const TaskCommentsSection = ({
|
|||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
teamSuggestions={teamMentionSuggestions}
|
||||
projectPath={projectPath}
|
||||
chips={chipDraft.chips}
|
||||
onFileChipInsert={chipDraft.addChip}
|
||||
onChipRemove={chipDraft.removeChip}
|
||||
onModEnter={() => void handleSubmit()}
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,9 @@ export const MessageComposer = ({
|
|||
const selectedMember = members.find((m) => m.name === recipient);
|
||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
||||
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;
|
||||
|
||||
|
|
@ -148,28 +150,32 @@ export const MessageComposer = ({
|
|||
// so we don't overwrite the persisted actionMode during initialization.
|
||||
// After draft loads, only auto-switch on subsequent recipient changes.
|
||||
const isInitializedRef = useRef(false);
|
||||
const prevIsLeadRef = useRef(isLeadRecipient);
|
||||
const prevShouldAutoDelegateRef = useRef(shouldAutoDelegate);
|
||||
useEffect(() => {
|
||||
if (!draftLoaded) return;
|
||||
|
||||
if (!canDelegate && actionMode === 'delegate') {
|
||||
setActionMode('do');
|
||||
return;
|
||||
}
|
||||
|
||||
// On first run after load, just record the baseline — don't overwrite
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
prevIsLeadRef.current = isLeadRecipient;
|
||||
prevShouldAutoDelegateRef.current = shouldAutoDelegate;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only react when isLeadRecipient actually changes
|
||||
if (isLeadRecipient === prevIsLeadRef.current) return;
|
||||
prevIsLeadRef.current = isLeadRecipient;
|
||||
// Only react when delegate availability actually changes
|
||||
if (shouldAutoDelegate === prevShouldAutoDelegateRef.current) return;
|
||||
prevShouldAutoDelegateRef.current = shouldAutoDelegate;
|
||||
|
||||
if (isLeadRecipient) {
|
||||
if (shouldAutoDelegate) {
|
||||
setActionMode('delegate');
|
||||
} else if (actionMode === 'delegate') {
|
||||
setActionMode('do');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLeadRecipient, draftLoaded]);
|
||||
}, [actionMode, canDelegate, draftLoaded, setActionMode, shouldAutoDelegate]);
|
||||
// NOTE: lead context ring disabled — usage formula is inaccurate
|
||||
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
|
||||
// const leadContext = useStore((s) =>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ import type {
|
|||
} from '@shared/types';
|
||||
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
type RecentHunkUndoAction = {
|
||||
filePath: string;
|
||||
originalIndex: number;
|
||||
at: number;
|
||||
};
|
||||
|
||||
interface ChangeReviewDialogProps {
|
||||
open: boolean;
|
||||
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)
|
||||
const lastHunkActionAtRef = 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 lastFileActionAtRef = useRef<number>(0);
|
||||
const removedNewFileUndoStackRef = useRef<
|
||||
|
|
@ -156,6 +164,16 @@ export const ChangeReviewDialog = ({
|
|||
const activeEditorViewRef = useRef<EditorView | 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
|
||||
useEffect(() => {
|
||||
activeFilePathRef.current = activeFilePath;
|
||||
|
|
@ -435,6 +453,11 @@ export const ChangeReviewDialog = ({
|
|||
hunkDecisionUndoStackRef.current[filePath] = [];
|
||||
}
|
||||
hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
|
||||
recentHunkUndoActionsRef.current.push({
|
||||
filePath,
|
||||
originalIndex,
|
||||
at: Date.now(),
|
||||
});
|
||||
},
|
||||
[setHunkDecision]
|
||||
);
|
||||
|
|
@ -447,6 +470,11 @@ export const ChangeReviewDialog = ({
|
|||
hunkDecisionUndoStackRef.current[filePath] = [];
|
||||
}
|
||||
hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
|
||||
recentHunkUndoActionsRef.current.push({
|
||||
filePath,
|
||||
originalIndex,
|
||||
at: Date.now(),
|
||||
});
|
||||
if (REVIEW_INSTANT_APPLY) {
|
||||
void applySingleFileDecision(teamName, filePath, taskId, memberName).then((result) => {
|
||||
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
|
||||
|
|
@ -757,11 +785,12 @@ export const ChangeReviewDialog = ({
|
|||
const target = e.target as Element | null;
|
||||
if (!target?.closest?.('.cm-editor')) return;
|
||||
|
||||
for (const view of editorViewMapRef.current.values()) {
|
||||
if (view.dom.contains(target)) {
|
||||
lastFocusedEditorRef.current = view;
|
||||
return;
|
||||
}
|
||||
const filePath = getEditorFilePathForTarget(target);
|
||||
if (!filePath) return;
|
||||
|
||||
const view = editorViewMapRef.current.get(filePath);
|
||||
if (view) {
|
||||
lastFocusedEditorRef.current = view;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -770,7 +799,34 @@ export const ChangeReviewDialog = ({
|
|||
document.removeEventListener('focusin', handleFocusIn);
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -827,6 +883,35 @@ export const ChangeReviewDialog = ({
|
|||
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,
|
||||
// we must also clear the persisted decision. Otherwise reopening the dialog will replay it.
|
||||
if (document.activeElement?.closest('.cm-editor')) {
|
||||
|
|
@ -841,6 +926,13 @@ export const ChangeReviewDialog = ({
|
|||
e.stopPropagation();
|
||||
undo(lastView);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,17 +58,33 @@ const MAX_PREVIEW_LINES = 12;
|
|||
const ChipFilePreview = ({
|
||||
chip,
|
||||
onOpenInEditor,
|
||||
onRevealFolder,
|
||||
}: {
|
||||
chip: InlineChip;
|
||||
onOpenInEditor?: (filePath: string) => void;
|
||||
onRevealFolder?: (folderPath: string) => void;
|
||||
}): React.JSX.Element => {
|
||||
const displayPath = chip.displayPath ?? chip.filePath;
|
||||
const isFolder = chip.isFolder === true;
|
||||
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="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
|
||||
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)]"
|
||||
|
|
@ -161,6 +177,7 @@ export const ChipInteractionLayer = ({
|
|||
}: ChipInteractionLayerProps): React.JSX.Element | null => {
|
||||
const [positions, setPositions] = React.useState<ChipPosition[]>([]);
|
||||
const revealFileInEditor = useStore((s) => s.revealFileInEditor);
|
||||
const revealFolderInEditor = useStore((s) => s.revealFolderInEditor);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (chips.length === 0) {
|
||||
|
|
@ -179,6 +196,7 @@ export const ChipInteractionLayer = ({
|
|||
<div style={{ transform: `translateY(-${scrollTop}px)` }}>
|
||||
{positions.map((pos) => {
|
||||
const isFileChip = pos.chip.fromLine == null;
|
||||
const isFolderChip = pos.chip.isFolder === true;
|
||||
return (
|
||||
<Tooltip key={pos.chip.id}>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -195,7 +213,11 @@ export const ChipInteractionLayer = ({
|
|||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
revealFileInEditor(pos.chip.filePath);
|
||||
if (isFolderChip) {
|
||||
revealFolderInEditor(pos.chip.filePath);
|
||||
} else {
|
||||
revealFileInEditor(pos.chip.filePath);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -215,7 +237,11 @@ export const ChipInteractionLayer = ({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-md p-0">
|
||||
{isFileChip ? (
|
||||
<ChipFilePreview chip={pos.chip} onOpenInEditor={revealFileInEditor} />
|
||||
<ChipFilePreview
|
||||
chip={pos.chip}
|
||||
onOpenInEditor={revealFileInEditor}
|
||||
onRevealFolder={revealFolderInEditor}
|
||||
/>
|
||||
) : (
|
||||
<ChipCodePreview chip={pos.chip} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,15 @@
|
|||
* Uses the same text as the textarea (transparent) to maintain pixel-perfect alignment.
|
||||
*
|
||||
* 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';
|
||||
|
||||
const CHIP_BG = 'rgba(139, 92, 246, 0.15)';
|
||||
const CHIP_TEXT = '#a78bfa';
|
||||
const FOLDER_CHIP_BG = 'rgba(45, 212, 191, 0.15)';
|
||||
const FOLDER_CHIP_TEXT = '#5eead4';
|
||||
|
||||
interface CodeChipBadgeProps {
|
||||
chip: InlineChip;
|
||||
|
|
@ -16,14 +19,16 @@ interface CodeChipBadgeProps {
|
|||
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 (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: CHIP_BG,
|
||||
color: CHIP_TEXT,
|
||||
backgroundColor: bg,
|
||||
color: text,
|
||||
borderRadius: '4px',
|
||||
boxShadow: `0 0 0 1.5px ${CHIP_BG}`,
|
||||
boxShadow: `0 0 0 1.5px ${bg}`,
|
||||
}}
|
||||
>
|
||||
{tokenText}
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
[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(
|
||||
(s: MentionSuggestion) => {
|
||||
const textarea = internalRef.current;
|
||||
|
|
@ -389,18 +389,51 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
const before = value.slice(0, replaceStart);
|
||||
const after = value.slice(replaceEnd);
|
||||
|
||||
const displayPath = s.relativePath ?? s.name;
|
||||
const insertion = `\`${displayPath}\` `;
|
||||
const newValue = before + insertion + after;
|
||||
onValueChange(newValue);
|
||||
dismiss();
|
||||
if (onFileChipInsert && onChipRemove) {
|
||||
// Chip mode: create folder InlineChip
|
||||
const chip = createChipFromSelection(
|
||||
{
|
||||
type: 'sendMessage',
|
||||
filePath: s.filePath ?? '',
|
||||
fromLine: null,
|
||||
toLine: null,
|
||||
selectedText: '',
|
||||
formattedContext: '',
|
||||
displayPath: s.relativePath,
|
||||
isFolder: true,
|
||||
},
|
||||
chips
|
||||
);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const cursor = before.length + insertion.length;
|
||||
textarea.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
if (chip) {
|
||||
const token = chipToken(chip);
|
||||
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 ---
|
||||
|
|
|
|||
|
|
@ -709,24 +709,6 @@ body {
|
|||
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 {
|
||||
from {
|
||||
max-height: 0;
|
||||
|
|
|
|||
|
|
@ -248,6 +248,8 @@ export interface EditorSlice {
|
|||
editorPendingRevealFile: string | null;
|
||||
/** Request to reveal a file in the editor. Opens editor overlay if needed. */
|
||||
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. */
|
||||
revealAndOpenFile: (filePath: string) => Promise<void>;
|
||||
clearPendingRevealFile: () => void;
|
||||
|
|
@ -313,6 +315,39 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
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: () => {
|
||||
set({ editorPendingRevealFile: null });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export interface InlineChip {
|
|||
language: string;
|
||||
/** Relative display path for file-level mentions */
|
||||
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 */
|
||||
export const CHIP_MARKER = '\u{1F4C4}'; // 📄
|
||||
/** Unicode marker for folder chips */
|
||||
export const FOLDER_CHIP_MARKER = '\u{1F4C1}'; // 📁
|
||||
|
||||
// =============================================================================
|
||||
// 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,
|
||||
* 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 {
|
||||
if (chip.isFolder) {
|
||||
return chip.displayPath ?? chip.fileName;
|
||||
}
|
||||
if (chip.fromLine == null || chip.toLine == null) {
|
||||
return chip.fileName;
|
||||
}
|
||||
|
|
@ -59,13 +66,19 @@ export function chipDisplayLabel(chip: InlineChip): string {
|
|||
* Must match EXACTLY in textarea and overlay for pixel-perfect alignment.
|
||||
*/
|
||||
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 {
|
||||
// Folder mention
|
||||
if (chip.isFolder) {
|
||||
const path = chip.displayPath ?? chip.filePath;
|
||||
return `**${path}** (folder)`;
|
||||
}
|
||||
// File-level mention — no code fence
|
||||
if (chip.fromLine == null || chip.toLine == null) {
|
||||
const path = chip.displayPath ?? chip.filePath;
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ export function createChipFromSelection(
|
|||
const isFileMention = !action.selectedText || action.fromLine == null || action.toLine == null;
|
||||
|
||||
if (isFileMention) {
|
||||
// File-level mention: deduplicate by filePath + null lines
|
||||
// File/folder-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 = getBasename(action.filePath) || 'file';
|
||||
const fileName = getBasename(action.filePath) || (action.isFolder ? 'folder' : 'file');
|
||||
return {
|
||||
id: `chip-${++chipCounter}-${Date.now()}`,
|
||||
filePath: action.filePath,
|
||||
|
|
@ -40,8 +40,9 @@ export function createChipFromSelection(
|
|||
fromLine: null,
|
||||
toLine: null,
|
||||
codeText: '',
|
||||
language: getCodeFenceLanguage(fileName),
|
||||
language: action.isFolder ? '' : getCodeFenceLanguage(fileName),
|
||||
displayPath: action.displayPath,
|
||||
isFolder: action.isFolder,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -254,4 +254,6 @@ export interface EditorSelectionAction {
|
|||
formattedContext: string;
|
||||
/** Relative display path for file-level mentions */
|
||||
displayPath?: string;
|
||||
/** Whether this action represents a folder (not a file) */
|
||||
isFolder?: boolean;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue