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; color?: string;
leadName?: string; leadName?: string;
leadColor?: string; leadColor?: string;
isOnline?: boolean;
} }
export class CrossTeamService { export class CrossTeamService {
@ -202,10 +203,15 @@ export class CrossTeamService {
color: config.color, color: config.color,
leadName: lead?.name, leadName: lead?.name,
leadColor: lead?.color, 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[]> { 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 * Builds provisioning CLI logs from the line-buffered claudeLogLines array
* byte-capped stdoutBuffer/stderrBuffer ring buffers. * instead of the byte-capped stdoutBuffer/stderrBuffer ring buffers.
* *
* claudeLogLines already contains [stdout]/[stderr] markers and individual lines * claudeLogLines already contains [stdout]/[stderr] markers and individual lines
* in chronological order (up to CLAUDE_LOG_LINES_LIMIT = 50 000 lines), so it * 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 * does not suffer from the 64 KB ring-buffer truncation that causes the raw
* stdoutBuffer to lose older assistant messages. * stdoutBuffer to lose older assistant messages.
* *
* Falls back to the legacy extractLogsTail when claudeLogLines is empty (e.g. * Returns the full launch log history preserved in claudeLogLines. Falls back
* early in provisioning before any output has been line-split). * 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 { function extractCliLogsFromRun(run: ProvisioningRun): string | undefined {
if (run.claudeLogLines.length > 0) { if (run.claudeLogLines.length > 0) {
@ -1053,7 +1054,7 @@ function extractCliLogsFromRun(run: ProvisioningRun): string | undefined {
if (joined.length === 0) { if (joined.length === 0) {
return undefined; return undefined;
} }
return joined.slice(-UI_LOGS_TAIL_LIMIT); return joined;
} }
return extractLogsTail(run.stdoutBuffer, run.stderrBuffer); return extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
} }

View file

@ -1099,6 +1099,7 @@ const electronAPI: ElectronAPI = {
color?: string; color?: string;
leadName?: string; leadName?: string;
leadColor?: string; leadColor?: string;
isOnline?: boolean;
}[] }[]
>(CROSS_TEAM_LIST_TARGETS, excludeTeam); >(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 { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
import { nameColorSet } from '@renderer/utils/projectColor';
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { FileText, UsersRound } from 'lucide-react'; import { FileText, UsersRound } from 'lucide-react';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@ -159,7 +160,8 @@ function hastToText(node: HastNode): string {
function createViewerMarkdownComponents( function createViewerMarkdownComponents(
searchCtx: SearchContext | null, searchCtx: SearchContext | null,
isLight = false isLight = false,
teamColorByName: ReadonlyMap<string, string> = new Map()
): Components { ): Components {
const hl = (children: React.ReactNode): React.ReactNode => const hl = (children: React.ReactNode): React.ReactNode =>
searchCtx ? highlightSearchInChildren(children, searchCtx) : children; searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
@ -248,7 +250,14 @@ function createViewerMarkdownComponents(
return badge; return badge;
} }
if (href?.startsWith('team://')) { 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); const bg = getThemedBadge(colorSet, isLight);
return ( return (
<span <span
@ -495,6 +504,20 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
const [showRaw, setShowRaw] = React.useState(false); const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme(); 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 isTooLarge = content.length > MAX_MARKDOWN_CHARS;
const disableHighlight = content.length > DISABLE_HIGHLIGHT_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) // 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 // useMemo would cache stale closures when parent re-renders without search deps changing
const baseComponents = searchCtx const baseComponents = searchCtx
? createViewerMarkdownComponents(searchCtx, isLight) ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName)
: isLight : isLight
? createViewerMarkdownComponents(null, true) ? createViewerMarkdownComponents(null, true, teamColorByName)
: defaultComponents; : createViewerMarkdownComponents(null, false, teamColorByName);
// When baseDir is set (editor preview), override img to load local files via IPC // When baseDir is set (editor preview), override img to load local files via IPC
const components = baseDir const components = baseDir

View file

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

View file

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

View file

@ -620,13 +620,15 @@ export const CreateTeamDialog = ({
const handleSubmit = (): void => { const handleSubmit = (): void => {
if (existingTeamNames.includes(sanitizedTeamName)) { if (existingTeamNames.includes(sanitizedTeamName)) {
setFieldErrors({ teamName: 'Team name already exists' }); setFieldErrors({ teamName: 'Team name already exists' });
setLocalError('Check form fields'); setLocalError('Team name already exists');
return; return;
} }
const validation = validateRequest(request, { requireCwd: launchTeam }); const validation = validateRequest(request, { requireCwd: launchTeam });
if (!validation.valid) { if (!validation.valid) {
setFieldErrors(validation.errors ?? {}); const errors = validation.errors ?? {};
setLocalError('Check form fields'); setFieldErrors(errors);
const messages = Object.values(errors).filter(Boolean) as string[];
setLocalError(messages.join(' · ') || 'Check form fields');
return; return;
} }
setFieldErrors({}); setFieldErrors({});
@ -676,8 +678,11 @@ export const CreateTeamDialog = ({
if (!prev.teamName) return prev; if (!prev.teamName) return prev;
// eslint-disable-next-line sonarjs/no-unused-vars -- destructured to omit teamName from rest // eslint-disable-next-line sonarjs/no-unused-vars -- destructured to omit teamName from rest
const { teamName: _teamName, ...rest } = prev; 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); setLocalError(null);
} else {
setLocalError(remaining.join(' · '));
} }
return rest; return rest;
}); });
@ -789,17 +794,27 @@ export const CreateTeamDialog = ({
<Label htmlFor="team-name">Team name</Label> <Label htmlFor="team-name">Team name</Label>
<Input <Input
id="team-name" 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} value={teamName}
onChange={(event) => handleTeamNameChange(event.target.value)} onChange={(event) => handleTeamNameChange(event.target.value)}
placeholder="team-alpha" placeholder="team-alpha"
/> />
{existingTeamNames.includes(sanitizedTeamName) ? ( {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) ? ( ) : 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 ? ( ) : 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} ) : null}
{sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? ( {sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? (
<p className="text-[11px] text-[var(--color-text-muted)]"> <p className="text-[11px] text-[var(--color-text-muted)]">
@ -1031,7 +1046,14 @@ export const CreateTeamDialog = ({
</div> </div>
{activeError ? ( {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} {activeError}
</p> </p>
) : null} ) : null}

View file

@ -190,6 +190,10 @@ export const ProjectPathSelector = ({
</div> </div>
</div> </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> </div>
); );

View file

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

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector'; import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector';
@ -84,6 +85,7 @@ export const MessageComposer = ({
// Cross-team state // Cross-team state
const [selectedTeam, setSelectedTeam] = useState<string | null>(null); const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
const [teamSelectorOpen, setTeamSelectorOpen] = useState(false); const [teamSelectorOpen, setTeamSelectorOpen] = useState(false);
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
const allCrossTeamTargets = useStore((s) => s.crossTeamTargets); const allCrossTeamTargets = useStore((s) => s.crossTeamTargets);
const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets); const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets);
@ -91,14 +93,52 @@ export const MessageComposer = ({
void fetchCrossTeamTargets(); void fetchCrossTeamTargets();
}, [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) // Always filter out current team on the UI side (store is global, shared across tabs)
const crossTeamTargets = useMemo( const crossTeamTargets = useMemo(
() => allCrossTeamTargets.filter((t) => t.teamName !== teamName), () => allCrossTeamTargets.filter((t) => t.teamName !== teamName),
[allCrossTeamTargets, 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 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 targetDisplayName = selectedTarget?.displayName ?? selectedTeam;
const crossTeamHintText = isCrossTeam 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.' ? '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 */} {/* Combined team + member selector */}
{crossTeamTargets.length > 0 ? ( {sortedCrossTeamTargets.length > 0 ? (
<div <div
className={cn( className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors', 'inline-flex items-center rounded-full border text-xs transition-colors',
@ -414,13 +454,18 @@ export const MessageComposer = ({
{isCrossTeam ? ( {isCrossTeam ? (
<> <>
<span <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={{ style={{
backgroundColor: selectedTarget backgroundColor: selectedTarget?.isOnline
? selectedTarget.color ? '#22c55e'
? getTeamColorSet(selectedTarget.color).border : selectedTarget
: nameColorSet(selectedTarget.displayName).border ? selectedTarget.color
: undefined, ? getTeamColorSet(selectedTarget.color).border
: nameColorSet(selectedTarget.displayName).border
: undefined,
}} }}
/> />
<span className="max-w-[100px] truncate">{targetDisplayName}</span> <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)]" /> <div className="my-1 h-px bg-[var(--color-border)]" />
{/* Other teams */} {/* Other teams */}
{crossTeamTargets.map((target) => { {sortedCrossTeamTargets.map((target) => {
const isSelected = selectedTeam === target.teamName; const isSelected = selectedTeam === target.teamName;
return ( return (
<button <button
@ -489,16 +534,34 @@ export const MessageComposer = ({
}} }}
> >
<span <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={{ style={{
backgroundColor: target.color backgroundColor: target.isOnline
? getTeamColorSet(target.color).border ? '#22c55e'
: nameColorSet(target.displayName).border, : target.color
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
}} }}
title={target.isOnline ? 'Online' : 'Offline'}
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-[var(--color-text)]"> <div className="flex items-center gap-1.5">
{target.displayName} <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> </div>
{target.description ? ( {target.description ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]"> <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 [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
const [messagesCollapsed, setMessagesCollapsed] = useState(true); const [messagesCollapsed, setMessagesCollapsed] = useState(true);
const [statusBlockCollapsed, setStatusBlockCollapsed] = useState(false); 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(() => { const filteredMessages = useMemo(() => {
return filterTeamMessages(messages, { return filterTeamMessages(messages, {
@ -140,8 +148,8 @@ export const MessagesPanel = ({
}, [filteredMessages, readSet, markAllRead]); }, [filteredMessages, readSet, markAllRead]);
const pendingCrossTeamReplies = useMemo( const pendingCrossTeamReplies = useMemo(
() => computePendingCrossTeamReplies(messages), () => computePendingCrossTeamReplies(messages, pendingRepliesNowMs),
[messages] [messages, pendingRepliesNowMs]
); );
/** Whether the Status block has any visible items (pending replies or active tasks). */ /** 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); 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 findHoveredChunkIndex = (event: MouseEvent, view: EditorView): number => {
const el = document.elementFromPoint(event.clientX, event.clientY); const el = document.elementFromPoint(event.clientX, event.clientY);
if (!el) return -1; if (!el) return -1;
@ -525,7 +525,11 @@ export const CodeMirrorDiffView = ({
const all = view.dom.querySelectorAll('.cm-deletedChunk'); const all = view.dom.querySelectorAll('.cm-deletedChunk');
return [...all].indexOf(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); const allChunks = getChunks(view.state);
if (!allChunks) return -1; if (!allChunks) return -1;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });

View file

@ -139,6 +139,10 @@
/* Badges */ /* Badges */
--badge-error-bg: #dc2626; --badge-error-bg: #dc2626;
--badge-error-text: #ffffff; --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-bg: #ca8a04;
--badge-warning-text: #ffffff; --badge-warning-text: #ffffff;
--badge-success-bg: #16a34a; --badge-success-bg: #16a34a;
@ -503,6 +507,10 @@
/* Badges - High contrast */ /* Badges - High contrast */
--badge-error-bg: #dc2626; --badge-error-bg: #dc2626;
--badge-error-text: #ffffff; --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-bg: #d97706;
--badge-warning-text: #ffffff; --badge-warning-text: #ffffff;
--badge-success-bg: #16a34a; --badge-success-bg: #16a34a;

View file

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

View file

@ -6,6 +6,8 @@ export interface PendingCrossTeamReply {
conversationId?: string; conversationId?: string;
} }
export const CROSS_TEAM_PENDING_REPLY_TTL_MS = 10_000;
function parseQualifiedTeamName(value: string | undefined): string | null { function parseQualifiedTeamName(value: string | undefined): string | null {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
const trimmed = value.trim(); const trimmed = value.trim();
@ -16,7 +18,8 @@ function parseQualifiedTeamName(value: string | undefined): string | null {
} }
export function computePendingCrossTeamReplies( export function computePendingCrossTeamReplies(
messages: InboxMessage[] | null | undefined messages: InboxMessage[] | null | undefined,
nowMs = Date.now()
): PendingCrossTeamReply[] { ): PendingCrossTeamReply[] {
if (!messages || messages.length === 0) return []; 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( const exactPending = Array.from(latestSentByConversation.values()).filter(
({ conversationId, sentAtMs }) => ({ conversationId, sentAtMs }) =>
sentAtMs > (latestInboundByConversation.get(conversationId) ?? 0) sentAtMs > (latestInboundByConversation.get(conversationId) ?? 0) &&
isWithinPendingWindow(sentAtMs)
); );
const teamsCoveredExactly = new Set(exactPending.map((entry) => entry.teamName)); const teamsCoveredExactly = new Set(exactPending.map((entry) => entry.teamName));
const legacyPending = Array.from(latestSentByTeam.entries()) const legacyPending = Array.from(latestSentByTeam.entries())
.filter(([teamName]) => !teamsCoveredExactly.has(teamName)) .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 })) .map(([teamName, sentAtMs]) => ({ teamName, sentAtMs }))
.sort((a, b) => b.sentAtMs - a.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 { import type {
LeadActivityState, LeadActivityState,
@ -167,7 +171,7 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
const paletteSize = MEMBER_COLOR_PALETTE.length; const paletteSize = MEMBER_COLOR_PALETTE.length;
for (const member of active) { for (const member of active) {
let color = member.color; let color = member.color ? normalizeMemberColorName(member.color) : undefined;
if (!color || usedColors.has(color)) { if (!color || usedColors.has(color)) {
// Deterministic fallback: hash the member name to a palette color. // Deterministic fallback: hash the member name to a palette color.
// If that color is already taken, linear-probe for the next free one. // 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++) { 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'); map.set('user', 'user');

View file

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

View file

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

View file

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