feat: enhance cross-team functionality and UI components

- Added isOnline property to CrossTeamTarget and updated CrossTeamService to sort targets based on online status.
- Enhanced MessageComposer to fetch and display online status of teams, improving user experience in cross-team messaging.
- Updated various UI components to reflect changes in team online status, including visual indicators in the MessagesPanel and MessageComposer.
- Improved error handling and validation messages in CreateTeamDialog and other forms for better user feedback.
- Refactored CSS for field-level validation to enhance visual consistency across forms.
- Updated utility functions to support new online status features in team management.
This commit is contained in:
iliya 2026-03-12 16:33:52 +02:00
parent 88722598ac
commit 19f2fa76d0
19 changed files with 242 additions and 59 deletions

View file

@ -32,6 +32,7 @@ export interface CrossTeamTarget {
color?: string;
leadName?: string;
leadColor?: string;
isOnline?: boolean;
}
export class CrossTeamService {
@ -202,10 +203,15 @@ export class CrossTeamService {
color: config.color,
leadName: lead?.name,
leadColor: lead?.color,
isOnline: this.provisioning?.isTeamAlive(entry) ?? false,
});
}
return targets;
return targets.sort((a, b) => {
if (a.isOnline && !b.isOnline) return -1;
if (!a.isOnline && b.isOnline) return 1;
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
});
}
async getOutbox(teamName: string): Promise<CrossTeamMessage[]> {

View file

@ -1036,16 +1036,17 @@ function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | u
}
/**
* Builds cliLogsTail from the line-buffered claudeLogLines array instead of the
* byte-capped stdoutBuffer/stderrBuffer ring buffers.
* Builds provisioning CLI logs 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).
* Returns the full launch log history preserved in claudeLogLines. Falls back
* to the legacy tail extraction only 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) {
@ -1053,7 +1054,7 @@ function extractCliLogsFromRun(run: ProvisioningRun): string | undefined {
if (joined.length === 0) {
return undefined;
}
return joined.slice(-UI_LOGS_TAIL_LIMIT);
return joined;
}
return extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
}

View file

@ -1099,6 +1099,7 @@ const electronAPI: ElectronAPI = {
color?: string;
leadName?: string;
leadColor?: string;
isOnline?: boolean;
}[]
>(CROSS_TEAM_LIST_TARGETS, excludeTeam);
},

View file

@ -28,6 +28,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
import { nameColorSet } from '@renderer/utils/projectColor';
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { FileText, UsersRound } from 'lucide-react';
import remarkGfm from 'remark-gfm';
@ -159,7 +160,8 @@ function hastToText(node: HastNode): string {
function createViewerMarkdownComponents(
searchCtx: SearchContext | null,
isLight = false
isLight = false,
teamColorByName: ReadonlyMap<string, string> = new Map()
): Components {
const hl = (children: React.ReactNode): React.ReactNode =>
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
@ -248,7 +250,14 @@ function createViewerMarkdownComponents(
return badge;
}
if (href?.startsWith('team://')) {
const colorSet = getTeamColorSet('blue');
let teamLabel = '';
try {
teamLabel = decodeURIComponent(href.slice('team://'.length));
} catch {
// malformed percent-encoding — fall back to deterministic name color
}
const teamColor = teamColorByName.get(teamLabel);
const colorSet = teamColor ? getTeamColorSet(teamColor) : nameColorSet(teamLabel, isLight);
const bg = getThemedBadge(colorSet, isLight);
return (
<span
@ -495,6 +504,20 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
const teams = useStore((s) => s.teams);
const teamColorByName = React.useMemo(() => {
const result = new Map<string, string>();
for (const team of teams) {
if (team.teamName) {
result.set(team.teamName, team.color ?? '');
}
if (team.displayName) {
result.set(team.displayName, team.color ?? '');
}
}
return result;
}, [teams]);
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
@ -647,10 +670,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
// 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 baseComponents = searchCtx
? createViewerMarkdownComponents(searchCtx, isLight)
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName)
: isLight
? createViewerMarkdownComponents(null, true)
: defaultComponents;
? createViewerMarkdownComponents(null, true, teamColorByName)
: createViewerMarkdownComponents(null, false, teamColorByName);
// When baseDir is set (editor preview), override img to load local files via IPC
const components = baseDir

View file

@ -31,7 +31,7 @@ export interface ProvisioningProgressBlockProps {
startedAt?: string;
/** PID of the CLI process */
pid?: number;
/** Tail of CLI logs */
/** CLI logs captured during launch */
cliLogsTail?: string;
/** Accumulated assistant text output for live preview */
assistantOutput?: string;
@ -262,7 +262,9 @@ export const ProvisioningProgressBlock = ({
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
CLI logs
</button>
{logsOpen ? <CliLogsRichView cliLogsTail={cliLogsTail} className="mt-1" /> : null}
{logsOpen ? (
<CliLogsRichView cliLogsTail={cliLogsTail} order="newest-first" className="mt-1" />
) : null}
</div>
) : null}
</div>

View file

@ -409,7 +409,15 @@ export const ActivityItem = ({
return result;
}, [strippedText, memberColorMap, teamNames, systemLabel]);
const crossTeamPreview = useMemo(() => {
if (!isCrossTeamAny || !strippedText) return '';
const oneLine = strippedText.replace(/\n+/g, ' ').trim();
if (!oneLine) return '';
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
}, [isCrossTeamAny, strippedText]);
const rawSummary = useMemo(() => {
if (crossTeamPreview) return crossTeamPreview;
const s = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';
if (s) return s;
// Fallback: use the beginning of message text as preview for plain-text messages
@ -417,7 +425,7 @@ export const ActivityItem = ({
if (!plain) return '';
const oneLine = plain.replace(/\n+/g, ' ');
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
}, [message.summary, structured, message.text]);
}, [crossTeamPreview, message.summary, structured, message.text]);
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
// Noise messages: minimal inline row

View file

@ -620,13 +620,15 @@ export const CreateTeamDialog = ({
const handleSubmit = (): void => {
if (existingTeamNames.includes(sanitizedTeamName)) {
setFieldErrors({ teamName: 'Team name already exists' });
setLocalError('Check form fields');
setLocalError('Team name already exists');
return;
}
const validation = validateRequest(request, { requireCwd: launchTeam });
if (!validation.valid) {
setFieldErrors(validation.errors ?? {});
setLocalError('Check form fields');
const errors = validation.errors ?? {};
setFieldErrors(errors);
const messages = Object.values(errors).filter(Boolean) as string[];
setLocalError(messages.join(' · ') || 'Check form fields');
return;
}
setFieldErrors({});
@ -676,8 +678,11 @@ export const CreateTeamDialog = ({
if (!prev.teamName) return prev;
// eslint-disable-next-line sonarjs/no-unused-vars -- destructured to omit teamName from rest
const { teamName: _teamName, ...rest } = prev;
if (!rest.members && !rest.cwd && localError === 'Check form fields') {
const remaining = Object.values(rest).filter(Boolean) as string[];
if (remaining.length === 0) {
setLocalError(null);
} else {
setLocalError(remaining.join(' · '));
}
return rest;
});
@ -789,17 +794,27 @@ export const CreateTeamDialog = ({
<Label htmlFor="team-name">Team name</Label>
<Input
id="team-name"
className="h-8 text-xs"
className={cn(
'h-8 text-xs',
(fieldErrors.teamName || existingTeamNames.includes(sanitizedTeamName)) &&
'border-[var(--field-error-border)] bg-[var(--field-error-bg)] focus-visible:ring-[var(--field-error-border)]'
)}
value={teamName}
onChange={(event) => handleTeamNameChange(event.target.value)}
placeholder="team-alpha"
/>
{existingTeamNames.includes(sanitizedTeamName) ? (
<p className="text-[11px] text-red-300">Team name already exists</p>
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
Team name already exists
</p>
) : validateTeamNameInline(teamName) ? (
<p className="text-[11px] text-red-300">{validateTeamNameInline(teamName)}</p>
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{validateTeamNameInline(teamName)}
</p>
) : fieldErrors.teamName ? (
<p className="text-[11px] text-red-300">{fieldErrors.teamName}</p>
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{fieldErrors.teamName}
</p>
) : null}
{sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
@ -1031,7 +1046,14 @@ export const CreateTeamDialog = ({
</div>
{activeError ? (
<p className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
<p
className="rounded border p-2 text-xs"
style={{
color: 'var(--field-error-text)',
borderColor: 'var(--field-error-border)',
backgroundColor: 'var(--field-error-bg)',
}}
>
{activeError}
</p>
) : null}

View file

@ -190,6 +190,10 @@ export const ProjectPathSelector = ({
</div>
</div>
</div>
{fieldError ? <p className="text-[11px] text-red-300">{fieldError}</p> : null}
{fieldError ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{fieldError}
</p>
) : null}
</div>
);

View file

@ -219,9 +219,13 @@ export const MembersEditorSection = ({
) : null}
</div>
{hasDuplicates ? (
<p className="text-[11px] text-red-300">Member names must be unique</p>
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
Member names must be unique
</p>
) : fieldError ? (
<p className="text-[11px] text-red-300">{fieldError}</p>
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{fieldError}
</p>
) : null}
</>
)}

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector';
@ -84,6 +85,7 @@ export const MessageComposer = ({
// Cross-team state
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
const [teamSelectorOpen, setTeamSelectorOpen] = useState(false);
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
const allCrossTeamTargets = useStore((s) => s.crossTeamTargets);
const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets);
@ -91,14 +93,52 @@ export const MessageComposer = ({
void fetchCrossTeamTargets();
}, [fetchCrossTeamTargets]);
const refreshAliveTeams = useCallback(async () => {
try {
const list = await api.teams.aliveList();
setAliveTeams(new Set(list));
} catch {
// best-effort
}
}, []);
useEffect(() => {
void refreshAliveTeams();
}, [refreshAliveTeams]);
useEffect(() => {
if (!teamSelectorOpen) return;
void refreshAliveTeams();
}, [teamSelectorOpen, refreshAliveTeams]);
// Always filter out current team on the UI side (store is global, shared across tabs)
const crossTeamTargets = useMemo(
() => allCrossTeamTargets.filter((t) => t.teamName !== teamName),
[allCrossTeamTargets, teamName]
);
const sortedCrossTeamTargets = useMemo(
() =>
crossTeamTargets
.map((target) => ({
...target,
isOnline: aliveTeams.has(target.teamName),
}))
.sort((a, b) => {
if (a.isOnline && !b.isOnline) return -1;
if (!a.isOnline && b.isOnline) return 1;
return (a.displayName || a.teamName).localeCompare(
b.displayName || b.teamName,
undefined,
{
sensitivity: 'base',
}
);
}),
[aliveTeams, crossTeamTargets]
);
const isCrossTeam = selectedTeam !== null;
const selectedTarget = crossTeamTargets.find((t) => t.teamName === selectedTeam);
const selectedTarget = sortedCrossTeamTargets.find((t) => t.teamName === selectedTeam);
const targetDisplayName = selectedTarget?.displayName ?? selectedTeam;
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.'
@ -393,7 +433,7 @@ export const MessageComposer = ({
)}
{/* Combined team + member selector */}
{crossTeamTargets.length > 0 ? (
{sortedCrossTeamTargets.length > 0 ? (
<div
className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors',
@ -414,13 +454,18 @@ export const MessageComposer = ({
{isCrossTeam ? (
<>
<span
className="inline-block size-2 shrink-0 rounded-full"
className={cn(
'inline-block size-2 shrink-0 rounded-full',
selectedTarget?.isOnline && 'animate-pulse'
)}
style={{
backgroundColor: selectedTarget
? selectedTarget.color
? getTeamColorSet(selectedTarget.color).border
: nameColorSet(selectedTarget.displayName).border
: undefined,
backgroundColor: selectedTarget?.isOnline
? '#22c55e'
: selectedTarget
? selectedTarget.color
? getTeamColorSet(selectedTarget.color).border
: nameColorSet(selectedTarget.displayName).border
: undefined,
}}
/>
<span className="max-w-[100px] truncate">{targetDisplayName}</span>
@ -472,7 +517,7 @@ export const MessageComposer = ({
<div className="my-1 h-px bg-[var(--color-border)]" />
{/* Other teams */}
{crossTeamTargets.map((target) => {
{sortedCrossTeamTargets.map((target) => {
const isSelected = selectedTeam === target.teamName;
return (
<button
@ -489,16 +534,34 @@ export const MessageComposer = ({
}}
>
<span
className="inline-block size-2 shrink-0 rounded-full"
className={cn(
'inline-block size-2 shrink-0 rounded-full',
target.isOnline && 'animate-pulse'
)}
style={{
backgroundColor: target.color
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
backgroundColor: target.isOnline
? '#22c55e'
: target.color
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
}}
title={target.isOnline ? 'Online' : 'Offline'}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[var(--color-text)]">
{target.displayName}
<div className="flex items-center gap-1.5">
<div className="truncate text-[var(--color-text)]">
{target.displayName}
</div>
<span
className={cn(
'shrink-0 text-[10px]',
target.isOnline
? 'text-green-400'
: 'text-[var(--color-text-muted)]'
)}
>
{target.isOnline ? 'online' : 'offline'}
</span>
</div>
{target.description ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">

View file

@ -110,6 +110,14 @@ export const MessagesPanel = ({
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
const [messagesCollapsed, setMessagesCollapsed] = useState(true);
const [statusBlockCollapsed, setStatusBlockCollapsed] = useState(false);
const [pendingRepliesNowMs, setPendingRepliesNowMs] = useState(() => Date.now());
useEffect(() => {
const intervalId = window.setInterval(() => {
setPendingRepliesNowMs(Date.now());
}, 1000);
return () => window.clearInterval(intervalId);
}, []);
const filteredMessages = useMemo(() => {
return filterTeamMessages(messages, {
@ -140,8 +148,8 @@ export const MessagesPanel = ({
}, [filteredMessages, readSet, markAllRead]);
const pendingCrossTeamReplies = useMemo(
() => computePendingCrossTeamReplies(messages),
[messages]
() => computePendingCrossTeamReplies(messages, pendingRepliesNowMs),
[messages, pendingRepliesNowMs]
);
/** Whether the Status block has any visible items (pending replies or active tasks). */

View file

@ -516,7 +516,7 @@ export const CodeMirrorDiffView = ({
pinToViewportRight(btnContainer, scroller);
};
// Find which chunk index the mouse is directly over (deleted or inserted area)
// Find which chunk index the mouse is directly over, including inline deleted text.
const findHoveredChunkIndex = (event: MouseEvent, view: EditorView): number => {
const el = document.elementFromPoint(event.clientX, event.clientY);
if (!el) return -1;
@ -525,7 +525,11 @@ export const CodeMirrorDiffView = ({
const all = view.dom.querySelectorAll('.cm-deletedChunk');
return [...all].indexOf(deletedChunk);
}
if (el.closest('.cm-changedLine, .cm-insertedLine')) {
if (
el.closest(
'.cm-changedLine, .cm-insertedLine, .cm-inlineChangedLine, .cm-changedText, .cm-deletedText'
)
) {
const allChunks = getChunks(view.state);
if (!allChunks) return -1;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });

View file

@ -139,6 +139,10 @@
/* Badges */
--badge-error-bg: #dc2626;
--badge-error-text: #ffffff;
/* Field-level validation */
--field-error-text: #fca5a5;
--field-error-border: rgba(239, 68, 68, 0.5);
--field-error-bg: rgba(127, 29, 29, 0.15);
--badge-warning-bg: #ca8a04;
--badge-warning-text: #ffffff;
--badge-success-bg: #16a34a;
@ -503,6 +507,10 @@
/* Badges - High contrast */
--badge-error-bg: #dc2626;
--badge-error-text: #ffffff;
/* Field-level validation */
--field-error-text: #b91c1c;
--field-error-border: rgba(220, 38, 38, 0.6);
--field-error-bg: rgba(254, 202, 202, 0.3);
--badge-warning-bg: #d97706;
--badge-warning-text: #ffffff;
--badge-success-bg: #16a34a;

View file

@ -388,6 +388,7 @@ export interface TeamSlice {
color?: string;
leadName?: string;
leadColor?: string;
isOnline?: boolean;
}[];
crossTeamTargetsLoading: boolean;
fetchCrossTeamTargets: () => Promise<void>;

View file

@ -6,6 +6,8 @@ export interface PendingCrossTeamReply {
conversationId?: string;
}
export const CROSS_TEAM_PENDING_REPLY_TTL_MS = 10_000;
function parseQualifiedTeamName(value: string | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
@ -16,7 +18,8 @@ function parseQualifiedTeamName(value: string | undefined): string | null {
}
export function computePendingCrossTeamReplies(
messages: InboxMessage[] | null | undefined
messages: InboxMessage[] | null | undefined,
nowMs = Date.now()
): PendingCrossTeamReply[] {
if (!messages || messages.length === 0) return [];
@ -67,14 +70,21 @@ export function computePendingCrossTeamReplies(
}
}
const isWithinPendingWindow = (sentAtMs: number): boolean =>
sentAtMs >= nowMs || nowMs - sentAtMs <= CROSS_TEAM_PENDING_REPLY_TTL_MS;
const exactPending = Array.from(latestSentByConversation.values()).filter(
({ conversationId, sentAtMs }) =>
sentAtMs > (latestInboundByConversation.get(conversationId) ?? 0)
sentAtMs > (latestInboundByConversation.get(conversationId) ?? 0) &&
isWithinPendingWindow(sentAtMs)
);
const teamsCoveredExactly = new Set(exactPending.map((entry) => entry.teamName));
const legacyPending = Array.from(latestSentByTeam.entries())
.filter(([teamName]) => !teamsCoveredExactly.has(teamName))
.filter(([teamName, sentAtMs]) => sentAtMs > (latestInboundByTeam.get(teamName) ?? 0))
.filter(
([teamName, sentAtMs]) =>
sentAtMs > (latestInboundByTeam.get(teamName) ?? 0) && isWithinPendingWindow(sentAtMs)
)
.map(([teamName, sentAtMs]) => ({ teamName, sentAtMs }))
.sort((a, b) => b.sentAtMs - a.sentAtMs);

View file

@ -1,4 +1,8 @@
import { getMemberColorByName, MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
import {
getMemberColorByName,
MEMBER_COLOR_PALETTE,
normalizeMemberColorName,
} from '@shared/constants/memberColors';
import type {
LeadActivityState,
@ -167,7 +171,7 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
const paletteSize = MEMBER_COLOR_PALETTE.length;
for (const member of active) {
let color = member.color;
let color = member.color ? normalizeMemberColorName(member.color) : undefined;
if (!color || usedColors.has(color)) {
// Deterministic fallback: hash the member name to a palette color.
// If that color is already taken, linear-probe for the next free one.
@ -190,7 +194,12 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
}
for (let i = 0; i < removed.length; i++) {
map.set(removed[i].name, removed[i].color ?? getMemberColorByName(removed[i].name));
map.set(
removed[i].name,
removed[i].color
? normalizeMemberColorName(removed[i].color)
: getMemberColorByName(removed[i].name)
);
}
map.set('user', 'user');

View file

@ -1,7 +1,6 @@
/**
* Default color palette for team members 64 contrasting colors.
* Designed for high contrast on dark backgrounds.
* Colors cycle by index: member[i] gets MEMBER_COLOR_PALETTE[i % length].
* Default color palette for team members.
* Intentionally excludes purple-family tones for member UI.
*/
export const MEMBER_COLOR_PALETTE = [
// ── Primary & classic ──
@ -9,7 +8,6 @@ export const MEMBER_COLOR_PALETTE = [
'green',
'yellow',
'cyan',
'purple',
'red',
'orange',
'pink',
@ -73,8 +71,12 @@ export const MEMBER_COLOR_PALETTE = [
'steel',
'royal',
'cornflower',
] as const;
// ── Purple / pink family ──
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
const DISALLOWED_MEMBER_COLORS = new Set([
'purple',
'violet',
'plum',
'amethyst',
@ -83,9 +85,7 @@ export const MEMBER_COLOR_PALETTE = [
'magenta',
'fuchsia',
'berry',
] as const;
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
]);
export function getMemberColor(index: number): string {
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
@ -103,6 +103,13 @@ function hashStringToIndex(str: string): number {
return Math.abs(hash);
}
export function normalizeMemberColorName(colorName: string): string {
const normalized = colorName.trim().toLowerCase();
if (!normalized) return MEMBER_COLOR_PALETTE[0];
if (!DISALLOWED_MEMBER_COLORS.has(normalized)) return normalized;
return MEMBER_COLOR_PALETTE[hashStringToIndex(normalized) % MEMBER_COLOR_PALETTE.length];
}
/**
* Get a stable color for a member name.
* The color is deterministic same name always maps to the same palette entry,

View file

@ -552,6 +552,7 @@ export interface CrossTeamAPI {
color?: string;
leadName?: string;
leadColor?: string;
isOnline?: boolean;
}[]
>;
getOutbox: (teamName: string) => Promise<CrossTeamMessage[]>;

View file

@ -560,6 +560,7 @@ export interface TeamProvisioningProgress {
pid?: number;
error?: string;
warnings?: string[];
/** Provisioning CLI logs shown in the launch progress UI. */
cliLogsTail?: string;
/** Accumulated assistant text output during provisioning (for live preview). */
assistantOutput?: string;