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:
parent
085ec144ac
commit
52ddbb2916
11 changed files with 338 additions and 182 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]"
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue