diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index 2561ba93..ae166e26 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -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 { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4a3473dc..79d26740 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 77ce5323..d67c1962 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1099,6 +1099,7 @@ const electronAPI: ElectronAPI = { color?: string; leadName?: string; leadColor?: string; + isOnline?: boolean; }[] >(CROSS_TEAM_LIST_TARGETS, excludeTeam); }, diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 07f42f62..f9502d7c 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -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 = 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 ( = ({ 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(); + 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 = ({ // 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 diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index b168d71d..ecf44b13 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -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 ? : } CLI logs - {logsOpen ? : null} + {logsOpen ? ( + + ) : null} ) : null} diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 85dd7186..b4c95688 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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 diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index c1f91875..8d5cdf78 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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 = ({ handleTeamNameChange(event.target.value)} placeholder="team-alpha" /> {existingTeamNames.includes(sanitizedTeamName) ? ( -

Team name already exists

+

+ Team name already exists +

) : validateTeamNameInline(teamName) ? ( -

{validateTeamNameInline(teamName)}

+

+ {validateTeamNameInline(teamName)} +

) : fieldErrors.teamName ? ( -

{fieldErrors.teamName}

+

+ {fieldErrors.teamName} +

) : null} {sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? (

@@ -1031,7 +1046,14 @@ export const CreateTeamDialog = ({ {activeError ? ( -

+

{activeError}

) : null} diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index b4abadcd..7d899f9c 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -190,6 +190,10 @@ export const ProjectPathSelector = ({ - {fieldError ?

{fieldError}

: null} + {fieldError ? ( +

+ {fieldError} +

+ ) : null} ); diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index da8f9800..3de38d21 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -219,9 +219,13 @@ export const MembersEditorSection = ({ ) : null} {hasDuplicates ? ( -

Member names must be unique

+

+ Member names must be unique +

) : fieldError ? ( -

{fieldError}

+

+ {fieldError} +

) : null} )} diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 2bfbbc65..84385a19 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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(null); const [teamSelectorOpen, setTeamSelectorOpen] = useState(false); + const [aliveTeams, setAliveTeams] = useState>(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 ? (
{targetDisplayName} @@ -472,7 +517,7 @@ export const MessageComposer = ({
{/* Other teams */} - {crossTeamTargets.map((target) => { + {sortedCrossTeamTargets.map((target) => { const isSelected = selectedTeam === target.teamName; return (