refactor: remove deprecated electron.vite.config and update dependencies

- Deleted the obsolete electron.vite.config file to streamline the project structure.
- Updated package.json to remove the "agent-teams-controller" dependency and added "@radix-ui/react-hover-card".
- Enhanced the pnpm-lock.yaml to reflect the updated dependencies.
- Modified CrossTeamService and TeamProvisioningService to accommodate new color properties in team configurations.
- Improved message handling in various components to support mention links and member hover cards.
This commit is contained in:
iliya 2026-03-09 23:19:37 +02:00
parent 210b59884e
commit 1f4c550ed3
23 changed files with 586 additions and 321 deletions

View file

@ -1,105 +0,0 @@
// electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import react from "@vitejs/plugin-react";
import { readFileSync } from "fs";
import { resolve } from "path";
var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team";
var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8"));
var prodDeps = Object.keys(pkg.dependencies || {});
var bundledDeps = prodDeps.filter((d) => d !== "node-pty" && d !== "agent-teams-controller");
function nativeModuleStub() {
const STUB_ID = "\0native-stub";
return {
name: "native-module-stub",
resolveId(source) {
if (source.endsWith(".node")) return STUB_ID;
return null;
},
load(id) {
if (id === STUB_ID) return "export default {}";
return null;
}
};
}
var electron_vite_config_default = defineConfig({
main: {
plugins: [
externalizeDepsPlugin({
exclude: bundledDeps
}),
nativeModuleStub()
],
resolve: {
alias: {
"@main": resolve(__electron_vite_injected_dirname, "src/main"),
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
"@preload": resolve(__electron_vite_injected_dirname, "src/preload")
}
},
build: {
outDir: "dist-electron/main",
rollupOptions: {
input: {
index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"),
"team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts")
},
output: {
// CJS format so bundled deps can use __dirname/require.
// Use .cjs extension since package.json has "type": "module".
format: "cjs",
entryFileNames: "[name].cjs",
// Set UV_THREADPOOL_SIZE before any module code runs.
// Must be in the banner because ESM→CJS hoists imports above top-level code.
// On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher;
// with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock.
banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}`
}
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
"@preload": resolve(__electron_vite_injected_dirname, "src/preload"),
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
"@main": resolve(__electron_vite_injected_dirname, "src/main")
}
},
build: {
outDir: "dist-electron/preload",
rollupOptions: {
input: {
index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts")
},
output: {
format: "cjs",
entryFileNames: "[name].js"
}
}
}
},
renderer: {
optimizeDeps: {
include: ["@codemirror/language-data"]
},
resolve: {
alias: {
"@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"),
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
"@main": resolve(__electron_vite_injected_dirname, "src/main")
}
},
plugins: [react()],
build: {
rollupOptions: {
input: {
index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html")
}
}
}
}
});
export {
electron_vite_config_default as default
};

View file

@ -65,7 +65,6 @@
]
},
"dependencies": {
"agent-teams-controller": "workspace:*",
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-cpp": "^6.0.3",
@ -101,6 +100,7 @@
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
@ -111,6 +111,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"agent-teams-controller": "workspace:*",
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View file

@ -113,6 +113,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-hover-card':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -1552,6 +1555,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-hover-card@1.1.15':
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
@ -8083,6 +8099,23 @@ snapshots:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-id@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)

View file

@ -26,6 +26,7 @@ export interface CrossTeamTarget {
teamName: string;
displayName: string;
description?: string;
color?: string;
}
export class CrossTeamService {
@ -155,6 +156,7 @@ export class CrossTeamService {
teamName: entry,
displayName: config.name || entry,
description: config.description,
color: config.color,
});
}

View file

@ -575,8 +575,11 @@ Communication protocol (CRITICAL — you are running headless, no one sees your
- Keep cross-team requests high-signal: one focused request per topic, with clear next action and desired outcome.
- Before sending a follow-up on the same topic, check "cross_team_get_outbox" so you do not resend the same request unnecessarily.
- If you receive a message that is clearly from another team (for example prefixed with "[${CROSS_TEAM_PREFIX_TAG} ...]"), treat it as an actionable cross-team request and respond to the originating team with "cross_team_send" when a reply, decision, or status update is needed.
- When a cross-team request arrives, do NOT appear silent: first emit a brief plain-text status update visible in your own team's Messages/Activity (for example: "Accepted cross-team request from @other-team. Investigating and delegating now."), then do the research, task creation, or delegation work.
- For cross-team work, your canonical progress trail should be team-visible first. Use plain text updates, task comments, and task state changes so your own team can see what is happening.
- Do not wait silently on another team: if cross-team coordination is blocking progress, send the request promptly, then continue any useful local work that does not depend on that answer.
- After a meaningful cross-team exchange, update the relevant task or plan context so your team retains the decision, dependency, or answer.
- Reply to the requesting team when a concrete answer, decision, blocker, or status update is ready. Do NOT default to messaging "user" for cross-team coordination unless the human explicitly asked to be kept informed or the update is clearly human-relevant.
- Golden format for cross-team requests: include (1) brief context, (2) the concrete ask, (3) why your team needs that team specifically, (4) the expected output or decision, and (5) any deadline or blocking impact if relevant.
- Golden format for cross-team replies: answer the concrete ask first, then include the decision, recommendation, or status, and finally any important caveats, next steps, or handoff expectations.
- Do NOT use cross-team messaging when your own team can answer the question locally, when no action/decision is required, when you are only thinking out loud, or when a task update belongs on your own board instead of another team's inbox.
@ -950,8 +953,31 @@ function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | u
return trimmed.slice(-UI_LOGS_TAIL_LIMIT);
}
/**
* Builds cliLogsTail from the line-buffered claudeLogLines array instead of the
* byte-capped stdoutBuffer/stderrBuffer ring buffers.
*
* claudeLogLines already contains [stdout]/[stderr] markers and individual lines
* in chronological order (up to CLAUDE_LOG_LINES_LIMIT = 50 000 lines), so it
* does not suffer from the 64 KB ring-buffer truncation that causes the raw
* stdoutBuffer to lose older assistant messages.
*
* Falls back to the legacy extractLogsTail when claudeLogLines is empty (e.g.
* early in provisioning before any output has been line-split).
*/
function extractCliLogsFromRun(run: ProvisioningRun): string | undefined {
if (run.claudeLogLines.length > 0) {
const joined = run.claudeLogLines.join('\n').trim();
if (joined.length === 0) {
return undefined;
}
return joined.slice(-UI_LOGS_TAIL_LIMIT);
}
return extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
}
function emitLogsProgress(run: ProvisioningRun): void {
const logsTail = extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
const logsTail = extractCliLogsFromRun(run);
const assistantOutput =
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n\n') : undefined;
@ -1265,7 +1291,6 @@ export class TeamProvisioningService {
private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000;
private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000;
private static readonly LEAD_TEXT_MIN_LENGTH = 30;
private emitLeadContextUsage(run: ProvisioningRun): void {
if (!run.leadContextUsage || !run.provisioningComplete) return;
@ -1506,7 +1531,7 @@ export class TeamProvisioningService {
const progress = updateProgress(run, 'failed', `${hint} failed — ${statusLabel}`, {
error: `Claude CLI reported ${statusLabel} during startup. The team was not started.`,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
@ -1541,7 +1566,7 @@ export class TeamProvisioningService {
error:
'Claude CLI is not authenticated. Run `claude auth login` (or start `claude` and run `/login`) ' +
'to authenticate, or set ANTHROPIC_API_KEY and try again.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);
@ -1664,7 +1689,7 @@ export class TeamProvisioningService {
const hint = run.isLaunch ? ' (launch)' : '';
const progress = updateProgress(run, 'failed', `Timed out waiting for CLI${hint}`, {
error: `Timed out waiting for CLI${hint}.`,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);
@ -1676,7 +1701,7 @@ export class TeamProvisioningService {
const hint = run.isLaunch ? ' (launch)' : '';
const progress = updateProgress(run, 'failed', `Failed to start Claude CLI${hint}`, {
error: error.message,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);
@ -1947,7 +1972,7 @@ export class TeamProvisioningService {
const progress = updateProgress(run, 'failed', 'Timed out waiting for CLI', {
error:
'Timed out waiting for CLI. Run `claude` once in terminal to complete onboarding and try again.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);
@ -1958,7 +1983,7 @@ export class TeamProvisioningService {
child.once('error', (error) => {
const progress = updateProgress(run, 'failed', 'Failed to start Claude CLI', {
error: error.message,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);
@ -2310,7 +2335,7 @@ export class TeamProvisioningService {
const progress = updateProgress(run, 'failed', 'Timed out waiting for CLI (launch)', {
error: 'Timed out waiting for CLI during team launch.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);
@ -2321,7 +2346,7 @@ export class TeamProvisioningService {
child.once('error', (error) => {
const progress = updateProgress(run, 'failed', 'Failed to start Claude CLI (launch)', {
error: error.message,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);
@ -3023,19 +3048,6 @@ export class TeamProvisioningService {
this.liveLeadProcessMessages.set(teamName, list);
}
private removeLiveLeadProcessMessage(teamName: string, messageId: string): void {
const id = messageId.trim();
if (!id) return;
const list = this.liveLeadProcessMessages.get(teamName);
if (!list || list.length === 0) return;
const next = list.filter((m) => (m.messageId ?? '').trim() !== id);
if (next.length === 0) {
this.liveLeadProcessMessages.delete(teamName);
} else {
this.liveLeadProcessMessages.set(teamName, next);
}
}
/**
* Create an InboxMessage from assistant text and push it into the live cache.
* Used for both pre-ready (provisioning) and post-ready assistant text.
@ -3398,7 +3410,7 @@ export class TeamProvisioningService {
'CLI reported an error during provisioning',
{
error: errorMsg,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
}
);
run.onProgress(progress);
@ -3998,7 +4010,7 @@ export class TeamProvisioningService {
const readyMessage = 'Team launched — process alive and ready';
const progress = updateProgress(run, 'ready', readyMessage, {
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
@ -4067,7 +4079,7 @@ export class TeamProvisioningService {
`TeamCreate produced config.json under a different Claude root (${configProbe.configPath}). ` +
`This app is configured to read teams from ${configuredTeamsBasePath}. ` +
'Align the app Claude root setting with the CLI, then retry.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
run.processKilled = true;
@ -4082,7 +4094,7 @@ export class TeamProvisioningService {
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', {
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
// NOTE: do NOT remove from activeByTeam — process stays alive
@ -4297,7 +4309,7 @@ export class TeamProvisioningService {
: `Team process exited unexpectedly (code ${code ?? 'unknown'})`;
logger.info(`[${run.teamName}] ${message}`);
const progress = updateProgress(run, 'disconnected', message, {
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);
@ -4325,7 +4337,7 @@ export class TeamProvisioningService {
`TeamCreate produced config.json under a different Claude root (${configProbe.configPath}). ` +
`This app is configured to read teams from ${configuredTeamsBasePath}. ` +
'Align the app Claude root setting with the CLI, then retry.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);
@ -4362,7 +4374,7 @@ export class TeamProvisioningService {
'Team provisioned but process is no longer alive',
{
warnings,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
}
);
run.onProgress(progress);
@ -4388,7 +4400,7 @@ export class TeamProvisioningService {
: 'Team did not appear in team:list after provisioning';
const progress = updateProgress(run, 'failed', 'Provisioning failed validation', {
error: errorMessage,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);
@ -4398,7 +4410,7 @@ export class TeamProvisioningService {
const errorText = buildCliExitError(code, run.stdoutBuffer, run.stderrBuffer);
const progress = updateProgress(run, 'failed', 'Claude CLI exited with an error', {
error: errorText,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.cleanupRun(run);

View file

@ -1065,10 +1065,9 @@ const electronAPI: ElectronAPI = {
return invokeIpcWithResult<CrossTeamSendResult>(CROSS_TEAM_SEND, request);
},
listTargets: async (excludeTeam?: string) => {
return invokeIpcWithResult<{ teamName: string; displayName: string; description?: string }[]>(
CROSS_TEAM_LIST_TARGETS,
excludeTeam
);
return invokeIpcWithResult<
{ teamName: string; displayName: string; description?: string; color?: string }[]
>(CROSS_TEAM_LIST_TARGETS, excludeTeam);
},
getOutbox: async (teamName: string) => {
return invokeIpcWithResult<CrossTeamMessage[]>(CROSS_TEAM_GET_OUTBOX, teamName);

View file

@ -1,10 +1,15 @@
import React, { useEffect, useMemo, useState } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown';
import { api } from '@renderer/api';
import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTabUI } from '@renderer/hooks/useTabUI';
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 { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger';
import { format } from 'date-fns';
@ -107,6 +112,15 @@ function highlightPaths(
});
}
/**
* Custom URL transform that preserves mention:// protocol.
* react-markdown strips non-standard protocols by default.
*/
function allowMentionProtocol(url: string): string {
if (url.startsWith('mention://')) return url;
return defaultUrlTransform(url);
}
/**
* Creates markdown components for user bubble rendering.
* Uses chat-user CSS variables for consistent styling and wraps
@ -115,7 +129,8 @@ function highlightPaths(
*/
function createUserMarkdownComponents(
validatedPaths: Record<string, boolean>,
searchCtx: SearchContext | null
searchCtx: SearchContext | null,
isLight = false
): Components {
const userTextColor = 'var(--chat-user-text)';
@ -168,17 +183,56 @@ function createUserMarkdownComponents(
),
// Inline elements — no hl(); parent block element's hl() descends here
a: ({ href, children }) => (
<a
href={href}
className="no-underline hover:underline"
style={{ color: 'var(--chat-user-tag-text)' }}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// mention:// links render as colored badges with MemberHoverCard
a: ({ href, children }) => {
if (href?.startsWith('mention://')) {
const path = href.slice('mention://'.length);
const slashIdx = path.indexOf('/');
let color = '';
let memberName = '';
try {
color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : '';
memberName = slashIdx >= 0 ? decodeURIComponent(path.slice(slashIdx + 1)) : '';
} catch {
// malformed percent-encoding
}
const colorSet = getTeamColorSet(color);
const bg = getThemedBadge(colorSet, isLight);
const badge = (
<span
style={{
backgroundColor: bg,
color: colorSet.text,
borderRadius: '3px',
boxShadow: `0 0 0 1.5px ${bg}`,
fontSize: 'inherit',
cursor: 'default',
}}
>
{children}
</span>
);
if (memberName) {
return (
<MemberHoverCard name={memberName} color={color}>
{badge}
</MemberHoverCard>
);
}
return badge;
}
return (
<a
href={href}
className="no-underline hover:underline"
style={{ color: 'var(--chat-user-tag-text)' }}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
},
strong: ({ children }) => (
<strong className="font-semibold" style={{ color: userTextColor }}>
@ -324,6 +378,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
const { content, timestamp, id: groupId } = userGroup;
const [isManuallyExpanded, setIsManuallyExpanded] = useState(false);
const [validatedPaths, setValidatedPaths] = useState<Record<string, boolean>>({});
const { isLight } = useTheme();
// Get projectPath from per-tab session data, falling back to global state
const { tabId } = useTabUI();
@ -332,6 +387,13 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath;
});
// Get team members for @mention highlighting
const members = useStore((s) => s.selectedTeamData?.members);
const memberColorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
[members]
);
// Get search state for highlighting
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
useShallow((s) => ({
@ -397,13 +459,13 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
// Base markdown components (no search) — safe to memoize
const userMarkdownComponentsBase = useMemo(
() => createUserMarkdownComponents(effectiveValidatedPaths, null),
[effectiveValidatedPaths]
() => createUserMarkdownComponents(effectiveValidatedPaths, null, isLight),
[effectiveValidatedPaths, isLight]
);
// When search is active, create fresh each render (match counter is stateful and must start at 0)
// useMemo would cache stale closures when parent re-renders without search deps changing
const userMarkdownComponents = searchCtx
? createUserMarkdownComponents(effectiveValidatedPaths, searchCtx)
? createUserMarkdownComponents(effectiveValidatedPaths, searchCtx, isLight)
: userMarkdownComponentsBase;
// Auto-expand when search is active and this message has ANY matches.
@ -418,7 +480,13 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
const isExpanded = isManuallyExpanded || shouldAutoExpand;
// Determine display text
const displayText = isLongContent && !isExpanded ? stripped.slice(0, 500) + '...' : stripped;
const baseDisplayText = isLongContent && !isExpanded ? stripped.slice(0, 500) + '...' : stripped;
// Pre-process: convert @memberName to mention:// markdown links
const displayText = useMemo(
() => linkifyMentionsInMarkdown(baseDisplayText, memberColorMap),
[baseDisplayText, memberColorMap]
);
return (
<div className="flex justify-end">
@ -451,6 +519,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
remarkPlugins={[remarkGfm]}
rehypePlugins={REHYPE_PLUGINS}
components={userMarkdownComponents}
urlTransform={allowMentionProtocol}
>
{displayText}
</ReactMarkdown>

View file

@ -9,8 +9,11 @@ import {
} from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
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 { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { format } from 'date-fns';
@ -81,6 +84,13 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
const colors = getTeamColorSet(teammateMessage.color);
const { isLight } = useTheme();
// Get team members for @mention highlighting
const members = useStore((s) => s.selectedTeamData?.members);
const memberColorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
[members]
);
// Detect operational noise
const noiseLabel = useMemo(
() => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
@ -102,10 +112,10 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
[teammateMessage.replyToSummary]
);
const displayContent = useMemo(
() => stripAgentBlocks(teammateMessage.content),
[teammateMessage.content]
);
const displayContent = useMemo(() => {
const stripped = stripAgentBlocks(teammateMessage.content);
return linkifyMentionsInMarkdown(stripped, memberColorMap);
}, [teammateMessage.content, memberColorMap]);
// Noise: minimal inline row (no card, no expand)
if (noiseLabel) {

View file

@ -3,6 +3,7 @@ import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markd
import { api } from '@renderer/api';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard';
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
import {
CODE_BG,
@ -212,14 +213,16 @@ function createViewerMarkdownComponents(
const path = href.slice('mention://'.length);
const slashIdx = path.indexOf('/');
let color = '';
let memberName = '';
try {
color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : '';
memberName = slashIdx >= 0 ? decodeURIComponent(path.slice(slashIdx + 1)) : '';
} catch {
// malformed percent-encoding — use empty color
// malformed percent-encoding — use empty color/name
}
const colorSet = getTeamColorSet(color);
const bg = getThemedBadge(colorSet, isLight);
return (
const badge = (
<span
style={{
backgroundColor: bg,
@ -227,11 +230,20 @@ function createViewerMarkdownComponents(
borderRadius: '3px',
boxShadow: `0 0 0 1.5px ${bg}`,
fontSize: 'inherit',
cursor: 'default',
}}
>
{children}
</span>
);
if (memberName) {
return (
<MemberHoverCard name={memberName} color={color}>
{badge}
</MemberHoverCard>
);
}
return badge;
}
if (href?.startsWith('task://')) {
const taskId = href.slice('task://'.length);

View file

@ -7,6 +7,8 @@ import {
import { useTheme } from '@renderer/hooks/useTheme';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { MemberHoverCard } from './members/MemberHoverCard';
interface MemberBadgeProps {
name: string;
color?: string;
@ -15,12 +17,15 @@ interface MemberBadgeProps {
/** Hide the avatar icon, show only the name badge */
hideAvatar?: boolean;
onClick?: (name: string) => void;
/** Disable the hover card (e.g. inside MemberHoverCard itself to avoid nesting) */
disableHoverCard?: boolean;
}
/**
* Reusable member avatar + colored name badge.
* Avatar is rendered OUTSIDE the badge, to the left.
* When onClick is provided, both avatar and badge are clickable as one unit.
* Wrapped in MemberHoverCard to show member info on hover.
*/
export const MemberBadge = ({
name,
@ -28,6 +33,7 @@ export const MemberBadge = ({
size = 'sm',
hideAvatar,
onClick,
disableHoverCard,
}: MemberBadgeProps): React.JSX.Element => {
const colors = getTeamColorSet(color ?? '');
const { isLight } = useTheme();
@ -59,26 +65,35 @@ export const MemberBadge = ({
</span>
);
if (onClick) {
return (
<button
type="button"
className="inline-flex items-center gap-1 rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
onClick={(e) => {
e.stopPropagation();
onClick(name);
}}
>
{!hideAvatar && avatar}
{badge}
</button>
);
}
// Skip hover card for "user" and "system" pseudo-members
const skipHoverCard = disableHoverCard || name === 'user' || name === 'system';
return (
const content = onClick ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
onClick={(e) => {
e.stopPropagation();
onClick(name);
}}
>
{!hideAvatar && avatar}
{badge}
</button>
) : (
<span className="inline-flex items-center gap-1">
{!hideAvatar && avatar}
{badge}
</span>
);
if (skipHoverCard) {
return content;
}
return (
<MemberHoverCard name={name} color={color}>
{content}
</MemberHoverCard>
);
};

View file

@ -782,6 +782,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setPendingReviewRequest(null);
}, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]);
// Pick up pending member profile request from MemberHoverCard
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
useEffect(() => {
if (!pendingMemberProfile || !data) return;
const member = data.members.find((m) => m.name === pendingMemberProfile);
if (member) {
setSelectedMember(member);
}
useStore.getState().closeMemberProfile();
}, [pendingMemberProfile, data]);
const handleDeleteTask = useCallback(
(taskId: string) => {
void (async () => {

View file

@ -22,6 +22,7 @@ import {
parseStructuredAgentMessage,
} from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { linkifyMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import {
CROSS_TEAM_SENT_SOURCE,
@ -197,28 +198,6 @@ export function linkifyTaskIdsInMarkdown(text: string): string {
return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)');
}
/**
* Convert `@memberName` in plain text to markdown links with mention:// protocol.
* Encodes color in the URL so MarkdownViewer can render colored badges without extra context.
* Greedy match: longer names are tried first to avoid partial matches.
*/
export function linkifyMentionsInMarkdown(
text: string,
memberColorMap: Map<string, string>
): string {
if (memberColorMap.size === 0) return text;
// Sort by name length descending for greedy matching
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
// Build regex that matches @name at start or after whitespace, followed by boundary
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi');
return text.replace(pattern, (match, prefix: string, name: string) => {
// Find the canonical name (case-insensitive lookup)
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
const color = memberColorMap.get(canonical) ?? '';
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
});
}
/** Render `#<task-display-id>` in plain text as clickable inline elements with TaskTooltip. */
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => {

View file

@ -14,11 +14,12 @@ 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 { toMessageKey } from '@renderer/utils/teamMessageKey';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react';
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
import { linkifyTaskIdsInMarkdown } from './ActivityItem';
import {
AnimatedHeightReveal,
ENTRY_REVEAL_ANIMATION_MS,

View file

@ -17,6 +17,7 @@ import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessage
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 { MAX_TEXT_LENGTH } from '@shared/constants';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns';
@ -62,19 +63,6 @@ function linkifyTaskIdsInMarkdown(text: string): string {
return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)');
}
/** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */
function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, string>): string {
if (memberColorMap.size === 0) return text;
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi');
return text.replace(pattern, (_match, prefix: string, name: string) => {
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
const color = memberColorMap.get(canonical) ?? '';
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
});
}
export const TaskCommentsSection = ({
teamName,
taskId,

View file

@ -0,0 +1,52 @@
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { Loader2 } from 'lucide-react';
import type { TeamTaskWithKanban } from '@shared/types';
interface CurrentTaskIndicatorProps {
task: TeamTaskWithKanban;
borderColor: string;
/** Max characters for the subject before truncating */
maxSubjectLength?: number;
onOpenTask?: () => void;
}
/**
* Inline indicator showing a spinning loader + "working on" + task label button.
* Shared between MemberCard and MemberHoverCard.
*/
export const CurrentTaskIndicator = ({
task,
borderColor,
maxSubjectLength = 36,
onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => {
const truncated = task.subject.length > maxSubjectLength;
const subjectText = truncated ? `${task.subject.slice(0, maxSubjectLength)}` : task.subject;
return (
<>
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">working on</span>
<button
type="button"
className="min-w-0 shrink truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ border: `1px solid ${borderColor}40` }}
title="Open task"
onClick={(e) => {
e.stopPropagation();
onOpenTask?.();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
e.stopPropagation();
onOpenTask?.();
}
}}
>
{formatTaskDisplayLabel(task)} {subjectText}
</button>
</>
);
};

View file

@ -4,9 +4,11 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -102,35 +104,11 @@ export const MemberCard = ({
</span>
) : null}
{currentTask ? (
<>
<Loader2
className="size-3 shrink-0 animate-spin"
style={{ color: colors.border }}
/>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
working on
</span>
<button
type="button"
className="min-w-0 shrink truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ border: `1px solid ${colors.border}40` }}
title="Open task"
onClick={(e) => {
e.stopPropagation();
onOpenTask?.();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
e.stopPropagation();
onOpenTask?.();
}
}}
>
{formatTaskDisplayLabel(currentTask)} {currentTask.subject.slice(0, 36)}
{currentTask.subject.length > 36 ? '…' : ''}
</button>
</>
<CurrentTaskIndicator
task={currentTask}
borderColor={colors.border}
onOpenTask={onOpenTask}
/>
) : null}
{!currentTask && isAwaitingReply ? (
<>

View file

@ -0,0 +1,146 @@
import { Badge } from '@renderer/components/ui/badge';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card';
import {
getTeamColorSet,
getThemedBadge,
getThemedBorder,
getThemedText,
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { ExternalLink } from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
interface MemberHoverCardProps {
/** The member name to look up */
name: string;
/** Color key for the member */
color?: string;
/** Called when user clicks on the current task */
onOpenTask?: (task: TeamTaskWithKanban) => void;
children: React.ReactNode;
}
/**
* Wraps children in a HoverCard that shows member info on hover.
* Reads member data from the store (selectedTeamData.members).
* Falls back to a simple wrapper when member data is unavailable.
*/
export const MemberHoverCard = ({
name,
color,
onOpenTask,
children,
}: MemberHoverCardProps): React.JSX.Element => {
const { isLight } = useTheme();
const member = useStore((s) => s.selectedTeamData?.members.find((m) => m.name === name) ?? null);
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive);
const teamName = useStore((s) => s.selectedTeamName);
const leadActivity: LeadActivityState | undefined = useStore((s) =>
teamName ? s.leadActivityByTeam[teamName] : undefined
);
const openMemberProfile = useStore((s) => s.openMemberProfile);
const tasks = useStore((s) => s.selectedTeamData?.tasks);
if (!member) {
return <>{children}</>;
}
const colors = getTeamColorSet(color ?? member.color ?? '');
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
const presenceLabel = getPresenceLabel(
member,
isTeamAlive,
false,
member.agentType === 'team-lead' ? leadActivity : undefined
);
const dotClass = getMemberDotClass(
member,
isTeamAlive,
false,
member.agentType === 'team-lead' ? leadActivity : undefined
);
const currentTask: TeamTaskWithKanban | null =
member.currentTaskId && tasks
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
return (
<HoverCard openDelay={300} closeDelay={200}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent side="top" align="start" sideOffset={8}>
<div className="flex flex-col gap-2.5">
{/* Header: avatar + name + presence */}
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name, 64)}
alt={member.name}
className="size-10 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={presenceLabel}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className="truncate text-sm font-semibold"
style={{ color: getThemedText(colors, isLight) }}
>
{member.name}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0 text-[10px] font-normal leading-tight"
style={{
backgroundColor: getThemedBadge(colors, isLight),
color: getThemedText(colors, isLight),
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
}}
>
{presenceLabel}
</Badge>
</div>
{roleLabel && (
<span className="text-xs text-[var(--color-text-muted)]">{roleLabel}</span>
)}
</div>
</div>
{/* Current task */}
{currentTask && (
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
<CurrentTaskIndicator
task={currentTask}
borderColor={colors.border}
maxSubjectLength={28}
onOpenTask={onOpenTask ? () => onOpenTask(currentTask) : undefined}
/>
</div>
)}
{/* Open profile button */}
<button
type="button"
className="flex w-full items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
openMemberProfile(member.name);
}}
>
<ExternalLink size={12} />
Open profile
</button>
</div>
</HoverCardContent>
</HoverCard>
);
};

View file

@ -25,12 +25,7 @@ import {
} from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type {
AttachmentPayload,
LeadContextUsage,
ResolvedTeamMember,
SendMessageResult,
} from '@shared/types';
import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types';
interface MessageComposerProps {
teamName: string;
@ -48,58 +43,6 @@ interface MessageComposerProps {
onCrossTeamSend?: (toTeam: string, text: string, summary?: string) => void;
}
/** Circular progress indicator for lead context usage. */
const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
const size = 26;
const stroke = 2.5;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const pct = Math.min(ctx.percent, 100);
const offset = circumference - (pct / 100) * circumference;
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#3b82f6';
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="relative flex shrink-0 cursor-default items-center justify-center"
style={{ width: size, height: size }}
>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="var(--color-border)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-500"
/>
</svg>
<span className="absolute text-[8px] font-medium" style={{ color }}>
{Math.round(pct)}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top">
Context: {Math.round(pct)}% ({(ctx.currentTokens / 1000).toFixed(1)}k /{' '}
{(ctx.contextWindow / 1000).toFixed(0)}k tokens)
</TooltipContent>
</Tooltip>
);
};
export const MessageComposer = ({
teamName,
members,
@ -140,8 +83,12 @@ export const MessageComposer = ({
);
const isCrossTeam = selectedTeam !== null;
const targetDisplayName =
crossTeamTargets.find((t) => t.teamName === selectedTeam)?.displayName ?? selectedTeam;
const selectedTarget = crossTeamTargets.find((t) => t.teamName === selectedTeam);
const targetDisplayName = selectedTarget?.displayName ?? selectedTeam;
const selectedTargetColor = selectedTarget?.color;
const crossTeamHintText = isCrossTeam
? 'Tip: Cross-team messages go to the target team lead. If you want the reply to come back to your team lead instead of you, say that explicitly in the message.'
: undefined;
// Members load async with team data; keep recipient stable if valid, otherwise default to lead/first.
useEffect(() => {
@ -156,6 +103,7 @@ export const MessageComposer = ({
}, [members, recipient]);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const currentTeamColor = useStore((s) => s.selectedTeamData?.config.color ?? undefined);
const isProvisioning = useStore((s) =>
Object.values(s.provisioningRuns).some(
(run) =>
@ -421,11 +369,26 @@ export const MessageComposer = ({
>
{isCrossTeam ? (
<>
<ArrowRightLeft size={11} className="shrink-0" />
{selectedTargetColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: selectedTargetColor }}
/>
) : (
<ArrowRightLeft size={11} className="shrink-0" />
)}
<span className="max-w-[100px] truncate">{targetDisplayName}</span>
</>
) : (
<span className="text-[var(--color-text-secondary)]">This team</span>
<>
{currentTeamColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
<span className="text-[var(--color-text-secondary)]">This team</span>
</>
)}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button>
@ -444,6 +407,12 @@ export const MessageComposer = ({
setTeamSelectorOpen(false);
}}
>
{currentTeamColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
<span className="truncate text-[var(--color-text)]">This team</span>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
current
@ -629,7 +598,8 @@ export const MessageComposer = ({
minRows={2}
maxRows={6}
maxLength={MAX_TEXT_LENGTH}
disabled={sending || isProvisioning}
disabled={sending}
hintText={crossTeamHintText}
cornerAction={
<div className="flex items-center gap-2">
{/* NOTE: ContextRing disabled — usage formula is inaccurate */}
@ -645,15 +615,26 @@ export const MessageComposer = ({
</TooltipTrigger>
<TooltipContent side="top">Voice to text</TooltipContent>
</Tooltip>
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSend}
onClick={handleSend}
>
<Send size={12} />
Send
</button>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSend}
onClick={handleSend}
>
<Send size={12} />
Send
</button>
</span>
</TooltipTrigger>
{isProvisioning && !sending ? (
<TooltipContent side="top">
Sending unavailable while team is launching
</TooltipContent>
) : null}
</Tooltip>
</div>
}
footerRight={

View file

@ -0,0 +1,30 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard Radix/shadcn pattern */
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@renderer/lib/utils';
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ComponentRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardContent, HoverCardTrigger };
/* eslint-enable react/jsx-props-no-spreading -- Standard Radix/shadcn pattern */

View file

@ -263,6 +263,10 @@ export interface TeamSlice {
globalTaskDetail: GlobalTaskDetailState | null;
openGlobalTaskDetail: (teamName: string, taskId: string) => void;
closeGlobalTaskDetail: () => void;
/** Set by MemberHoverCard to signal TeamDetailView to open MemberDetailDialog */
pendingMemberProfile: string | null;
openMemberProfile: (memberName: string) => void;
closeMemberProfile: () => void;
/** Set by GlobalTaskDetailDialog to signal TeamDetailView to open ChangeReviewDialog */
pendingReviewRequest: { taskId: string; filePath?: string } | null;
setPendingReviewRequest: (req: { taskId: string; filePath?: string } | null) => void;
@ -296,7 +300,12 @@ export interface TeamSlice {
selectTeam: (teamName: string, opts?: { skipProjectAutoSelect?: boolean }) => Promise<void>;
refreshTeamData: (teamName: string) => Promise<void>;
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
crossTeamTargets: { teamName: string; displayName: string; description?: string }[];
crossTeamTargets: {
teamName: string;
displayName: string;
description?: string;
color?: string;
}[];
crossTeamTargetsLoading: boolean;
fetchCrossTeamTargets: () => Promise<void>;
sendCrossTeamMessage: (request: CrossTeamSendRequest) => Promise<void>;
@ -455,6 +464,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
clearProvisioningError: () => set({ provisioningError: null }),
kanbanFilterQuery: null,
globalTaskDetail: null,
pendingMemberProfile: null,
openMemberProfile: (memberName: string) => set({ pendingMemberProfile: memberName }),
closeMemberProfile: () => set({ pendingMemberProfile: null }),
pendingReviewRequest: null,
setPendingReviewRequest: (req) => set({ pendingReviewRequest: req }),
openGlobalTaskDetail: (teamName: string, taskId: string) => {

View file

@ -0,0 +1,36 @@
/**
* Shared utility for converting @memberName mentions in plain text
* to markdown links with mention:// protocol.
*
* Used by UserChatGroup, TeammateMessageItem, ActivityItem, TaskCommentsSection.
* MarkdownViewer already handles rendering mention:// links as colored badges.
*/
/**
* Convert `@memberName` in plain text to markdown links with mention:// protocol.
* Encodes color in the URL so MarkdownViewer can render colored badges without extra context.
* Greedy match: longer names are tried first to avoid partial matches.
*
* @param text - The plain text to process
* @param memberColorMap - Map of member name color key (e.g. "blue", "red")
* @returns Text with @mentions replaced by markdown links
*/
export function linkifyMentionsInMarkdown(
text: string,
memberColorMap: Map<string, string>
): string {
if (memberColorMap.size === 0) return text;
// Sort by name length descending for greedy matching
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
// eslint-disable-next-line no-useless-escape -- escaped chars needed for regex character class
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`, 'gi');
return text.replace(pattern, (_match, prefix: string, name: string) => {
// Find the canonical name (case-insensitive lookup)
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
const color = memberColorMap.get(canonical) ?? '';
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
});
}

View file

@ -544,7 +544,7 @@ export interface CrossTeamAPI {
send: (request: CrossTeamSendRequest) => Promise<CrossTeamSendResult>;
listTargets: (
excludeTeam?: string
) => Promise<{ teamName: string; displayName: string; description?: string }[]>;
) => Promise<{ teamName: string; displayName: string; description?: string; color?: string }[]>;
getOutbox: (teamName: string) => Promise<CrossTeamMessage[]>;
}

View file

@ -287,6 +287,9 @@ describe('TeamProvisioningService post-compact lifecycle', () => {
expect(text).toContain('Golden format for cross-team replies');
expect(text).toContain('Do NOT use cross-team messaging when your own team can answer');
expect(text).toContain('resolve it through your own task board and teammates first');
expect(text).toContain('do NOT appear silent');
expect(text).toContain("canonical progress trail should be team-visible first");
expect(text).toContain('Do NOT default to messaging "user" for cross-team coordination');
await svc.cancelProvisioning(runId);
});