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:
parent
3f923c480e
commit
afb6e2794f
8 changed files with 157 additions and 109 deletions
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue