feat: refine team provisioning and UI components for improved user experience

- Updated TeamProvisioningService to prevent duplicate display of provisioning narration in the Activity timeline.
- Enhanced CompactBoundary component with a cleaner design and improved interaction for expanding/collapsing content.
- Added new CSS variables for compact phase badge styling to enhance visual consistency.
- Modified TeamDetailView to include a collapsible status block for better organization of team information.
- Improved MessageComposer to reflect provisioning status in placeholders and notifications, enhancing user awareness during team launches.
- Updated ActivityTimeline to suppress session separators for reconnects within the same team, streamlining the user experience.
This commit is contained in:
iliya 2026-03-09 18:15:00 +02:00
parent 3f923c480e
commit afb6e2794f
8 changed files with 157 additions and 109 deletions

View file

@ -3154,14 +3154,9 @@ export class TeamProvisioningService {
} }
} }
} else { } else {
// Pre-ready: also push to live cache so Messages shows early narration // Pre-ready: provisioning narration is shown in the ProvisioningProgressBlock banner
// once team:getData becomes readable. The banner still uses provisioningOutputParts. // (via provisioningOutputParts). Do NOT push to live cache to avoid duplicate display
if (!run.silentUserDmForward && !hasCapturedSendMessage) { // and leaking internal provisioning monologue into the Activity timeline.
const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length > 0) {
this.pushLiveLeadTextMessage(run, cleanText);
}
}
} }
} }

View file

@ -6,8 +6,6 @@ import {
CODE_BORDER, CODE_BORDER,
COLOR_TEXT_MUTED, COLOR_TEXT_MUTED,
COLOR_TEXT_SECONDARY, COLOR_TEXT_SECONDARY,
TOOL_CALL_BG,
TOOL_CALL_BORDER,
TOOL_CALL_TEXT, TOOL_CALL_TEXT,
} from '@renderer/constants/cssVariables'; } from '@renderer/constants/cssVariables';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
@ -27,14 +25,8 @@ interface CompactBoundaryProps {
} }
/** /**
* CompactBoundary displays an interactive, collapsible marker indicating where * CompactBoundary displays a horizontal divider indicating where
* the conversation was compacted. * the conversation was compacted. Click to expand the compacted summary.
*
* Features:
* - Minimalist design with subtle border and hover states
* - Click to expand/collapse compacted content
* - Scrollable content area with enforced max-height
* - Linear/Notion-inspired aesthetics
*/ */
export const CompactBoundary = ({ export const CompactBoundary = ({
compactGroup, compactGroup,
@ -64,70 +56,71 @@ export const CompactBoundary = ({
const compactContent = getCompactContent(); const compactContent = getCompactContent();
return ( return (
<div className="my-6"> <div className="my-4">
{/* Collapsible Header - Amber/orange accent for distinction */} {/* Divider with centered label */}
<button <button
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
className="group flex w-full cursor-pointer items-center gap-3 overflow-hidden rounded-lg px-4 py-2.5 transition-all duration-200" className="group flex w-full cursor-pointer items-center transition-opacity hover:opacity-90"
style={{
backgroundColor: TOOL_CALL_BG,
border: `1px solid ${TOOL_CALL_BORDER}`,
}}
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-label="Toggle compacted content" aria-label="Toggle compacted content"
> >
{/* Icon Stack */} {/* Left line */}
<div <div className="h-px flex-1" style={{ backgroundColor: TOOL_CALL_TEXT, opacity: 0.3 }} />
className="flex shrink-0 items-center gap-2 transition-colors"
style={{ color: TOOL_CALL_TEXT }} {/* Center content */}
> <div className="flex shrink-0 items-center gap-2 px-3">
<ChevronRight <ChevronRight
size={16} size={12}
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} className="transition-transform duration-200"
style={{
color: TOOL_CALL_TEXT,
transform: isExpanded ? 'rotate(90deg)' : undefined,
}}
/> />
<Layers size={16} /> <Layers size={12} style={{ color: TOOL_CALL_TEXT }} />
<span
className="whitespace-nowrap text-[11px] font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
Context compacted
</span>
{/* Token delta */}
{compactGroup.tokenDelta && (
<span
className="whitespace-nowrap text-[10px] tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokens(compactGroup.tokenDelta.preCompactionTokens)} {' '}
{formatTokens(compactGroup.tokenDelta.postCompactionTokens)}
<span style={{ color: 'var(--diff-added-text)' }}>
{' '}
({formatTokens(Math.abs(compactGroup.tokenDelta.delta))} freed)
</span>
</span>
)}
{/* Phase badge */}
{compactGroup.startingPhaseNumber && (
<span
className="whitespace-nowrap rounded px-1.5 py-0.5 text-[10px]"
style={{
backgroundColor: 'var(--compact-phase-bg)',
color: 'var(--compact-phase-text)',
}}
>
Phase {compactGroup.startingPhaseNumber}
</span>
)}
{/* Timestamp */}
<span className="whitespace-nowrap text-[10px]" style={{ color: COLOR_TEXT_MUTED }}>
{format(timestamp, 'h:mm:ss a')}
</span>
</div> </div>
{/* Label */} {/* Right line */}
<span <div className="h-px flex-1" style={{ backgroundColor: TOOL_CALL_TEXT, opacity: 0.3 }} />
className="shrink-0 whitespace-nowrap text-sm font-medium transition-colors"
style={{ color: TOOL_CALL_TEXT }}
>
Compacted
</span>
{/* Token delta info */}
{compactGroup.tokenDelta && (
<span
className="ml-2 min-w-0 truncate text-xs tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokens(compactGroup.tokenDelta.preCompactionTokens)} {' '}
{formatTokens(compactGroup.tokenDelta.postCompactionTokens)}
<span style={{ color: '#4ade80' }}>
{' '}
({formatTokens(Math.abs(compactGroup.tokenDelta.delta))} freed)
</span>
</span>
)}
{/* Phase badge */}
{compactGroup.startingPhaseNumber && (
<span
className="shrink-0 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px]"
style={{ backgroundColor: 'rgba(99, 102, 241, 0.15)', color: '#818cf8' }}
>
Phase {compactGroup.startingPhaseNumber}
</span>
)}
{/* Timestamp */}
<span
className="ml-auto shrink-0 whitespace-nowrap text-xs transition-colors"
style={{ color: COLOR_TEXT_MUTED }}
>
{format(timestamp, 'h:mm:ss a')}
</span>
</button> </button>
{/* Expanded Content */} {/* Expanded Content */}
@ -144,7 +137,7 @@ export const CompactBoundary = ({
{/* Content - scrollable with left accent bar */} {/* Content - scrollable with left accent bar */}
<div <div
className="max-h-96 overflow-y-auto border-l-2 px-4 py-3" className="max-h-96 overflow-y-auto border-l-2 px-4 py-3"
style={{ borderColor: 'var(--chat-ai-border)' }} style={{ borderColor: TOOL_CALL_TEXT }}
> >
{compactContent ? ( {compactContent ? (
<ReactMarkdown <ReactMarkdown

View file

@ -37,6 +37,7 @@ import {
CheckCheck, CheckCheck,
ChevronsDownUp, ChevronsDownUp,
ChevronsUpDown, ChevronsUpDown,
ChevronRight,
Clock, Clock,
Code, Code,
Columns3, Columns3,
@ -316,6 +317,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}); });
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);
// Open editor overlay when a file reveal is requested (e.g. from chip click) // Open editor overlay when a file reveal is requested (e.g. from chip click)
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
@ -523,7 +525,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
); );
// Filter sessions to team-only using sessionHistory + leadSessionId // Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessions = useMemo(() => { const teamSessionIds = useMemo(() => {
const sessionIds = new Set<string>(); const sessionIds = new Set<string>();
if (data?.config.leadSessionId) { if (data?.config.leadSessionId) {
sessionIds.add(data.config.leadSessionId); sessionIds.add(data.config.leadSessionId);
@ -533,10 +535,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sessionIds.add(id); sessionIds.add(id);
} }
} }
return sessionIds;
}, [data?.config.leadSessionId, data?.config.sessionHistory]);
const teamSessions = useMemo(() => {
// If no session IDs known (backward compat), show all sessions // If no session IDs known (backward compat), show all sessions
if (sessionIds.size === 0) return sessions; if (teamSessionIds.size === 0) return sessions;
return sessions.filter((s) => sessionIds.has(s.id)); return sessions.filter((s) => teamSessionIds.has(s.id));
}, [sessions, data?.config.leadSessionId, data?.config.sessionHistory]); }, [sessions, teamSessionIds]);
// Auto-reset session filter if the selected session is no longer in teamSessions // Auto-reset session filter if the selected session is no longer in teamSessions
useEffect(() => { useEffect(() => {
@ -1563,17 +1569,33 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}} }}
/> />
<div className="mb-[35px]"> <div className="mb-[35px]">
<PendingRepliesBlock <button
members={data.members} type="button"
pendingRepliesByMember={pendingRepliesByMember} className="mb-1.5 flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onMemberClick={setSelectedMember} onClick={() => setStatusBlockCollapsed((prev) => !prev)}
/> aria-label={statusBlockCollapsed ? 'Expand status' : 'Collapse status'}
<ActiveTasksBlock >
members={data.members} <ChevronRight
tasks={data.tasks} size={12}
onMemberClick={setSelectedMember} className={`shrink-0 transition-transform duration-150 ${statusBlockCollapsed ? '' : 'rotate-90'}`}
onTaskClick={setSelectedTask} />
/> Status
</button>
{!statusBlockCollapsed && (
<>
<PendingRepliesBlock
members={data.members}
pendingRepliesByMember={pendingRepliesByMember}
onMemberClick={setSelectedMember}
/>
<ActiveTasksBlock
members={data.members}
tasks={data.tasks}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
/>
</>
)}
</div> </div>
<ActivityTimeline <ActivityTimeline
messages={filteredMessages} messages={filteredMessages}
@ -1583,6 +1605,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
allCollapsed={messagesCollapsed} allCollapsed={messagesCollapsed}
expandOverrides={expandedSet} expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride} onToggleExpandOverride={toggleExpandOverride}
teamSessionIds={teamSessionIds}
onMemberClick={setSelectedMember} onMemberClick={setSelectedMember}
onCreateTaskFromMessage={(subject, description) => { onCreateTaskFromMessage={(subject, description) => {
openCreateTaskDialog(subject, description); openCreateTaskDialog(subject, description);

View file

@ -110,7 +110,9 @@ export const TeamProvisioningBanner = ({
<div className="mb-3"> <div className="mb-3">
<div className="mb-2 flex items-center gap-2 rounded-md border border-[var(--step-done-border)] bg-[var(--step-done-bg)] px-3 py-2"> <div className="mb-2 flex items-center gap-2 rounded-md border border-[var(--step-done-border)] bg-[var(--step-done-bg)] px-3 py-2">
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" /> <CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
<p className="flex-1 text-xs text-[var(--step-success-text)]">Team launched process alive</p> <p className="flex-1 text-xs text-[var(--step-success-text)]">
Team launched teammates may still be starting
</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View file

@ -42,6 +42,12 @@ interface ActivityTimelineProps {
expandOverrides?: Set<string>; expandOverrides?: Set<string>;
/** Called when user toggles expand/collapse override on a specific message. */ /** Called when user toggles expand/collapse override on a specific message. */
onToggleExpandOverride?: (key: string) => void; onToggleExpandOverride?: (key: string) => void;
/**
* All session IDs belonging to this team (current + history).
* When two adjacent messages have different leadSessionId but both are in this set,
* the "New session" separator is suppressed (it's a reconnect, not a new session).
*/
teamSessionIds?: Set<string>;
} }
const VIEWPORT_THRESHOLD = 0.15; const VIEWPORT_THRESHOLD = 0.15;
@ -148,6 +154,7 @@ export const ActivityTimeline = ({
allCollapsed, allCollapsed,
expandOverrides, expandOverrides,
onToggleExpandOverride, onToggleExpandOverride,
teamSessionIds,
}: ActivityTimelineProps): React.JSX.Element => { }: ActivityTimelineProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
@ -354,18 +361,26 @@ export const ActivityTimeline = ({
const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]); const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]);
const currSessionId = getItemSessionId(item); const currSessionId = getItemSessionId(item);
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
sessionSeparator = ( // Suppress separator when both sessions belong to the same team
<div // (reconnects produce new session IDs but are not "new sessions" from user's perspective)
className="flex items-center gap-3" const isSameTeam =
style={{ paddingTop: 45, paddingBottom: 45 }} teamSessionIds &&
> teamSessionIds.has(prevSessionId) &&
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" /> teamSessionIds.has(currSessionId);
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400"> if (!isSameTeam) {
New session sessionSeparator = (
</span> <div
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" /> className="flex items-center gap-3"
</div> style={{ paddingTop: 45, paddingBottom: 45 }}
); >
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
New session
</span>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
</div>
);
}
} }
} }

View file

@ -118,6 +118,13 @@ export const MessageComposer = ({
}, [members, recipient]); }, [members, recipient]);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const isProvisioning = useStore((s) =>
Object.values(s.provisioningRuns).some(
(run) =>
run.teamName === teamName &&
!['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state)
)
);
const draft = useComposerDraft(teamName); const draft = useComposerDraft(teamName);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
@ -151,6 +158,7 @@ export const MessageComposer = ({
trimmed.length > 0 && trimmed.length > 0 &&
trimmed.length <= MAX_TEXT_LENGTH && trimmed.length <= MAX_TEXT_LENGTH &&
!sending && !sending &&
!isProvisioning &&
!attachmentsBlocked; !attachmentsBlocked;
// Track whether we initiated a send — clear draft only on confirmed success // Track whether we initiated a send — clear draft only on confirmed success
@ -335,7 +343,11 @@ export const MessageComposer = ({
)} )}
<div className="ml-auto flex shrink-0 items-center gap-2"> <div className="ml-auto flex shrink-0 items-center gap-2">
{!isTeamAlive ? ( {isProvisioning ? (
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
Launching...
</span>
) : !isTeamAlive ? (
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}> <span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
Team offline Team offline
</span> </span>
@ -448,7 +460,11 @@ export const MessageComposer = ({
<MentionableTextarea <MentionableTextarea
id={`compose-${teamName}`} id={`compose-${teamName}`}
placeholder="Write a message... (Enter to send, Shift+Enter for new line)" placeholder={
isProvisioning
? 'Team is launching... Please wait.'
: 'Write a message... (Enter to send, Shift+Enter for new line)'
}
value={draft.text} value={draft.text}
onValueChange={draft.setText} onValueChange={draft.setText}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
@ -460,7 +476,7 @@ export const MessageComposer = ({
minRows={2} minRows={2}
maxRows={6} maxRows={6}
maxLength={MAX_TEXT_LENGTH} maxLength={MAX_TEXT_LENGTH}
disabled={sending} disabled={sending || isProvisioning}
cornerAction={ cornerAction={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* NOTE: ContextRing disabled — usage formula is inaccurate */} {/* NOTE: ContextRing disabled — usage formula is inaccurate */}

View file

@ -249,6 +249,10 @@
--color-section-bg-open: rgba(255, 255, 255, 0.07); --color-section-bg-open: rgba(255, 255, 255, 0.07);
--color-section-hover: rgba(255, 255, 255, 0.08); --color-section-hover: rgba(255, 255, 255, 0.08);
--color-section-hover-open: rgba(255, 255, 255, 0.1); --color-section-hover-open: rgba(255, 255, 255, 0.1);
/* Compact boundary — phase badge */
--compact-phase-bg: rgba(99, 102, 241, 0.15);
--compact-phase-text: #818cf8;
} }
/* File icon glow — halo so dark icons stay visible on dark backgrounds */ /* File icon glow — halo so dark icons stay visible on dark backgrounds */
@ -502,6 +506,10 @@
--color-section-bg-open: rgba(0, 0, 0, 0.07); --color-section-bg-open: rgba(0, 0, 0, 0.07);
--color-section-hover: rgba(0, 0, 0, 0.08); --color-section-hover: rgba(0, 0, 0, 0.08);
--color-section-hover-open: rgba(0, 0, 0, 0.1); --color-section-hover-open: rgba(0, 0, 0, 0.1);
/* Compact boundary — phase badge */
--compact-phase-bg: rgba(67, 56, 202, 0.12);
--compact-phase-text: #4338ca;
} }
/* rehype-highlight (highlight.js) — map hljs classes to app theme variables */ /* rehype-highlight (highlight.js) — map hljs classes to app theme variables */

View file

@ -317,9 +317,6 @@ const KNOWN_DIRS = new Set([
'services', 'services',
'hooks', 'hooks',
'store', 'store',
'renderer',
'main',
'preload',
'public', 'public',
'assets', 'assets',
'config', 'config',
@ -340,7 +337,6 @@ const KNOWN_DIRS = new Set([
'middleware', 'middleware',
'api', 'api',
'common', 'common',
'shared',
'core', 'core',
'modules', 'modules',
'client', 'client',