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:
parent
210b59884e
commit
1f4c550ed3
23 changed files with 586 additions and 321 deletions
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
146
src/renderer/components/team/members/MemberHoverCard.tsx
Normal file
146
src/renderer/components/team/members/MemberHoverCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
30
src/renderer/components/ui/hover-card.tsx
Normal file
30
src/renderer/components/ui/hover-card.tsx
Normal 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 */
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
36
src/renderer/utils/mentionLinkify.ts
Normal file
36
src/renderer/utils/mentionLinkify.ts
Normal 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)})`;
|
||||
});
|
||||
}
|
||||
|
|
@ -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[]>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue