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

View file

@ -6,8 +6,6 @@ import {
CODE_BORDER,
COLOR_TEXT_MUTED,
COLOR_TEXT_SECONDARY,
TOOL_CALL_BG,
TOOL_CALL_BORDER,
TOOL_CALL_TEXT,
} from '@renderer/constants/cssVariables';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
@ -27,14 +25,8 @@ interface CompactBoundaryProps {
}
/**
* CompactBoundary displays an interactive, collapsible marker indicating where
* the conversation was compacted.
*
* 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
* CompactBoundary displays a horizontal divider indicating where
* the conversation was compacted. Click to expand the compacted summary.
*/
export const CompactBoundary = ({
compactGroup,
@ -64,70 +56,71 @@ export const CompactBoundary = ({
const compactContent = getCompactContent();
return (
<div className="my-6">
{/* Collapsible Header - Amber/orange accent for distinction */}
<div className="my-4">
{/* Divider with centered label */}
<button
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"
style={{
backgroundColor: TOOL_CALL_BG,
border: `1px solid ${TOOL_CALL_BORDER}`,
}}
className="group flex w-full cursor-pointer items-center transition-opacity hover:opacity-90"
aria-expanded={isExpanded}
aria-label="Toggle compacted content"
>
{/* Icon Stack */}
<div
className="flex shrink-0 items-center gap-2 transition-colors"
style={{ color: TOOL_CALL_TEXT }}
>
{/* Left line */}
<div className="h-px flex-1" style={{ backgroundColor: TOOL_CALL_TEXT, opacity: 0.3 }} />
{/* Center content */}
<div className="flex shrink-0 items-center gap-2 px-3">
<ChevronRight
size={16}
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
size={12}
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>
{/* Label */}
<span
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>
{/* Right line */}
<div className="h-px flex-1" style={{ backgroundColor: TOOL_CALL_TEXT, opacity: 0.3 }} />
</button>
{/* Expanded Content */}
@ -144,7 +137,7 @@ export const CompactBoundary = ({
{/* Content - scrollable with left accent bar */}
<div
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 ? (
<ReactMarkdown

View file

@ -37,6 +37,7 @@ import {
CheckCheck,
ChevronsDownUp,
ChevronsUpDown,
ChevronRight,
Clock,
Code,
Columns3,
@ -316,6 +317,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
});
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
const [messagesCollapsed, setMessagesCollapsed] = useState(true);
const [statusBlockCollapsed, setStatusBlockCollapsed] = useState(false);
// Open editor overlay when a file reveal is requested (e.g. from chip click)
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
const teamSessions = useMemo(() => {
const teamSessionIds = useMemo(() => {
const sessionIds = new Set<string>();
if (data?.config.leadSessionId) {
sessionIds.add(data.config.leadSessionId);
@ -533,10 +535,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
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 (sessionIds.size === 0) return sessions;
return sessions.filter((s) => sessionIds.has(s.id));
}, [sessions, data?.config.leadSessionId, data?.config.sessionHistory]);
if (teamSessionIds.size === 0) return sessions;
return sessions.filter((s) => teamSessionIds.has(s.id));
}, [sessions, teamSessionIds]);
// Auto-reset session filter if the selected session is no longer in teamSessions
useEffect(() => {
@ -1563,17 +1569,33 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}}
/>
<div className="mb-[35px]">
<PendingRepliesBlock
members={data.members}
pendingRepliesByMember={pendingRepliesByMember}
onMemberClick={setSelectedMember}
/>
<ActiveTasksBlock
members={data.members}
tasks={data.tasks}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
/>
<button
type="button"
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)]"
onClick={() => setStatusBlockCollapsed((prev) => !prev)}
aria-label={statusBlockCollapsed ? 'Expand status' : 'Collapse status'}
>
<ChevronRight
size={12}
className={`shrink-0 transition-transform duration-150 ${statusBlockCollapsed ? '' : 'rotate-90'}`}
/>
Status
</button>
{!statusBlockCollapsed && (
<>
<PendingRepliesBlock
members={data.members}
pendingRepliesByMember={pendingRepliesByMember}
onMemberClick={setSelectedMember}
/>
<ActiveTasksBlock
members={data.members}
tasks={data.tasks}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
/>
</>
)}
</div>
<ActivityTimeline
messages={filteredMessages}
@ -1583,6 +1605,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
teamSessionIds={teamSessionIds}
onMemberClick={setSelectedMember}
onCreateTaskFromMessage={(subject, description) => {
openCreateTaskDialog(subject, description);

View file

@ -110,7 +110,9 @@ export const TeamProvisioningBanner = ({
<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">
<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
variant="outline"
size="sm"

View file

@ -42,6 +42,12 @@ interface ActivityTimelineProps {
expandOverrides?: Set<string>;
/** Called when user toggles expand/collapse override on a specific message. */
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;
@ -148,6 +154,7 @@ export const ActivityTimeline = ({
allCollapsed,
expandOverrides,
onToggleExpandOverride,
teamSessionIds,
}: ActivityTimelineProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
@ -354,18 +361,26 @@ export const ActivityTimeline = ({
const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]);
const currSessionId = getItemSessionId(item);
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
sessionSeparator = (
<div
className="flex items-center gap-3"
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>
);
// Suppress separator when both sessions belong to the same team
// (reconnects produce new session IDs but are not "new sessions" from user's perspective)
const isSameTeam =
teamSessionIds &&
teamSessionIds.has(prevSessionId) &&
teamSessionIds.has(currSessionId);
if (!isSameTeam) {
sessionSeparator = (
<div
className="flex items-center gap-3"
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]);
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 colorMap = useMemo(() => buildMemberColorMap(members), [members]);
@ -151,6 +158,7 @@ export const MessageComposer = ({
trimmed.length > 0 &&
trimmed.length <= MAX_TEXT_LENGTH &&
!sending &&
!isProvisioning &&
!attachmentsBlocked;
// 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">
{!isTeamAlive ? (
{isProvisioning ? (
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
Launching...
</span>
) : !isTeamAlive ? (
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
Team offline
</span>
@ -448,7 +460,11 @@ export const MessageComposer = ({
<MentionableTextarea
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}
onValueChange={draft.setText}
suggestions={mentionSuggestions}
@ -460,7 +476,7 @@ export const MessageComposer = ({
minRows={2}
maxRows={6}
maxLength={MAX_TEXT_LENGTH}
disabled={sending}
disabled={sending || isProvisioning}
cornerAction={
<div className="flex items-center gap-2">
{/* 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-hover: rgba(255, 255, 255, 0.08);
--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 */
@ -502,6 +506,10 @@
--color-section-bg-open: rgba(0, 0, 0, 0.07);
--color-section-hover: rgba(0, 0, 0, 0.08);
--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 */

View file

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