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.
This commit is contained in:
iliya 2026-03-07 23:50:27 +02:00
parent 085ec144ac
commit 52ddbb2916
11 changed files with 338 additions and 182 deletions

View file

@ -114,6 +114,7 @@ import type {
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamData, TeamData,
TeamGetDataOptions,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamMessageNotificationData, TeamMessageNotificationData,
@ -377,17 +378,23 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
async function handleGetData( async function handleGetData(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown teamName: unknown,
options: unknown
): Promise<IpcResult<TeamData>> { ): Promise<IpcResult<TeamData>> {
const validated = validateTeamName(teamName); const validated = validateTeamName(teamName);
if (!validated.valid) { if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' }; return { success: false, error: validated.error ?? 'Invalid teamName' };
} }
const tn = validated.value!; const tn = validated.value!;
const includeMessages =
!options ||
typeof options !== 'object' ||
!('includeMessages' in options) ||
(options as TeamGetDataOptions).includeMessages !== false;
const startedAt = Date.now(); const startedAt = Date.now();
let data: TeamData; let data: TeamData;
try { try {
data = await getTeamDataService().getTeamData(tn); data = await getTeamDataService().getTeamData(tn, { includeMessages });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if ( if (
@ -409,6 +416,10 @@ async function handleGetData(
const displayName = data.config.name || tn; const displayName = data.config.name || tn;
const projectPath = data.config.projectPath; const projectPath = data.config.projectPath;
if (!includeMessages) {
return { success: true, data: { ...data, isAlive } };
}
const live = provisioning.getLiveLeadProcessMessages(tn); const live = provisioning.getLiveLeadProcessMessages(tn);
if (live.length === 0) { if (live.length === 0) {
checkRateLimitMessages(data.messages, tn, displayName, projectPath); checkRateLimitMessages(data.messages, tn, displayName, projectPath);

View file

@ -53,6 +53,7 @@ import type {
TeamConfig, TeamConfig,
TeamCreateConfigRequest, TeamCreateConfigRequest,
TeamData, TeamData,
TeamGetDataOptions,
TeamMember, TeamMember,
TeamProcess, TeamProcess,
TeamSummary, TeamSummary,
@ -255,8 +256,9 @@ export class TeamDataService {
await fs.promises.rm(tasksDir, { recursive: true, force: true }); await fs.promises.rm(tasksDir, { recursive: true, force: true });
} }
async getTeamData(teamName: string): Promise<TeamData> { async getTeamData(teamName: string, options?: TeamGetDataOptions): Promise<TeamData> {
const startedAt = Date.now(); const startedAt = Date.now();
const includeMessages = options?.includeMessages !== false;
const marks: Record<string, number> = {}; const marks: Record<string, number> = {};
const mark = (label: string): void => { const mark = (label: string): void => {
marks[label] = Date.now(); marks[label] = Date.now();
@ -291,32 +293,38 @@ export class TeamDataService {
mark('inboxNames'); mark('inboxNames');
let messages: InboxMessage[] = []; let messages: InboxMessage[] = [];
try { if (includeMessages) {
messages = await this.inboxReader.getMessages(teamName); try {
} catch { messages = await this.inboxReader.getMessages(teamName);
warnings.push('Messages failed to load'); } catch {
warnings.push('Messages failed to load');
}
} }
mark('messages'); mark('messages');
let leadTexts: InboxMessage[] = []; let leadTexts: InboxMessage[] = [];
try { if (includeMessages) {
leadTexts = await this.extractLeadSessionTexts(config); try {
if (leadTexts.length > 0) { leadTexts = await this.extractLeadSessionTexts(config);
messages = [...messages, ...leadTexts]; 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'); mark('leadTexts');
let sentMessages: InboxMessage[] = []; let sentMessages: InboxMessage[] = [];
try { if (includeMessages) {
sentMessages = await this.sentMessagesStore.readMessages(teamName); try {
if (sentMessages.length > 0) { sentMessages = await this.sentMessagesStore.readMessages(teamName);
messages = [...messages, ...sentMessages]; 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'); 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 // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
// session ID (by timestamp). This avoids the old forward-only propagation bug where // session ID (by timestamp). This avoids the old forward-only propagation bug.
// messages between two sessions always inherited the *earlier* session, causing a if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
// spurious "New session" divider even when the message is chronologically closer to messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
// the later session.
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 }[] = [];
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;
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {
if (messages[i].leadSessionId) { if (messages[i].leadSessionId) {
// Advance anchorIdx to track current position for efficient lookup anchors.push({
while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) { index: i,
anchorIdx++; time: Date.parse(messages[i].timestamp),
} sessionId: messages[i].leadSessionId!,
continue; });
} }
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. if (anchors.length > 0) {
for (const msg of messages) { let anchorIdx = 0;
msg.leadSessionId = config.leadSessionId; 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'] = []; let metaMembers: TeamConfig['members'] = [];
try { try {

View file

@ -208,6 +208,7 @@ import type {
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamData, TeamData,
TeamGetDataOptions,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamMessageNotificationData, TeamMessageNotificationData,
@ -705,8 +706,8 @@ const electronAPI: ElectronAPI = {
list: async () => { list: async () => {
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST); return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
}, },
getData: async (teamName: string) => { getData: async (teamName: string, options?: TeamGetDataOptions) => {
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName); return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName, options);
}, },
getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => { getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => {
return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query); return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query);

View file

@ -177,56 +177,53 @@ export const ConfigEditorDialog = ({
const init = async (): Promise<void> => { const init = async (): Promise<void> => {
try { try {
const config = await api.config.get(); const config = await api.config.get();
if (destroyed) return; if (destroyed || !editorRef.current) return;
const jsonText = JSON.stringify(config, null, 2); const jsonText = JSON.stringify(config, null, 2);
initialConfigRef.current = jsonText; initialConfigRef.current = jsonText;
setLoading(false);
// Wait for DOM render // Clean up existing view
requestAnimationFrame(() => { if (viewRef.current) {
if (destroyed || !editorRef.current) return; viewRef.current.destroy();
viewRef.current = null;
}
// Clean up existing view const state = EditorState.create({
if (viewRef.current) { doc: jsonText,
viewRef.current.destroy(); extensions: [
viewRef.current = null; lineNumbers(),
} highlightActiveLineGutter(),
highlightActiveLine(),
const state = EditorState.create({ history(),
doc: jsonText, foldGutter(),
extensions: [ indentOnInput(),
lineNumbers(), bracketMatching(),
highlightActiveLineGutter(), json(),
highlightActiveLine(), syntaxHighlighting(oneDarkHighlightStyle),
history(), jsonLinter,
foldGutter(), lintGutter(),
indentOnInput(), search(),
bracketMatching(), keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]),
json(), baseEditorTheme,
syntaxHighlighting(oneDarkHighlightStyle), configEditorTheme,
jsonLinter, // eslint-disable-next-line sonarjs/no-nested-functions -- CodeMirror listener callback within useEffect setup
lintGutter(), EditorView.updateListener.of((update) => {
search(), if (update.docChanged) {
keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]), const text = update.state.doc.toString();
baseEditorTheme, scheduleSave(text);
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 view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
// Reveal editor only after CodeMirror is fully mounted
setLoading(false);
} catch (e) { } catch (e) {
if (destroyed) return; if (destroyed) return;
setLoading(false); setLoading(false);
@ -301,15 +298,18 @@ export const ConfigEditorDialog = ({
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
{loading ? ( {loading ? (
<div <div
className="flex h-96 items-center justify-center gap-2 text-sm" className="absolute inset-0 z-10 flex items-center justify-center gap-2 text-sm"
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)', backgroundColor: 'var(--color-surface)' }}
> >
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Loading config... Loading config...
</div> </div>
) : ( ) : null}
<div ref={editorRef} className="config-editor-container h-full min-h-[400px]" /> <div
)} ref={editorRef}
className="config-editor-container h-full min-h-[400px]"
style={loading ? { visibility: 'hidden' } : undefined}
/>
</div> </div>
{/* Footer */} {/* Footer */}

View file

@ -97,6 +97,7 @@ interface TeamDetailViewProps {
} }
const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']); const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
const MESSAGE_LOAD_DELAY_MS = 2_000;
interface CreateTaskDialogState { interface CreateTaskDialogState {
open: boolean; open: boolean;
@ -200,6 +201,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const { const {
data, data,
loading, loading,
messagesLoading,
error, error,
projects, projects,
repositoryGroups, repositoryGroups,
@ -240,6 +242,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
useShallow((s) => ({ useShallow((s) => ({
data: s.selectedTeamData, data: s.selectedTeamData,
loading: s.selectedTeamLoading, loading: s.selectedTeamLoading,
messagesLoading: s.selectedTeamMessagesLoading,
error: s.selectedTeamError, error: s.selectedTeamError,
projects: s.projects, projects: s.projects,
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
@ -331,6 +334,20 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
void fetchDeletedTasks(teamName); void fetchDeletedTasks(teamName);
}, [teamName, selectTeam, fetchDeletedTasks]); }, [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) // Fetch active teams when launch dialog opens (for conflict warning)
useEffect(() => { useEffect(() => {
if (!launchDialogOpen) return; if (!launchDialogOpen) return;
@ -1431,14 +1448,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sectionId="messages" sectionId="messages"
title="Messages" title="Messages"
icon={<MessageSquare size={14} />} icon={<MessageSquare size={14} />}
badge={filteredMessages.length} badge={messagesLoading ? '...' : filteredMessages.length}
secondaryBadge={ secondaryBadge={
filteredMessages.length > 0 && messagesUnreadCount > 0 !messagesLoading && filteredMessages.length > 0 && messagesUnreadCount > 0
? messagesUnreadCount ? messagesUnreadCount
: undefined : undefined
} }
afterBadge={ afterBadge={
messagesUnreadCount > 0 ? ( !messagesLoading && messagesUnreadCount > 0 ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -1564,34 +1581,40 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onTaskClick={setSelectedTask} onTaskClick={setSelectedTask}
/> />
</div> </div>
<ActivityTimeline {messagesLoading ? (
messages={filteredMessages} <div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
teamName={teamName} Loading messages in 2 seconds so the rest of the team view can open faster.
members={data.members} </div>
readState={{ readSet, getMessageKey: toMessageKey }} ) : (
allCollapsed={messagesCollapsed} <ActivityTimeline
expandOverrides={expandedSet} messages={filteredMessages}
onToggleExpandOverride={toggleExpandOverride} teamName={teamName}
onMemberClick={setSelectedMember} members={data.members}
onCreateTaskFromMessage={(subject, description) => { readState={{ readSet, getMessageKey: toMessageKey }}
openCreateTaskDialog(subject, description); allCollapsed={messagesCollapsed}
}} expandOverrides={expandedSet}
onReplyToMessage={(message) => { onToggleExpandOverride={toggleExpandOverride}
setSendDialogRecipient(message.from); onMemberClick={setSelectedMember}
setSendDialogDefaultText(undefined); onCreateTaskFromMessage={(subject, description) => {
setSendDialogDefaultChip(undefined); openCreateTaskDialog(subject, description);
setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); }}
setSendDialogOpen(true); onReplyToMessage={(message) => {
}} setSendDialogRecipient(message.from);
onMessageVisible={handleMessageVisible} setSendDialogDefaultText(undefined);
onRestartTeam={() => setLaunchDialogOpen(true)} setSendDialogDefaultChip(undefined);
onTaskIdClick={(taskId) => { setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) });
const task = setSendDialogOpen(true);
taskMap.get(taskId) ?? }}
data.tasks.find((candidate) => candidate.displayId === taskId); onMessageVisible={handleMessageVisible}
if (task) setSelectedTask(task); onRestartTeam={() => setLaunchDialogOpen(true)}
}} onTaskIdClick={(taskId) => {
/> const task =
taskMap.get(taskId) ??
data.tasks.find((candidate) => candidate.displayId === taskId);
if (task) setSelectedTask(task);
}}
/>
)}
</CollapsibleTeamSection> </CollapsibleTeamSection>
<ReviewDialog <ReviewDialog

View file

@ -1,12 +1,34 @@
import { ImagePlus } from 'lucide-react'; import { Ban, ImagePlus } from 'lucide-react';
interface DropZoneOverlayProps { interface DropZoneOverlayProps {
active: boolean; active: boolean;
/** Show a "rejected" variant when images can't be sent to this recipient. */
rejected?: boolean;
} }
export const DropZoneOverlay = ({ active }: DropZoneOverlayProps): React.JSX.Element | null => { export const DropZoneOverlay = ({
active,
rejected,
}: DropZoneOverlayProps): React.JSX.Element | null => {
if (!active) return null; if (!active) return null;
if (rejected) {
return (
<div
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border-2 border-dashed backdrop-blur-[1px]"
style={{
borderColor: '#ef4444',
backgroundColor: 'color-mix(in srgb, #ef4444 10%, transparent)',
}}
>
<div className="flex flex-col items-center gap-1.5 text-red-400">
<Ban size={24} />
<span className="text-xs font-medium">Images can only be sent to the team lead</span>
</div>
</div>
);
}
return ( return (
<div <div
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border-2 border-dashed backdrop-blur-[1px]" className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border-2 border-dashed backdrop-blur-[1px]"

View file

@ -88,6 +88,8 @@ export const SendMessageDialog = ({
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
const imageRestrictionTimerRef = useRef(0);
const { const {
attachments, attachments,
@ -216,6 +218,20 @@ export const SendMessageDialog = ({
[addFiles] [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) => { const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
dragCounterRef.current += 1; dragCounterRef.current += 1;
@ -237,31 +253,52 @@ export const SendMessageDialog = ({
const handleDropWrapper = useCallback( const handleDropWrapper = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0; dragCounterRef.current = 0;
setIsDragOver(false); 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); if (canAttach) handleDrop(e);
}, },
[canAttach, handleDrop] [isLeadRecipient, canAttach, handleDrop, showImageRestrictionError]
); );
const handlePasteWrapper = useCallback( const handlePasteWrapper = useCallback(
(e: React.ClipboardEvent) => { (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); if (canAttach) handlePaste(e);
}, },
[canAttach, handlePaste] [isLeadRecipient, canAttach, handlePaste, showImageRestrictionError]
); );
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent <DialogContent
className="min-w-0 max-w-3xl" className="min-w-0 max-w-3xl"
onDragEnter={canAttach ? handleDragEnter : undefined} onDragEnter={handleDragEnter}
onDragLeave={canAttach ? handleDragLeave : undefined} onDragLeave={handleDragLeave}
onDragOver={canAttach ? handleDragOver : undefined} onDragOver={handleDragOver}
onDrop={canAttach ? handleDropWrapper : undefined} onDrop={handleDropWrapper}
onPaste={canAttach ? handlePasteWrapper : undefined} onPaste={handlePasteWrapper}
> >
<DropZoneOverlay active={isDragOver && !!canAttach} /> <DropZoneOverlay active={isDragOver} rejected={!isLeadRecipient} />
<DialogHeader> <DialogHeader>
<DialogTitle>Send Message</DialogTitle> <DialogTitle>Send Message</DialogTitle>
@ -323,7 +360,7 @@ export const SendMessageDialog = ({
<AttachmentPreviewList <AttachmentPreviewList
attachments={attachments} attachments={attachments}
onRemove={removeAttachment} onRemove={removeAttachment}
error={attachmentError} error={attachmentError ?? imageRestrictionError}
disabled={attachmentsBlocked} disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient." disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/> />

View file

@ -102,6 +102,8 @@ export const MessageComposer = ({
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
const imageRestrictionTimerRef = useRef(0);
// Members load async with team data; keep recipient stable if valid, otherwise default to lead/first. // Members load async with team data; keep recipient stable if valid, otherwise default to lead/first.
useEffect(() => { useEffect(() => {
@ -200,6 +202,20 @@ export const MessageComposer = ({
[draftAddFiles] [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) => { const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
dragCounterRef.current += 1; dragCounterRef.current += 1;
@ -222,19 +238,40 @@ export const MessageComposer = ({
const { handleDrop: draftHandleDrop } = draft; const { handleDrop: draftHandleDrop } = draft;
const handleDropWrapper = useCallback( const handleDropWrapper = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0; dragCounterRef.current = 0;
setIsDragOver(false); 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); if (canAttach) draftHandleDrop(e);
}, },
[canAttach, draftHandleDrop] [isLeadRecipient, canAttach, draftHandleDrop, showImageRestrictionError]
); );
const { handlePaste: draftHandlePaste } = draft; const { handlePaste: draftHandlePaste } = draft;
const handlePasteWrapper = useCallback( const handlePasteWrapper = useCallback(
(e: React.ClipboardEvent) => { (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); if (canAttach) draftHandlePaste(e);
}, },
[canAttach, draftHandlePaste] [isLeadRecipient, canAttach, draftHandlePaste, showImageRestrictionError]
); );
const remaining = MAX_TEXT_LENGTH - trimmed.length; const remaining = MAX_TEXT_LENGTH - trimmed.length;
@ -244,13 +281,13 @@ export const MessageComposer = ({
className="relative mb-3 p-3" className="relative mb-3 p-3"
role="group" role="group"
onKeyDownCapture={handleKeyDownCapture} onKeyDownCapture={handleKeyDownCapture}
onDragEnter={canAttach ? handleDragEnter : undefined} onDragEnter={handleDragEnter}
onDragLeave={canAttach ? handleDragLeave : undefined} onDragLeave={handleDragLeave}
onDragOver={canAttach ? handleDragOver : undefined} onDragOver={handleDragOver}
onDrop={canAttach ? handleDropWrapper : undefined} onDrop={handleDropWrapper}
onPaste={canAttach ? handlePasteWrapper : undefined} onPaste={handlePasteWrapper}
> >
<DropZoneOverlay active={isDragOver && !!canAttach} /> <DropZoneOverlay active={isDragOver} rejected={!isLeadRecipient} />
<div className="mb-1 flex items-center gap-2"> <div className="mb-1 flex items-center gap-2">
{isLeadRecipient ? ( {isLeadRecipient ? (
@ -291,7 +328,7 @@ export const MessageComposer = ({
<AttachmentPreviewList <AttachmentPreviewList
attachments={draft.attachments} attachments={draft.attachments}
onRemove={draft.removeAttachment} onRemove={draft.removeAttachment}
error={draft.attachmentError} error={draft.attachmentError ?? imageRestrictionError}
onDismissError={draft.clearAttachmentError} onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked} disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient." disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
@ -302,7 +339,7 @@ export const MessageComposer = ({
<AttachmentPreviewList <AttachmentPreviewList
attachments={draft.attachments} attachments={draft.attachments}
onRemove={draft.removeAttachment} onRemove={draft.removeAttachment}
error={draft.attachmentError} error={draft.attachmentError ?? imageRestrictionError}
onDismissError={draft.clearAttachmentError} onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked} disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient." disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."

View file

@ -264,6 +264,7 @@ export interface TeamSlice {
selectedTeamName: string | null; selectedTeamName: string | null;
selectedTeamData: TeamData | null; selectedTeamData: TeamData | null;
selectedTeamLoading: boolean; selectedTeamLoading: boolean;
selectedTeamMessagesLoading: boolean;
selectedTeamError: string | null; selectedTeamError: string | null;
sendingMessage: boolean; sendingMessage: boolean;
sendMessageError: string | null; sendMessageError: string | null;
@ -289,7 +290,10 @@ export interface TeamSlice {
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void; openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void;
clearKanbanFilter: () => void; clearKanbanFilter: () => void;
selectTeam: (teamName: string, opts?: { skipProjectAutoSelect?: boolean }) => Promise<void>; selectTeam: (teamName: string, opts?: { skipProjectAutoSelect?: boolean }) => Promise<void>;
refreshTeamData: (teamName: string) => Promise<void>; refreshTeamData: (
teamName: string,
opts?: { includeMessages?: boolean; messagesLoading?: boolean }
) => Promise<void>;
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
requestReview: (teamName: string, taskId: string) => Promise<void>; requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
@ -395,6 +399,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamName: null, selectedTeamName: null,
selectedTeamData: null, selectedTeamData: null,
selectedTeamLoading: false, selectedTeamLoading: false,
selectedTeamMessagesLoading: false,
selectedTeamError: null, selectedTeamError: null,
sendingMessage: false, sendingMessage: false,
sendMessageError: null, sendMessageError: null,
@ -615,13 +620,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamName: teamName, selectedTeamName: teamName,
selectedTeamData: prev !== teamName ? null : get().selectedTeamData, selectedTeamData: prev !== teamName ? null : get().selectedTeamData,
selectedTeamLoading: true, selectedTeamLoading: true,
selectedTeamMessagesLoading: true,
selectedTeamError: null, selectedTeamError: null,
reviewActionError: null, reviewActionError: null,
}); });
try { try {
const data = await withTimeout( 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_GET_DATA_TIMEOUT_MS,
`team:getData(${teamName})` `team:getData(${teamName})`
); );
@ -712,6 +718,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (msg === 'TEAM_PROVISIONING' || (msg.includes('TEAM_PROVISIONING') && isProvisioning)) { if (msg === 'TEAM_PROVISIONING' || (msg.includes('TEAM_PROVISIONING') && isProvisioning)) {
set({ set({
selectedTeamLoading: true, selectedTeamLoading: true,
selectedTeamMessagesLoading: true,
selectedTeamData: null, selectedTeamData: null,
selectedTeamError: null, selectedTeamError: null,
}); });
@ -726,22 +733,27 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
: 'Failed to fetch team data'; : 'Failed to fetch team data';
set({ set({
selectedTeamLoading: false, selectedTeamLoading: false,
selectedTeamMessagesLoading: false,
selectedTeamData: null, selectedTeamData: null,
selectedTeamError: message, selectedTeamError: message,
}); });
} }
}, },
refreshTeamData: async (teamName: string) => { refreshTeamData: async (teamName: string, opts) => {
const state = get(); const state = get();
if (state.selectedTeamName !== teamName) { if (state.selectedTeamName !== teamName) {
return; return;
} }
const includeMessages = opts?.includeMessages !== false;
if (opts?.messagesLoading !== undefined) {
set({ selectedTeamMessagesLoading: opts.messagesLoading });
}
// Silent refresh — update data without showing loading skeleton. // Silent refresh — update data without showing loading skeleton.
// Only selectTeam() sets loading: true (for initial load). // Only selectTeam() sets loading: true (for initial load).
try { try {
const data = await withTimeout( const data = await withTimeout(
unwrapIpc('team:getData', () => api.teams.getData(teamName)), unwrapIpc('team:getData', () => api.teams.getData(teamName, { includeMessages })),
TEAM_GET_DATA_TIMEOUT_MS, TEAM_GET_DATA_TIMEOUT_MS,
`refreshTeamData(${teamName})` `refreshTeamData(${teamName})`
); );
@ -751,6 +763,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} }
set({ set({
selectedTeamData: data, selectedTeamData: data,
selectedTeamMessagesLoading: includeMessages ? false : get().selectedTeamMessagesLoading,
selectedTeamError: null, selectedTeamError: null,
}); });
} catch (error) { } catch (error) {
@ -764,7 +777,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
? error.message ? error.message
: 'Failed to refresh team data'; : 'Failed to refresh team data';
logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`); 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<AppState, [], [], TeamSlice> = (set,
await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName));
const state = get(); const state = get();
if (state.selectedTeamName === teamName) { 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().fetchTeams();
await get().fetchAllTasks(); await get().fetchAllTasks();

View file

@ -52,6 +52,7 @@ import type {
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamData, TeamData,
TeamGetDataOptions,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamMessageNotificationData, TeamMessageNotificationData,
@ -400,7 +401,7 @@ export interface HttpServerAPI {
export interface TeamsAPI { export interface TeamsAPI {
list: () => Promise<TeamSummary[]>; list: () => Promise<TeamSummary[]>;
getData: (teamName: string) => Promise<TeamData>; getData: (teamName: string, options?: TeamGetDataOptions) => Promise<TeamData>;
getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>; getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>;
deleteTeam: (teamName: string) => Promise<void>; deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>; restoreTeam: (teamName: string) => Promise<void>;

View file

@ -301,6 +301,10 @@ export interface TeamData {
isAlive?: boolean; isAlive?: boolean;
} }
export interface TeamGetDataOptions {
includeMessages?: boolean;
}
export type EffortLevel = 'low' | 'medium' | 'high'; export type EffortLevel = 'low' | 'medium' | 'high';
export interface TeamLaunchRequest { export interface TeamLaunchRequest {