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:
parent
88722598ac
commit
19f2fa76d0
19 changed files with 242 additions and 59 deletions
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1099,6 +1099,7 @@ const electronAPI: ElectronAPI = {
|
|||
color?: string;
|
||||
leadName?: string;
|
||||
leadColor?: string;
|
||||
isOnline?: boolean;
|
||||
}[]
|
||||
>(CROSS_TEAM_LIST_TARGETS, excludeTeam);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -388,6 +388,7 @@ export interface TeamSlice {
|
|||
color?: string;
|
||||
leadName?: string;
|
||||
leadColor?: string;
|
||||
isOnline?: boolean;
|
||||
}[];
|
||||
crossTeamTargetsLoading: boolean;
|
||||
fetchCrossTeamTargets: () => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -552,6 +552,7 @@ export interface CrossTeamAPI {
|
|||
color?: string;
|
||||
leadName?: string;
|
||||
leadColor?: string;
|
||||
isOnline?: boolean;
|
||||
}[]
|
||||
>;
|
||||
getOutbox: (teamName: string) => Promise<CrossTeamMessage[]>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue