diff --git a/electron.vite.config.1773089413142.mjs b/electron.vite.config.1773089413142.mjs deleted file mode 100644 index 63a288e9..00000000 --- a/electron.vite.config.1773089413142.mjs +++ /dev/null @@ -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 -}; diff --git a/package.json b/package.json index 30fb3b1d..d29ac95a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d25bb05..0bd4c861 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index 84c12274..95c07f1b 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -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, }); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4e80dfb1..8965218a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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); diff --git a/src/preload/index.ts b/src/preload/index.ts index 3b49bc3a..442d39c1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1065,10 +1065,9 @@ const electronAPI: ElectronAPI = { return invokeIpcWithResult(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(CROSS_TEAM_GET_OUTBOX, teamName); diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 9fbaf53b..ef14324a 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -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, - 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 }) => ( - - {children} - - ), + // 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 = ( + + {children} + + ); + if (memberName) { + return ( + + {badge} + + ); + } + return badge; + } + return ( + + {children} + + ); + }, strong: ({ children }) => ( @@ -324,6 +378,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. const { content, timestamp, id: groupId } = userGroup; const [isManuallyExpanded, setIsManuallyExpanded] = useState(false); const [validatedPaths, setValidatedPaths] = useState>({}); + 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): 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()), + [members] + ); + // Get search state for highlighting const { searchQuery, searchMatches, currentSearchIndex } = useStore( useShallow((s) => ({ @@ -397,13 +459,13 @@ const UserChatGroupInner = ({ userGroup }: Readonly): 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): 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 (
@@ -451,6 +519,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. remarkPlugins={[remarkGfm]} rehypePlugins={REHYPE_PLUGINS} components={userMarkdownComponents} + urlTransform={allowMentionProtocol} > {displayText} diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index 29c1fac9..da48665f 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -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 = ({ 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()), + [members] + ); + // Detect operational noise const noiseLabel = useMemo( () => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId), @@ -102,10 +112,10 @@ export const TeammateMessageItem: React.FC = ({ [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) { diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 02c18af0..79f1ea0e 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -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 = ( {children} ); + if (memberName) { + return ( + + {badge} + + ); + } + return badge; } if (href?.startsWith('task://')) { const taskId = href.slice('task://'.length); diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index ff431671..c4f40187 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -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 = ({ ); - if (onClick) { - return ( - - ); - } + // Skip hover card for "user" and "system" pseudo-members + const skipHoverCard = disableHoverCard || name === 'user' || name === 'system'; - return ( + const content = onClick ? ( + + ) : ( {!hideAvatar && avatar} {badge} ); + + if (skipHoverCard) { + return content; + } + + return ( + + {content} + + ); }; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 43546e81..4bfe84b0 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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 () => { diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 6b44835d..3fb92a23 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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 { - 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 `#` 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) => { diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index af8b4660..08753c37 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -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, diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 6d9af857..d69069f9 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -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 { - 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, diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx new file mode 100644 index 00000000..eb3653e2 --- /dev/null +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -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 ( + <> + + working on + + + ); +}; diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 6339c15a..8d5178e7 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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 = ({ ) : null} {currentTask ? ( - <> - - - working on - - - + ) : null} {!currentTask && isAwaitingReply ? ( <> diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx new file mode 100644 index 00000000..5f8b877c --- /dev/null +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -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 ( + + {children} + +
+ {/* Header: avatar + name + presence */} +
+
+ {member.name} + +
+
+
+ + {member.name} + + + {presenceLabel} + +
+ {roleLabel && ( + {roleLabel} + )} +
+
+ + {/* Current task */} + {currentTask && ( +
+ onOpenTask(currentTask) : undefined} + /> +
+ )} + + {/* Open profile button */} + +
+
+
+ ); +}; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index a4e4c5a2..7f5ffae1 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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 ( - - -
- - - - - - {Math.round(pct)} - -
-
- - Context: {Math.round(pct)}% ({(ctx.currentTokens / 1000).toFixed(1)}k /{' '} - {(ctx.contextWindow / 1000).toFixed(0)}k tokens) - -
- ); -}; - 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 ? ( <> - + {selectedTargetColor ? ( + + ) : ( + + )} {targetDisplayName} ) : ( - This team + <> + {currentTeamColor ? ( + + ) : null} + This team + )} @@ -444,6 +407,12 @@ export const MessageComposer = ({ setTeamSelectorOpen(false); }} > + {currentTeamColor ? ( + + ) : null} This team current @@ -629,7 +598,8 @@ export const MessageComposer = ({ minRows={2} maxRows={6} maxLength={MAX_TEXT_LENGTH} - disabled={sending || isProvisioning} + disabled={sending} + hintText={crossTeamHintText} cornerAction={
{/* NOTE: ContextRing disabled — usage formula is inaccurate */} @@ -645,15 +615,26 @@ export const MessageComposer = ({ Voice to text - + + + + + + + {isProvisioning && !sending ? ( + + Sending unavailable while team is launching + + ) : null} +
} footerRight={ diff --git a/src/renderer/components/ui/hover-card.tsx b/src/renderer/components/ui/hover-card.tsx new file mode 100644 index 00000000..76711994 --- /dev/null +++ b/src/renderer/components/ui/hover-card.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; + +export { HoverCard, HoverCardContent, HoverCardTrigger }; +/* eslint-enable react/jsx-props-no-spreading -- Standard Radix/shadcn pattern */ diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 9070a80e..144f19e4 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -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; refreshTeamData: (teamName: string) => Promise; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; - crossTeamTargets: { teamName: string; displayName: string; description?: string }[]; + crossTeamTargets: { + teamName: string; + displayName: string; + description?: string; + color?: string; + }[]; crossTeamTargetsLoading: boolean; fetchCrossTeamTargets: () => Promise; sendCrossTeamMessage: (request: CrossTeamSendRequest) => Promise; @@ -455,6 +464,9 @@ export const createTeamSlice: StateCreator = (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) => { diff --git a/src/renderer/utils/mentionLinkify.ts b/src/renderer/utils/mentionLinkify.ts new file mode 100644 index 00000000..43c221a5 --- /dev/null +++ b/src/renderer/utils/mentionLinkify.ts @@ -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 { + 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)})`; + }); +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 53d58fa3..9845bd34 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -544,7 +544,7 @@ export interface CrossTeamAPI { send: (request: CrossTeamSendRequest) => Promise; listTargets: ( excludeTeam?: string - ) => Promise<{ teamName: string; displayName: string; description?: string }[]>; + ) => Promise<{ teamName: string; displayName: string; description?: string; color?: string }[]>; getOutbox: (teamName: string) => Promise; } diff --git a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts index 1e9024cc..adf42576 100644 --- a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts +++ b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts @@ -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); });