From 52ddbb2916edcd27f64e8f2f0216417c8ee7335d Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 7 Mar 2026 23:50:27 +0200 Subject: [PATCH] feat: enhance team data retrieval with optional message inclusion - Updated the `getTeamData` method to accept an options parameter, allowing for conditional inclusion of messages in the response. - Modified the `handleGetData` function to validate and process the new options, improving flexibility in data retrieval. - Enhanced the `TeamDetailView` and message components to handle loading states and display messages based on the new options. - Introduced a loading delay for messages to optimize UI performance during data fetching. - Updated relevant types and interfaces to support the new options structure. --- src/main/ipc/teams.ts | 15 +- src/main/services/team/TeamDataService.ts | 138 +++++++++--------- src/preload/index.ts | 5 +- .../settings/sections/ConfigEditorDialog.tsx | 98 ++++++------- .../components/team/TeamDetailView.tsx | 85 +++++++---- .../team/attachments/DropZoneOverlay.tsx | 26 +++- .../team/dialogs/SendMessageDialog.tsx | 55 +++++-- .../team/messages/MessageComposer.tsx | 57 ++++++-- src/renderer/store/slices/teamSlice.ts | 34 ++++- src/shared/types/api.ts | 3 +- src/shared/types/team.ts | 4 + 11 files changed, 338 insertions(+), 182 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 0c6dbbf6..28d6cd70 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -114,6 +114,7 @@ import type { TeamCreateRequest, TeamCreateResponse, TeamData, + TeamGetDataOptions, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, @@ -377,17 +378,23 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } const tn = validated.value!; + const includeMessages = + !options || + typeof options !== 'object' || + !('includeMessages' in options) || + (options as TeamGetDataOptions).includeMessages !== false; const startedAt = Date.now(); let data: TeamData; try { - data = await getTeamDataService().getTeamData(tn); + data = await getTeamDataService().getTeamData(tn, { includeMessages }); } catch (error) { const message = error instanceof Error ? error.message : String(error); if ( @@ -409,6 +416,10 @@ async function handleGetData( const displayName = data.config.name || tn; const projectPath = data.config.projectPath; + if (!includeMessages) { + return { success: true, data: { ...data, isAlive } }; + } + const live = provisioning.getLiveLeadProcessMessages(tn); if (live.length === 0) { checkRateLimitMessages(data.messages, tn, displayName, projectPath); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 0712460b..34cadef9 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -53,6 +53,7 @@ import type { TeamConfig, TeamCreateConfigRequest, TeamData, + TeamGetDataOptions, TeamMember, TeamProcess, TeamSummary, @@ -255,8 +256,9 @@ export class TeamDataService { await fs.promises.rm(tasksDir, { recursive: true, force: true }); } - async getTeamData(teamName: string): Promise { + async getTeamData(teamName: string, options?: TeamGetDataOptions): Promise { const startedAt = Date.now(); + const includeMessages = options?.includeMessages !== false; const marks: Record = {}; const mark = (label: string): void => { marks[label] = Date.now(); @@ -291,32 +293,38 @@ export class TeamDataService { mark('inboxNames'); let messages: InboxMessage[] = []; - try { - messages = await this.inboxReader.getMessages(teamName); - } catch { - warnings.push('Messages failed to load'); + if (includeMessages) { + try { + messages = await this.inboxReader.getMessages(teamName); + } catch { + warnings.push('Messages failed to load'); + } } mark('messages'); let leadTexts: InboxMessage[] = []; - try { - leadTexts = await this.extractLeadSessionTexts(config); - if (leadTexts.length > 0) { - messages = [...messages, ...leadTexts]; + if (includeMessages) { + try { + leadTexts = await this.extractLeadSessionTexts(config); + if (leadTexts.length > 0) { + messages = [...messages, ...leadTexts]; + } + } catch { + warnings.push('Lead session texts failed to load'); } - } catch { - warnings.push('Lead session texts failed to load'); } mark('leadTexts'); let sentMessages: InboxMessage[] = []; - try { - sentMessages = await this.sentMessagesStore.readMessages(teamName); - if (sentMessages.length > 0) { - messages = [...messages, ...sentMessages]; + if (includeMessages) { + try { + sentMessages = await this.sentMessagesStore.readMessages(teamName); + if (sentMessages.length > 0) { + messages = [...messages, ...sentMessages]; + } + } catch { + warnings.push('Sent messages failed to load'); } - } catch { - warnings.push('Sent messages failed to load'); } mark('sentMessages'); @@ -339,68 +347,58 @@ export class TeamDataService { }); } - this.ensureStableMessageIds(messages); + if (includeMessages) { + this.ensureStableMessageIds(messages); - // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's - // session ID (by timestamp). This avoids the old forward-only propagation bug where - // messages between two sessions always inherited the *earlier* session, causing a - // spurious "New session" divider even when the message is chronologically closer to - // the later session. - if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { - messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's + // session ID (by timestamp). This avoids the old forward-only propagation bug. + if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { + messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - // Collect indices of messages that already have a leadSessionId (anchors). - const anchors: { index: number; time: number; sessionId: string }[] = []; - for (let i = 0; i < messages.length; i++) { - if (messages[i].leadSessionId) { - anchors.push({ - index: i, - time: Date.parse(messages[i].timestamp), - sessionId: messages[i].leadSessionId!, - }); - } - } - - if (anchors.length > 0) { - // For each message without leadSessionId, find the closest anchor by timestamp - // and inherit its sessionId. - let anchorIdx = 0; + const anchors: { index: number; time: number; sessionId: string }[] = []; for (let i = 0; i < messages.length; i++) { if (messages[i].leadSessionId) { - // Advance anchorIdx to track current position for efficient lookup - while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) { - anchorIdx++; - } - continue; + anchors.push({ + index: i, + time: Date.parse(messages[i].timestamp), + sessionId: messages[i].leadSessionId!, + }); } - - const msgTime = Date.parse(messages[i].timestamp); - - // Find closest anchor by timestamp (binary-search-like scan from current position) - let bestAnchor = anchors[0]; - let bestDist = Math.abs(msgTime - bestAnchor.time); - for (const anchor of anchors) { - const dist = Math.abs(msgTime - anchor.time); - if (dist < bestDist) { - bestDist = dist; - bestAnchor = anchor; - } else if (dist > bestDist && anchor.time > msgTime) { - // Anchors are sorted by index (asc time) — once distance grows past the - // message time, further anchors will only be farther. - break; - } - } - messages[i].leadSessionId = bestAnchor.sessionId; } - } else if (config.leadSessionId) { - // No anchors at all — fall back to config.leadSessionId for everything. - for (const msg of messages) { - msg.leadSessionId = config.leadSessionId; + + if (anchors.length > 0) { + let anchorIdx = 0; + for (let i = 0; i < messages.length; i++) { + if (messages[i].leadSessionId) { + while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) { + anchorIdx++; + } + continue; + } + + const msgTime = Date.parse(messages[i].timestamp); + let bestAnchor = anchors[0]; + let bestDist = Math.abs(msgTime - bestAnchor.time); + for (const anchor of anchors) { + const dist = Math.abs(msgTime - anchor.time); + if (dist < bestDist) { + bestDist = dist; + bestAnchor = anchor; + } else if (dist > bestDist && anchor.time > msgTime) { + break; + } + } + messages[i].leadSessionId = bestAnchor.sessionId; + } + } else if (config.leadSessionId) { + for (const msg of messages) { + msg.leadSessionId = config.leadSessionId; + } } } - } - messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + } let metaMembers: TeamConfig['members'] = []; try { diff --git a/src/preload/index.ts b/src/preload/index.ts index a397873e..2eb9d7fa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -208,6 +208,7 @@ import type { TeamCreateRequest, TeamCreateResponse, TeamData, + TeamGetDataOptions, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, @@ -705,8 +706,8 @@ const electronAPI: ElectronAPI = { list: async () => { return invokeIpcWithResult(TEAM_LIST); }, - getData: async (teamName: string) => { - return invokeIpcWithResult(TEAM_GET_DATA, teamName); + getData: async (teamName: string, options?: TeamGetDataOptions) => { + return invokeIpcWithResult(TEAM_GET_DATA, teamName, options); }, getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => { return invokeIpcWithResult(TEAM_GET_CLAUDE_LOGS, teamName, query); diff --git a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx index dabe26d1..428e93f7 100644 --- a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx +++ b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx @@ -177,56 +177,53 @@ export const ConfigEditorDialog = ({ const init = async (): Promise => { try { const config = await api.config.get(); - if (destroyed) return; + if (destroyed || !editorRef.current) return; const jsonText = JSON.stringify(config, null, 2); initialConfigRef.current = jsonText; - setLoading(false); - // Wait for DOM render - requestAnimationFrame(() => { - if (destroyed || !editorRef.current) return; + // Clean up existing view + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } - // Clean up existing view - if (viewRef.current) { - viewRef.current.destroy(); - viewRef.current = null; - } - - const state = EditorState.create({ - doc: jsonText, - extensions: [ - lineNumbers(), - highlightActiveLineGutter(), - highlightActiveLine(), - history(), - foldGutter(), - indentOnInput(), - bracketMatching(), - json(), - syntaxHighlighting(oneDarkHighlightStyle), - jsonLinter, - lintGutter(), - search(), - keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]), - baseEditorTheme, - configEditorTheme, - // eslint-disable-next-line sonarjs/no-nested-functions -- CodeMirror listener callback within useEffect setup - EditorView.updateListener.of((update) => { - if (update.docChanged) { - const text = update.state.doc.toString(); - scheduleSave(text); - } - }), - ], - }); - - const view = new EditorView({ - state, - parent: editorRef.current, - }); - viewRef.current = view; + const state = EditorState.create({ + doc: jsonText, + extensions: [ + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + history(), + foldGutter(), + indentOnInput(), + bracketMatching(), + json(), + syntaxHighlighting(oneDarkHighlightStyle), + jsonLinter, + lintGutter(), + search(), + keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]), + baseEditorTheme, + configEditorTheme, + // eslint-disable-next-line sonarjs/no-nested-functions -- CodeMirror listener callback within useEffect setup + EditorView.updateListener.of((update) => { + if (update.docChanged) { + const text = update.state.doc.toString(); + scheduleSave(text); + } + }), + ], }); + + const view = new EditorView({ + state, + parent: editorRef.current, + }); + viewRef.current = view; + + // Reveal editor only after CodeMirror is fully mounted + setLoading(false); } catch (e) { if (destroyed) return; setLoading(false); @@ -301,15 +298,18 @@ export const ConfigEditorDialog = ({
{loading ? (
Loading config...
- ) : ( -
- )} + ) : null} +
{/* Footer */} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 80069b40..90d56af7 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -97,6 +97,7 @@ interface TeamDetailViewProps { } const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']); +const MESSAGE_LOAD_DELAY_MS = 2_000; interface CreateTaskDialogState { open: boolean; @@ -200,6 +201,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const { data, loading, + messagesLoading, error, projects, repositoryGroups, @@ -240,6 +242,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele useShallow((s) => ({ data: s.selectedTeamData, loading: s.selectedTeamLoading, + messagesLoading: s.selectedTeamMessagesLoading, error: s.selectedTeamError, projects: s.projects, repositoryGroups: s.repositoryGroups, @@ -331,6 +334,20 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele void fetchDeletedTasks(teamName); }, [teamName, selectTeam, fetchDeletedTasks]); + useEffect(() => { + if (!teamName || loading || !data || data.teamName !== teamName || !messagesLoading) { + return; + } + + const timeoutId = window.setTimeout(() => { + void refreshTeamData(teamName, { includeMessages: true, messagesLoading: true }); + }, MESSAGE_LOAD_DELAY_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [teamName, loading, data, messagesLoading, refreshTeamData]); + // Fetch active teams when launch dialog opens (for conflict warning) useEffect(() => { if (!launchDialogOpen) return; @@ -1431,14 +1448,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sectionId="messages" title="Messages" icon={} - badge={filteredMessages.length} + badge={messagesLoading ? '...' : filteredMessages.length} secondaryBadge={ - filteredMessages.length > 0 && messagesUnreadCount > 0 + !messagesLoading && filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined } afterBadge={ - messagesUnreadCount > 0 ? ( + !messagesLoading && messagesUnreadCount > 0 ? (
- { - openCreateTaskDialog(subject, description); - }} - onReplyToMessage={(message) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }} - onMessageVisible={handleMessageVisible} - onRestartTeam={() => setLaunchDialogOpen(true)} - onTaskIdClick={(taskId) => { - const task = - taskMap.get(taskId) ?? - data.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); - }} - /> + {messagesLoading ? ( +
+ Loading messages in 2 seconds so the rest of the team view can open faster. +
+ ) : ( + { + openCreateTaskDialog(subject, description); + }} + onReplyToMessage={(message) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }} + onMessageVisible={handleMessageVisible} + onRestartTeam={() => setLaunchDialogOpen(true)} + onTaskIdClick={(taskId) => { + const task = + taskMap.get(taskId) ?? + data.tasks.find((candidate) => candidate.displayId === taskId); + if (task) setSelectedTask(task); + }} + /> + )} { +export const DropZoneOverlay = ({ + active, + rejected, +}: DropZoneOverlayProps): React.JSX.Element | null => { if (!active) return null; + if (rejected) { + return ( +
+
+ + Images can only be sent to the team lead +
+
+ ); + } + return (
(null); + const [imageRestrictionError, setImageRestrictionError] = useState(null); + const imageRestrictionTimerRef = useRef(0); const { attachments, @@ -216,6 +218,20 @@ export const SendMessageDialog = ({ [addFiles] ); + const showImageRestrictionError = useCallback(() => { + setImageRestrictionError('Images can only be sent to the team lead'); + window.clearTimeout(imageRestrictionTimerRef.current); + imageRestrictionTimerRef.current = window.setTimeout(() => { + setImageRestrictionError(null); + }, 4000); + }, []); + + // Cleanup restriction error timer on unmount + useEffect(() => { + const ref = imageRestrictionTimerRef; + return () => window.clearTimeout(ref.current); + }, []); + const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current += 1; @@ -237,31 +253,52 @@ export const SendMessageDialog = ({ const handleDropWrapper = useCallback( (e: React.DragEvent) => { + e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); + if (!isLeadRecipient) { + const files = e.dataTransfer?.files; + if (files?.length) { + const hasImages = Array.from(files).some((f) => f.type.startsWith('image/')); + if (hasImages) { + showImageRestrictionError(); + } + } + return; + } if (canAttach) handleDrop(e); }, - [canAttach, handleDrop] + [isLeadRecipient, canAttach, handleDrop, showImageRestrictionError] ); const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { + if (!isLeadRecipient) { + const hasImages = Array.from(e.clipboardData.items).some((item) => + item.type.startsWith('image/') + ); + if (hasImages) { + e.preventDefault(); + showImageRestrictionError(); + } + return; + } if (canAttach) handlePaste(e); }, - [canAttach, handlePaste] + [isLeadRecipient, canAttach, handlePaste, showImageRestrictionError] ); return ( - + Send Message @@ -323,7 +360,7 @@ export const SendMessageDialog = ({ diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index d0f5e452..aa068075 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -102,6 +102,8 @@ export const MessageComposer = ({ const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); + const [imageRestrictionError, setImageRestrictionError] = useState(null); + const imageRestrictionTimerRef = useRef(0); // Members load async with team data; keep recipient stable if valid, otherwise default to lead/first. useEffect(() => { @@ -200,6 +202,20 @@ export const MessageComposer = ({ [draftAddFiles] ); + const showImageRestrictionError = useCallback(() => { + setImageRestrictionError('Images can only be sent to the team lead'); + window.clearTimeout(imageRestrictionTimerRef.current); + imageRestrictionTimerRef.current = window.setTimeout(() => { + setImageRestrictionError(null); + }, 4000); + }, []); + + // Cleanup restriction error timer on unmount + useEffect(() => { + const ref = imageRestrictionTimerRef; + return () => window.clearTimeout(ref.current); + }, []); + const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current += 1; @@ -222,19 +238,40 @@ export const MessageComposer = ({ const { handleDrop: draftHandleDrop } = draft; const handleDropWrapper = useCallback( (e: React.DragEvent) => { + e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); + if (!isLeadRecipient) { + const files = e.dataTransfer?.files; + if (files?.length) { + const hasImages = Array.from(files).some((f) => f.type.startsWith('image/')); + if (hasImages) { + showImageRestrictionError(); + } + } + return; + } if (canAttach) draftHandleDrop(e); }, - [canAttach, draftHandleDrop] + [isLeadRecipient, canAttach, draftHandleDrop, showImageRestrictionError] ); const { handlePaste: draftHandlePaste } = draft; const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { + if (!isLeadRecipient) { + const hasImages = Array.from(e.clipboardData.items).some((item) => + item.type.startsWith('image/') + ); + if (hasImages) { + e.preventDefault(); + showImageRestrictionError(); + } + return; + } if (canAttach) draftHandlePaste(e); }, - [canAttach, draftHandlePaste] + [isLeadRecipient, canAttach, draftHandlePaste, showImageRestrictionError] ); const remaining = MAX_TEXT_LENGTH - trimmed.length; @@ -244,13 +281,13 @@ export const MessageComposer = ({ className="relative mb-3 p-3" role="group" onKeyDownCapture={handleKeyDownCapture} - onDragEnter={canAttach ? handleDragEnter : undefined} - onDragLeave={canAttach ? handleDragLeave : undefined} - onDragOver={canAttach ? handleDragOver : undefined} - onDrop={canAttach ? handleDropWrapper : undefined} - onPaste={canAttach ? handlePasteWrapper : undefined} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDropWrapper} + onPaste={handlePasteWrapper} > - +
{isLeadRecipient ? ( @@ -291,7 +328,7 @@ export const MessageComposer = ({ void; clearKanbanFilter: () => void; selectTeam: (teamName: string, opts?: { skipProjectAutoSelect?: boolean }) => Promise; - refreshTeamData: (teamName: string) => Promise; + refreshTeamData: ( + teamName: string, + opts?: { includeMessages?: boolean; messagesLoading?: boolean } + ) => Promise; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; @@ -395,6 +399,7 @@ export const createTeamSlice: StateCreator = (set, selectedTeamName: null, selectedTeamData: null, selectedTeamLoading: false, + selectedTeamMessagesLoading: false, selectedTeamError: null, sendingMessage: false, sendMessageError: null, @@ -615,13 +620,14 @@ export const createTeamSlice: StateCreator = (set, selectedTeamName: teamName, selectedTeamData: prev !== teamName ? null : get().selectedTeamData, selectedTeamLoading: true, + selectedTeamMessagesLoading: true, selectedTeamError: null, reviewActionError: null, }); try { const data = await withTimeout( - unwrapIpc('team:getData', () => api.teams.getData(teamName)), + unwrapIpc('team:getData', () => api.teams.getData(teamName, { includeMessages: false })), TEAM_GET_DATA_TIMEOUT_MS, `team:getData(${teamName})` ); @@ -712,6 +718,7 @@ export const createTeamSlice: StateCreator = (set, if (msg === 'TEAM_PROVISIONING' || (msg.includes('TEAM_PROVISIONING') && isProvisioning)) { set({ selectedTeamLoading: true, + selectedTeamMessagesLoading: true, selectedTeamData: null, selectedTeamError: null, }); @@ -726,22 +733,27 @@ export const createTeamSlice: StateCreator = (set, : 'Failed to fetch team data'; set({ selectedTeamLoading: false, + selectedTeamMessagesLoading: false, selectedTeamData: null, selectedTeamError: message, }); } }, - refreshTeamData: async (teamName: string) => { + refreshTeamData: async (teamName: string, opts) => { const state = get(); if (state.selectedTeamName !== teamName) { return; } + const includeMessages = opts?.includeMessages !== false; + if (opts?.messagesLoading !== undefined) { + set({ selectedTeamMessagesLoading: opts.messagesLoading }); + } // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). try { const data = await withTimeout( - unwrapIpc('team:getData', () => api.teams.getData(teamName)), + unwrapIpc('team:getData', () => api.teams.getData(teamName, { includeMessages })), TEAM_GET_DATA_TIMEOUT_MS, `refreshTeamData(${teamName})` ); @@ -751,6 +763,7 @@ export const createTeamSlice: StateCreator = (set, } set({ selectedTeamData: data, + selectedTeamMessagesLoading: includeMessages ? false : get().selectedTeamMessagesLoading, selectedTeamError: null, }); } catch (error) { @@ -764,7 +777,10 @@ export const createTeamSlice: StateCreator = (set, ? error.message : 'Failed to refresh team data'; logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`); - set({ selectedTeamError: msg }); + set({ + selectedTeamError: msg, + selectedTeamMessagesLoading: includeMessages ? false : get().selectedTeamMessagesLoading, + }); } }, @@ -980,7 +996,13 @@ export const createTeamSlice: StateCreator = (set, await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); const state = get(); if (state.selectedTeamName === teamName) { - set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null }); + set({ + selectedTeamName: null, + selectedTeamData: null, + selectedTeamLoading: false, + selectedTeamMessagesLoading: false, + selectedTeamError: null, + }); } await get().fetchTeams(); await get().fetchAllTasks(); diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 48984886..318b437c 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -52,6 +52,7 @@ import type { TeamCreateRequest, TeamCreateResponse, TeamData, + TeamGetDataOptions, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, @@ -400,7 +401,7 @@ export interface HttpServerAPI { export interface TeamsAPI { list: () => Promise; - getData: (teamName: string) => Promise; + getData: (teamName: string, options?: TeamGetDataOptions) => Promise; getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise; deleteTeam: (teamName: string) => Promise; restoreTeam: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index d891463a..51596388 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -301,6 +301,10 @@ export interface TeamData { isAlive?: boolean; } +export interface TeamGetDataOptions { + includeMessages?: boolean; +} + export type EffortLevel = 'low' | 'medium' | 'high'; export interface TeamLaunchRequest {