merge(main): integrate origin/main into spike/free-code-compat
This commit is contained in:
commit
53bcea337f
76 changed files with 1757 additions and 383 deletions
|
|
@ -26,7 +26,7 @@
|
|||
<sub>100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.</sub>
|
||||
</p>
|
||||
|
||||
<img width="1902" height="1350" alt="image" src="https://github.com/user-attachments/assets/9c8917e0-5847-4fa9-a495-0aa7b75fb939" />
|
||||
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/be19cfcb-93ff-403a-9a1e-8ff1a803c55e" />
|
||||
|
||||
|
||||
<table>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ export default defineConfig({
|
|||
input: {
|
||||
index: resolve(__dirname, 'src/main/index.ts'),
|
||||
'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts'),
|
||||
'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts')
|
||||
'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts'),
|
||||
'team-data-worker': resolve(__dirname, 'src/main/workers/team-data-worker.ts')
|
||||
},
|
||||
output: {
|
||||
// CJS format so bundled deps can use __dirname/require.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { addMainBreadcrumb } from '@main/sentry';
|
||||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||
import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient';
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
TEAM_GET_CLAUDE_LOGS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
|
|
@ -153,6 +155,7 @@ import type {
|
|||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
MessagesPage,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMessageNotificationData,
|
||||
|
|
@ -429,6 +432,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
||||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
||||
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||||
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
||||
|
|
@ -495,6 +499,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
||||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
||||
|
|
@ -632,33 +637,51 @@ async function handleGetData(
|
|||
let data: TeamData;
|
||||
setCurrentMainOp('team:getData');
|
||||
try {
|
||||
try {
|
||||
// Prefer worker thread to keep main event loop responsive
|
||||
const worker = getTeamDataWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
try {
|
||||
data = await worker.getTeamData(tn);
|
||||
} catch (workerErr) {
|
||||
logger.warn(
|
||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||
);
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
}
|
||||
} else {
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
message === `Team not found: ${tn}` &&
|
||||
getTeamProvisioningService().hasProvisioningRun(tn)
|
||||
) {
|
||||
return { success: false, error: 'TEAM_PROVISIONING' };
|
||||
}
|
||||
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
|
||||
if (message === `Team not found: ${tn}`) {
|
||||
const meta = await teamMetaStore.getMeta(tn);
|
||||
if (meta) {
|
||||
return { success: false, error: 'TEAM_DRAFT' };
|
||||
}
|
||||
}
|
||||
logger.error(`[teams:getData] ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
message === `Team not found: ${tn}` &&
|
||||
getTeamProvisioningService().hasProvisioningRun(tn)
|
||||
) {
|
||||
return { success: false, error: 'TEAM_PROVISIONING' };
|
||||
}
|
||||
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
|
||||
if (message === `Team not found: ${tn}`) {
|
||||
const meta = await teamMetaStore.getMeta(tn);
|
||||
if (meta) {
|
||||
return { success: false, error: 'TEAM_DRAFT' };
|
||||
}
|
||||
}
|
||||
logger.error(`[teams:getData] ${message}`);
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
setCurrentMainOp(null);
|
||||
}
|
||||
const getDataMs = Date.now() - startedAt;
|
||||
|
||||
if (getDataMs >= 1500) {
|
||||
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`);
|
||||
}
|
||||
const teamDataService = getTeamDataService();
|
||||
if (data.processes.some((process) => !process.stoppedAt)) {
|
||||
teamDataService.trackProcessHealthForTeam?.(tn);
|
||||
} else {
|
||||
teamDataService.untrackProcessHealthForTeam?.(tn);
|
||||
}
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isAlive = provisioning.isTeamAlive(tn);
|
||||
|
||||
|
|
@ -1590,6 +1613,29 @@ function buildMessageDeliveryText(
|
|||
return [...hiddenBlocks, baseText].join('\n\n');
|
||||
}
|
||||
|
||||
async function handleGetMessagesPage(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
options: unknown
|
||||
): Promise<IpcResult<MessagesPage>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) {
|
||||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const opts = (options && typeof options === 'object' ? options : {}) as {
|
||||
beforeTimestamp?: string;
|
||||
limit?: number;
|
||||
};
|
||||
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
|
||||
const beforeTimestamp =
|
||||
typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined;
|
||||
|
||||
return wrapTeamHandler('getMessagesPage', async () => {
|
||||
const service = getTeamDataService();
|
||||
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit });
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSendMessage(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
@ -2375,6 +2421,20 @@ async function handleGetLogsForTask(
|
|||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
// Prefer worker thread to keep main event loop responsive.
|
||||
// Call worker directly (not via wrapTeamHandler) so that failures
|
||||
// propagate to the catch block and trigger the main-thread fallback.
|
||||
const worker = getTeamDataWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
try {
|
||||
const result = await worker.findLogsForTask(vTeam.value!, vTask.value!, opts);
|
||||
return { success: true, data: result };
|
||||
} catch (workerErr) {
|
||||
logger.warn(
|
||||
`[teams:getLogsForTask] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return wrapTeamHandler('getLogsForTask', () =>
|
||||
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import type {
|
|||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
InboxMessage,
|
||||
MessagesPage,
|
||||
KanbanColumnId,
|
||||
KanbanState,
|
||||
ResolvedTeamMember,
|
||||
|
|
@ -1023,7 +1024,6 @@ export class TeamDataService {
|
|||
// Enrich members with git branch when it differs from lead's branch
|
||||
await this.enrichMemberBranches(members, config);
|
||||
mark('enrichBranches');
|
||||
|
||||
mark('syncComments');
|
||||
|
||||
let processes: TeamProcess[] = [];
|
||||
|
|
@ -1042,7 +1042,9 @@ export class TeamDataService {
|
|||
'inboxNames'
|
||||
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
||||
'sentMessages'
|
||||
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} post=${msBetween(
|
||||
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
||||
'kanbanGc'
|
||||
)} post=${msBetween(
|
||||
'postStart',
|
||||
'mergeMessages'
|
||||
)}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween(
|
||||
|
|
@ -1086,18 +1088,191 @@ export class TeamDataService {
|
|||
this.processHealthTeams.delete(teamName);
|
||||
}
|
||||
|
||||
// Cap messages to keep IPC payloads small. Full history is available
|
||||
// via the paginated getMessagesPage() API. We still include a small
|
||||
// batch here for backward compatibility (notifications, dedup, etc.).
|
||||
const MAX_RETURN_MESSAGES = 50;
|
||||
const cappedMessages =
|
||||
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
|
||||
|
||||
return {
|
||||
teamName,
|
||||
config,
|
||||
tasks: tasksWithKanban,
|
||||
members,
|
||||
messages,
|
||||
messages: cappedMessages,
|
||||
kanbanState,
|
||||
processes,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated message retrieval for the messages panel.
|
||||
* Uses cursor-based pagination by timestamp to handle live message insertion.
|
||||
*/
|
||||
async getMessagesPage(
|
||||
teamName: string,
|
||||
options: { beforeTimestamp?: string; limit: number }
|
||||
): Promise<MessagesPage> {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config) {
|
||||
return { messages: [], nextCursor: null, hasMore: false };
|
||||
}
|
||||
|
||||
// Collect all messages from the same sources as getTeamData
|
||||
let messages: InboxMessage[] = [];
|
||||
|
||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
||||
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]),
|
||||
this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
]);
|
||||
|
||||
messages = [...inboxMessages, ...leadTexts, ...sentMessages];
|
||||
|
||||
// Dedup lead_session vs lead_process (same logic as getTeamData)
|
||||
if (leadTexts.length > 0) {
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const getFingerprint = (msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>) =>
|
||||
`${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
const leadSessionFingerprints = new Set<string>();
|
||||
for (const msg of leadTexts) {
|
||||
if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg));
|
||||
}
|
||||
messages = messages.filter((m) => {
|
||||
if (m.source !== 'lead_process') return true;
|
||||
if (m.to) return true;
|
||||
return !leadSessionFingerprints.has(getFingerprint(m));
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich: propagate leadSessionId to messages missing it (same as getTeamData)
|
||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
const anchors: { time: number; sessionId: string }[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.leadSessionId) {
|
||||
anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId });
|
||||
}
|
||||
}
|
||||
if (anchors.length > 0) {
|
||||
for (const msg of messages) {
|
||||
if (msg.leadSessionId) continue;
|
||||
const msgTime = Date.parse(msg.timestamp);
|
||||
let best = anchors[0];
|
||||
let bestDist = Math.abs(msgTime - best.time);
|
||||
for (const a of anchors) {
|
||||
const dist = Math.abs(msgTime - a.time);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = a;
|
||||
} else if (dist > bestDist && a.time > msgTime) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg.leadSessionId = best.sessionId;
|
||||
}
|
||||
} else if (config.leadSessionId) {
|
||||
for (const msg of messages) {
|
||||
msg.leadSessionId = config.leadSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich: annotate slash command responses
|
||||
this.annotateSlashCommandResponses(messages);
|
||||
|
||||
// Sort newest-first, with stable tie-breaker by messageId
|
||||
messages.sort((a, b) => {
|
||||
const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
|
||||
if (diff !== 0) return diff;
|
||||
return (a.messageId ?? '').localeCompare(b.messageId ?? '');
|
||||
});
|
||||
|
||||
// Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
|
||||
// to handle multiple messages sharing the same timestamp.
|
||||
if (options.beforeTimestamp) {
|
||||
const [cursorTs, cursorId] = options.beforeTimestamp.split('|');
|
||||
const cursorMs = Date.parse(cursorTs);
|
||||
messages = messages.filter((m) => {
|
||||
const ms = Date.parse(m.timestamp);
|
||||
if (ms < cursorMs) return true;
|
||||
if (ms > cursorMs) return false;
|
||||
// Same timestamp — use messageId tie-breaker
|
||||
if (!cursorId) return false;
|
||||
return (m.messageId ?? '').localeCompare(cursorId) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const hasMore = messages.length > options.limit;
|
||||
const page = messages.slice(0, options.limit);
|
||||
const lastMsg = page[page.length - 1];
|
||||
const nextCursor =
|
||||
hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
|
||||
|
||||
return { messages: page, nextCursor, hasMore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches members with gitBranch when their cwd differs from the lead's.
|
||||
* Mutates members in-place for efficiency (called right after resolveMembers).
|
||||
*/
|
||||
private async enrichMemberBranches(
|
||||
members: ResolvedTeamMember[],
|
||||
config: TeamConfig
|
||||
): Promise<void> {
|
||||
const leadEntry = config.members?.find((member) => isLeadMember(member));
|
||||
const leadCwd = leadEntry?.cwd ?? config.projectPath;
|
||||
if (!leadCwd) return;
|
||||
|
||||
const withTimeout = async <T>(promise: Promise<T>, ms: number): Promise<T> => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
let leadBranch: string | null = null;
|
||||
try {
|
||||
leadBranch = await withTimeout(gitIdentityResolver.getBranch(path.normalize(leadCwd)), 2000);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = members.filter((member) => member.cwd && member.cwd !== leadCwd);
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const concurrency = process.platform === 'win32' ? 4 : 8;
|
||||
for (let index = 0; index < candidates.length; index += concurrency) {
|
||||
const batch = candidates.slice(index, index + concurrency);
|
||||
await Promise.all(
|
||||
batch.map(async (member) => {
|
||||
if (!member.cwd) return;
|
||||
try {
|
||||
const branch = await withTimeout(
|
||||
gitIdentityResolver.getBranch(path.normalize(member.cwd)),
|
||||
2000
|
||||
);
|
||||
if (branch && branch !== leadBranch) {
|
||||
member.gitBranch = branch;
|
||||
}
|
||||
} catch {
|
||||
// Member cwd may not be a git repo - skip silently.
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
startProcessHealthPolling(): void {
|
||||
if (this.processHealthTimer) return;
|
||||
this.processHealthTimer = setInterval(() => {
|
||||
|
|
@ -1162,68 +1337,6 @@ export class TeamDataService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches members with gitBranch when their cwd differs from the lead's.
|
||||
* Mutates members in-place for efficiency (called right after resolveMembers).
|
||||
*/
|
||||
private async enrichMemberBranches(
|
||||
members: ResolvedTeamMember[],
|
||||
config: TeamConfig
|
||||
): Promise<void> {
|
||||
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
|
||||
const leadEntry = config.members?.find((m) => isLeadMember(m));
|
||||
const leadCwd = leadEntry?.cwd ?? config.projectPath;
|
||||
if (!leadCwd) return;
|
||||
|
||||
const withTimeout = async <T>(p: Promise<T>, ms: number): Promise<T> => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
p,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
let leadBranch: string | null = null;
|
||||
try {
|
||||
// Git can hang on some Windows setups (network drives, locked repos, credential prompts).
|
||||
// Branch is best-effort; never block team:getData on it.
|
||||
leadBranch = await withTimeout(gitIdentityResolver.getBranch(path.normalize(leadCwd)), 2000);
|
||||
} catch {
|
||||
// Lead cwd may not be a git repo — skip enrichment entirely
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd);
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const concurrency = process.platform === 'win32' ? 4 : 8;
|
||||
for (let i = 0; i < candidates.length; i += concurrency) {
|
||||
const batch = candidates.slice(i, i + concurrency);
|
||||
await Promise.all(
|
||||
batch.map(async (member) => {
|
||||
if (!member.cwd) return;
|
||||
try {
|
||||
const branch = await withTimeout(
|
||||
gitIdentityResolver.getBranch(path.normalize(member.cwd)),
|
||||
2000
|
||||
);
|
||||
if (branch && branch !== leadBranch) {
|
||||
member.gitBranch = branch;
|
||||
}
|
||||
} catch {
|
||||
// Member cwd may not be a git repo — skip silently
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a member exists in members.meta.json.
|
||||
* Members can appear in the UI from three sources (see TeamMemberResolver):
|
||||
|
|
|
|||
184
src/main/services/team/TeamDataWorkerClient.ts
Normal file
184
src/main/services/team/TeamDataWorkerClient.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* Main-thread client for team-data-worker.
|
||||
*
|
||||
* Proxies getTeamData and findLogsForTask calls to a worker thread
|
||||
* so they don't block the Electron main event loop.
|
||||
* Falls back to main-thread execution if the worker is unavailable.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
||||
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
|
||||
|
||||
const logger = createLogger('Service:TeamDataWorkerClient');
|
||||
const WORKER_CALL_TIMEOUT_MS = 30_000;
|
||||
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const SAFE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$/;
|
||||
|
||||
function makeId(): string {
|
||||
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function resolveWorkerPath(): string | null {
|
||||
const baseDir =
|
||||
typeof __dirname === 'string' && __dirname.length > 0
|
||||
? __dirname
|
||||
: path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const candidates = [
|
||||
path.join(baseDir, 'team-data-worker.cjs'),
|
||||
path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// Don't warn here — resolveWorkerPath runs at module load time and
|
||||
// the worker file is expected to be absent during tests.
|
||||
// isAvailable() warns once on first access instead.
|
||||
return null;
|
||||
}
|
||||
|
||||
type PendingEntry = {
|
||||
resolve: (v: unknown) => void;
|
||||
reject: (e: Error) => void;
|
||||
};
|
||||
|
||||
export class TeamDataWorkerClient {
|
||||
private worker: Worker | null = null;
|
||||
private readonly workerPath: string | null = resolveWorkerPath();
|
||||
private warnedUnavailable = false;
|
||||
private pending = new Map<string, PendingEntry>();
|
||||
|
||||
private failWorker(worker: Worker, error: Error): void {
|
||||
if (this.worker !== worker) return;
|
||||
|
||||
this.worker = null;
|
||||
const pendingEntries = Array.from(this.pending.values());
|
||||
this.pending.clear();
|
||||
|
||||
for (const entry of pendingEntries) {
|
||||
entry.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
if (!this.workerPath && !this.warnedUnavailable) {
|
||||
this.warnedUnavailable = true;
|
||||
logger.debug('team-data-worker not found; falling back to main-thread execution');
|
||||
}
|
||||
return this.workerPath !== null;
|
||||
}
|
||||
|
||||
private ensureWorker(): Worker {
|
||||
if (!this.workerPath) throw new Error('Worker not available');
|
||||
if (this.worker) return this.worker;
|
||||
|
||||
const w = new Worker(this.workerPath);
|
||||
this.worker = w;
|
||||
|
||||
w.on('message', (msg: TeamDataWorkerResponse) => {
|
||||
const entry = this.pending.get(msg.id);
|
||||
if (!entry) return;
|
||||
this.pending.delete(msg.id);
|
||||
if (msg.ok) {
|
||||
entry.resolve(msg.result);
|
||||
} else {
|
||||
entry.reject(new Error(msg.error));
|
||||
}
|
||||
});
|
||||
|
||||
// Scope error/exit handlers to this specific worker instance.
|
||||
// Without this guard, a stale worker's exit event can reject
|
||||
// pending requests that belong to a newer replacement worker.
|
||||
w.on('error', (err) => {
|
||||
logger.error('Worker error', err);
|
||||
this.failWorker(w, err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
|
||||
w.on('exit', (code) => {
|
||||
if (code !== 0) logger.warn(`Worker exited with code ${code}`);
|
||||
this.failWorker(w, new Error(`Worker exited with code ${code}`));
|
||||
});
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
private call(
|
||||
op: TeamDataWorkerRequest['op'],
|
||||
payload: TeamDataWorkerRequest['payload']
|
||||
): Promise<unknown> {
|
||||
const worker = this.ensureWorker();
|
||||
const id = makeId();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`);
|
||||
this.failWorker(worker, timeoutError);
|
||||
worker.terminate().catch(() => undefined);
|
||||
reject(timeoutError);
|
||||
}, WORKER_CALL_TIMEOUT_MS);
|
||||
|
||||
this.pending.set(id, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
worker.postMessage({ id, op, payload } as TeamDataWorkerRequest);
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamData(teamName: string): Promise<TeamData> {
|
||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||
return this.call('getTeamData', { teamName }) as Promise<TeamData>;
|
||||
}
|
||||
|
||||
async findLogsForTask(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
}
|
||||
): Promise<MemberLogSummary[]> {
|
||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||
if (!SAFE_ID_RE.test(taskId)) throw new Error('Invalid taskId');
|
||||
return this.call('findLogsForTask', { teamName, taskId, options }) as Promise<
|
||||
MemberLogSummary[]
|
||||
>;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.worker?.terminate().catch(() => undefined);
|
||||
this.worker = null;
|
||||
for (const [, entry] of this.pending) {
|
||||
entry.reject(new Error('Client disposed'));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let singleton: TeamDataWorkerClient | null = null;
|
||||
export function getTeamDataWorkerClient(): TeamDataWorkerClient {
|
||||
if (!singleton) singleton = new TeamDataWorkerClient();
|
||||
return singleton;
|
||||
}
|
||||
|
|
@ -45,7 +45,8 @@ export class TeamInboxReader {
|
|||
|
||||
return entries
|
||||
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
|
||||
.map((name) => name.replace(/\.json$/, ''));
|
||||
.map((name) => name.replace(/\.json$/, ''))
|
||||
.filter((name) => name !== '*');
|
||||
}
|
||||
|
||||
async getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]> {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const ATTRIBUTION_CACHE_MAX = 5_000;
|
|||
const SCAN_CONCURRENCY = 15;
|
||||
|
||||
/** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */
|
||||
const DISCOVERY_CACHE_TTL = 5_000;
|
||||
const DISCOVERY_CACHE_TTL = 30_000;
|
||||
|
||||
/** Signal sources for subagent member attribution, ordered by reliability. */
|
||||
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
|
||||
|
|
|
|||
32
src/main/services/team/teamDataWorkerTypes.ts
Normal file
32
src/main/services/team/teamDataWorkerTypes.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Shared request/response types for the team-data-worker thread.
|
||||
*/
|
||||
|
||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
||||
|
||||
// ── Payloads ──
|
||||
|
||||
export interface GetTeamDataPayload {
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export interface FindLogsForTaskPayload {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Request / Response ──
|
||||
|
||||
export type TeamDataWorkerRequest =
|
||||
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
||||
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
|
||||
|
||||
export type TeamDataWorkerResponse =
|
||||
| { id: string; ok: true; result: TeamData | MemberLogSummary[] }
|
||||
| { id: string; ok: false; error: string };
|
||||
94
src/main/workers/team-data-worker.ts
Normal file
94
src/main/workers/team-data-worker.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Worker thread for heavy team I/O operations (getTeamData, findLogsForTask).
|
||||
*
|
||||
* Runs in its own event loop, completely isolated from the Electron main thread.
|
||||
* This prevents file-heavy operations (scanning 300+ subagent JSONL files,
|
||||
* parsing large session files) from stalling the main process UI/IPC.
|
||||
*/
|
||||
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
|
||||
import { TeamDataService } from '@main/services/team/TeamDataService';
|
||||
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { MemberLogSummary } from '@shared/types';
|
||||
|
||||
import type {
|
||||
TeamDataWorkerRequest,
|
||||
TeamDataWorkerResponse,
|
||||
} from '@main/services/team/teamDataWorkerTypes';
|
||||
|
||||
const logger = createLogger('Worker:TeamData');
|
||||
|
||||
// Instantiate services with default dependencies — worker has its own event loop
|
||||
const teamDataService = new TeamDataService();
|
||||
const logsFinder = new TeamMemberLogsFinder();
|
||||
|
||||
// In-flight dedup: concurrent calls for the same task piggyback on one request
|
||||
const logsInFlight = new Map<string, Promise<unknown>>();
|
||||
// Result cache with TTL to avoid re-scanning files
|
||||
const logsResultCache = new Map<string, { result: MemberLogSummary[]; cachedAt: number }>();
|
||||
const LOGS_CACHE_TTL_MS = 10_000;
|
||||
|
||||
function respond(msg: TeamDataWorkerResponse): void {
|
||||
parentPort?.postMessage(msg);
|
||||
}
|
||||
|
||||
parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
||||
try {
|
||||
switch (msg.op) {
|
||||
case 'getTeamData': {
|
||||
const result = await teamDataService.getTeamData(msg.payload.teamName);
|
||||
respond({ id: msg.id, ok: true, result });
|
||||
break;
|
||||
}
|
||||
case 'findLogsForTask': {
|
||||
const { teamName, taskId, options } = msg.payload;
|
||||
const intervalsKey = options?.intervals
|
||||
? options.intervals.map((i) => `${i.startedAt}~${i.completedAt ?? ''}`).join(',')
|
||||
: '';
|
||||
const cacheKey = `${teamName}:${taskId}:${options?.owner ?? ''}:${options?.status ?? ''}:${options?.since ?? ''}:${intervalsKey}`;
|
||||
|
||||
// Check result cache
|
||||
const cached = logsResultCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.cachedAt < LOGS_CACHE_TTL_MS) {
|
||||
respond({ id: msg.id, ok: true, result: cached.result });
|
||||
break;
|
||||
}
|
||||
|
||||
// Dedup concurrent calls
|
||||
let promise = logsInFlight.get(cacheKey) as Promise<MemberLogSummary[]> | undefined;
|
||||
if (!promise) {
|
||||
promise = logsFinder
|
||||
.findLogsForTask(teamName, taskId, options)
|
||||
.then((result) => {
|
||||
logsResultCache.set(cacheKey, { result, cachedAt: Date.now() });
|
||||
// Cap cache
|
||||
if (logsResultCache.size > 100) {
|
||||
const firstKey = logsResultCache.keys().next().value;
|
||||
if (firstKey !== undefined) logsResultCache.delete(firstKey);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
logsInFlight.delete(cacheKey);
|
||||
});
|
||||
logsInFlight.set(cacheKey, promise);
|
||||
}
|
||||
const result = await promise;
|
||||
respond({ id: msg.id, ok: true, result });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = msg;
|
||||
respond({ id: (_exhaustive as { id: string }).id, ok: false, error: `Unknown op` });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error(`[${msg.op}] ${message}`);
|
||||
respond({ id: msg.id, ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('team-data-worker started');
|
||||
|
|
@ -231,6 +231,9 @@ export const TEAM_UPDATE_KANBAN_COLUMN_ORDER = 'team:updateKanbanColumnOrder';
|
|||
/** Send inbox message to team member */
|
||||
export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
||||
|
||||
/** Paginated messages for timeline/messages panel */
|
||||
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
|
||||
|
||||
/** Request review for task */
|
||||
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
|
||||
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ import {
|
|||
TEAM_RESTORE_TASK,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
|
|
@ -260,6 +261,7 @@ import type {
|
|||
ScheduleRun,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
MessagesPage,
|
||||
SessionsByIdsOptions,
|
||||
SessionsPaginationOptions,
|
||||
SnippetDiff,
|
||||
|
|
@ -867,6 +869,12 @@ const electronAPI: ElectronAPI = {
|
|||
sendMessage: async (teamName: string, request: SendMessageRequest) => {
|
||||
return invokeIpcWithResult<SendMessageResult>(TEAM_SEND_MESSAGE, teamName, request);
|
||||
},
|
||||
getMessagesPage: async (
|
||||
teamName: string,
|
||||
options?: { beforeTimestamp?: string; limit?: number }
|
||||
) => {
|
||||
return invokeIpcWithResult<MessagesPage>(TEAM_GET_MESSAGES_PAGE, teamName, options);
|
||||
},
|
||||
createTask: async (teamName: string, request: CreateTaskRequest) => {
|
||||
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -729,6 +729,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
): Promise<SendMessageResult> => {
|
||||
throw new Error('Team messaging is not available in browser mode');
|
||||
},
|
||||
getMessagesPage: async () => {
|
||||
return { messages: [], nextCursor: null, hasMore: false };
|
||||
},
|
||||
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
|
||||
throw new Error('Team task creation is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ const AIChatGroupInner = ({
|
|||
);
|
||||
|
||||
// Notification color map for tool item dots
|
||||
const notifications = useStore((s) => s.notifications);
|
||||
const notifications = useStore(useShallow((s) => s.notifications));
|
||||
const notificationColorMap = useMemo(() => {
|
||||
const map = new Map<string, TriggerColor>();
|
||||
for (const n of notifications) {
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null;
|
||||
const pendingNavigation = thisTab?.pendingNavigation;
|
||||
|
||||
const teamBySessionId = useStore((s) => s.teamBySessionId);
|
||||
const teamBySessionId = useStore(useShallow((s) => s.teamBySessionId));
|
||||
|
||||
// Look up whether this session belongs to a team
|
||||
const sessionTeam = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -388,15 +388,17 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath;
|
||||
});
|
||||
|
||||
// Get team members for @mention highlighting
|
||||
const members = useStore((s) => s.selectedTeamData?.members);
|
||||
// Get team members for @mention highlighting and team names for @team linkification
|
||||
const { members, teams } = useStore(
|
||||
useShallow((s) => ({
|
||||
members: s.selectedTeamData?.members,
|
||||
teams: s.teams,
|
||||
}))
|
||||
);
|
||||
const memberColorMap = useMemo(
|
||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||
[members]
|
||||
);
|
||||
|
||||
// Get team names for @team linkification
|
||||
const teams = useStore((s) => s.teams);
|
||||
const teamNames = useMemo(
|
||||
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
||||
[teams]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
|
||||
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
|
||||
|
|
@ -82,7 +83,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description;
|
||||
|
||||
// Agent configs from .claude/agents/ for color lookup
|
||||
const agentConfigs = useStore((s) => s.agentConfigs);
|
||||
const agentConfigs = useStore(useShallow((s) => s.agentConfigs));
|
||||
|
||||
// Team member colors (when this subagent is a team member)
|
||||
const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null;
|
||||
|
|
@ -171,7 +172,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
}, [subagent.messages]);
|
||||
|
||||
// Search expansion
|
||||
const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds);
|
||||
const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds));
|
||||
const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId);
|
||||
const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -85,14 +86,14 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
const { isLight } = useTheme();
|
||||
|
||||
// Get team members for @mention highlighting
|
||||
const members = useStore((s) => s.selectedTeamData?.members);
|
||||
const members = useStore(useShallow((s) => s.selectedTeamData?.members));
|
||||
const memberColorMap = useMemo(
|
||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||
[members]
|
||||
);
|
||||
|
||||
// Get team names for @team linkification
|
||||
const teams = useStore((s) => s.teams);
|
||||
const teams = useStore(useShallow((s) => s.teams));
|
||||
const teamNames = useMemo(
|
||||
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
||||
[teams]
|
||||
|
|
|
|||
|
|
@ -690,7 +690,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
const [showRaw, setShowRaw] = React.useState(false);
|
||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||
const { isLight } = useTheme();
|
||||
const teams = useStore((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams));
|
||||
const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)));
|
||||
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
|
||||
|
||||
const fallbackTeamColorByName = React.useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@
|
|||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
export const CliInstallWarningBanner = (): React.JSX.Element | null => {
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliStatus = useStore(useShallow((s) => s.cliStatus));
|
||||
const openDashboard = useStore((s) => s.openDashboard);
|
||||
|
||||
// Returns a primitive boolean — minimizes re-renders
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
interface ConnectionStatusBadgeProps {
|
||||
contextId: string;
|
||||
|
|
@ -21,10 +22,12 @@ export const ConnectionStatusBadge = ({
|
|||
contextId,
|
||||
className,
|
||||
}: Readonly<ConnectionStatusBadgeProps>): React.JSX.Element => {
|
||||
const { connectionState, connectedHost } = useStore((s) => ({
|
||||
connectionState: s.connectionState,
|
||||
connectedHost: s.connectedHost,
|
||||
}));
|
||||
const { connectionState, connectedHost } = useStore(
|
||||
useShallow((s) => ({
|
||||
connectionState: s.connectionState,
|
||||
connectedHost: s.connectedHost,
|
||||
}))
|
||||
);
|
||||
|
||||
// Local context always shows Monitor icon
|
||||
if (contextId === 'local') {
|
||||
|
|
|
|||
|
|
@ -6,14 +6,26 @@
|
|||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { CheckCircle, Loader2, X } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
export const UpdateBanner = (): React.JSX.Element | null => {
|
||||
const showUpdateBanner = useStore((s) => s.showUpdateBanner);
|
||||
const updateStatus = useStore((s) => s.updateStatus);
|
||||
const downloadProgress = useStore((s) => s.downloadProgress);
|
||||
const availableVersion = useStore((s) => s.availableVersion);
|
||||
const installUpdate = useStore((s) => s.installUpdate);
|
||||
const dismissUpdateBanner = useStore((s) => s.dismissUpdateBanner);
|
||||
const {
|
||||
showUpdateBanner,
|
||||
updateStatus,
|
||||
downloadProgress,
|
||||
availableVersion,
|
||||
installUpdate,
|
||||
dismissUpdateBanner,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
showUpdateBanner: s.showUpdateBanner,
|
||||
updateStatus: s.updateStatus,
|
||||
downloadProgress: s.downloadProgress,
|
||||
availableVersion: s.availableVersion,
|
||||
installUpdate: s.installUpdate,
|
||||
dismissUpdateBanner: s.dismissUpdateBanner,
|
||||
}))
|
||||
);
|
||||
|
||||
if (!showUpdateBanner || (updateStatus !== 'downloading' && updateStatus !== 'downloaded')) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -15,15 +15,28 @@ import { useStore } from '@renderer/store';
|
|||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||
import { ExternalLink, X } from 'lucide-react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
export const UpdateDialog = (): React.JSX.Element | null => {
|
||||
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
|
||||
const updateStatus = useStore((s) => s.updateStatus);
|
||||
const availableVersion = useStore((s) => s.availableVersion);
|
||||
const releaseNotes = useStore((s) => s.releaseNotes);
|
||||
const downloadUpdate = useStore((s) => s.downloadUpdate);
|
||||
const installUpdate = useStore((s) => s.installUpdate);
|
||||
const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog);
|
||||
const {
|
||||
showUpdateDialog,
|
||||
updateStatus,
|
||||
availableVersion,
|
||||
releaseNotes,
|
||||
downloadUpdate,
|
||||
installUpdate,
|
||||
dismissUpdateDialog,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
showUpdateDialog: s.showUpdateDialog,
|
||||
updateStatus: s.updateStatus,
|
||||
availableVersion: s.availableVersion,
|
||||
releaseNotes: s.releaseNotes,
|
||||
downloadUpdate: s.downloadUpdate,
|
||||
installUpdate: s.installUpdate,
|
||||
dismissUpdateDialog: s.dismissUpdateDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -18,7 +20,6 @@ import {
|
|||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||
|
||||
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
||||
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
|
||||
|
|
@ -29,18 +30,41 @@ import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
|
|||
|
||||
export const ExtensionStoreView = (): React.JSX.Element => {
|
||||
const tabId = useTabIdOptional();
|
||||
const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog);
|
||||
const fetchApiKeys = useStore((s) => s.fetchApiKeys);
|
||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
||||
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
|
||||
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
|
||||
const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading);
|
||||
const skillsLoading = useStore((s) => s.skillsLoading);
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked
|
||||
const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing));
|
||||
const projects = useStore((s) => s.projects);
|
||||
const {
|
||||
fetchPluginCatalog,
|
||||
fetchCliStatus,
|
||||
fetchApiKeys,
|
||||
fetchSkillsCatalog,
|
||||
mcpBrowse,
|
||||
mcpFetchInstalled,
|
||||
pluginCatalogLoading,
|
||||
mcpBrowseLoading,
|
||||
skillsLoading,
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
openDashboard,
|
||||
sessions,
|
||||
projects,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
fetchPluginCatalog: s.fetchPluginCatalog,
|
||||
fetchCliStatus: s.fetchCliStatus,
|
||||
fetchApiKeys: s.fetchApiKeys,
|
||||
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
||||
mcpBrowse: s.mcpBrowse,
|
||||
mcpFetchInstalled: s.mcpFetchInstalled,
|
||||
pluginCatalogLoading: s.pluginCatalogLoading,
|
||||
mcpBrowseLoading: s.mcpBrowseLoading,
|
||||
skillsLoading: s.skillsLoading,
|
||||
cliStatus: s.cliStatus,
|
||||
cliStatusLoading: s.cliStatusLoading,
|
||||
openDashboard: s.openDashboard,
|
||||
sessions: s.sessions,
|
||||
projects: s.projects,
|
||||
}))
|
||||
);
|
||||
const cliInstalled = cliStatus?.installed ?? true;
|
||||
const hasOngoingSessions = sessions.some((sess) => sess.isOngoing);
|
||||
const extensionsTabProjectId = useStore((s) =>
|
||||
tabId
|
||||
? (s.paneLayout.panes.flatMap((pane) => pane.tabs).find((tab) => tab.id === tabId)
|
||||
|
|
@ -97,6 +121,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
void fetchPluginCatalog(projectPath ?? undefined);
|
||||
}, [fetchPluginCatalog, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchCliStatus();
|
||||
}, [fetchCliStatus]);
|
||||
|
||||
// Fetch MCP installed state on mount
|
||||
useEffect(() => {
|
||||
void mcpFetchInstalled(projectPath ?? undefined);
|
||||
|
|
@ -121,6 +149,71 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
|
||||
|
||||
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||
const cliStatusBanner = useMemo(() => {
|
||||
if (cliStatusLoading || cliStatus === null) {
|
||||
return (
|
||||
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text">Checking Claude CLI availability</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Extensions need Claude CLI to install plugins, run MCP servers, and validate auth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cliStatus.installed) {
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-amber-300">Claude CLI is not available</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to
|
||||
install it and retry.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={openDashboard}>
|
||||
Open Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cliStatus.authLoggedIn) {
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-amber-300">Claude CLI needs sign-in</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Claude CLI was found
|
||||
{cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin
|
||||
installs are disabled until you sign in from the Dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={openDashboard}>
|
||||
Open Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-emerald-300">Claude CLI is ready</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Plugins can be installed from this page
|
||||
{cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [cliStatus, cliStatusLoading, openDashboard]);
|
||||
|
||||
// Browser mode guard
|
||||
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
||||
|
|
@ -138,6 +231,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{cliStatusBanner}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
|
|||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
|
||||
|
||||
import { ApiKeyCard } from './ApiKeyCard';
|
||||
|
|
@ -15,11 +16,15 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog';
|
|||
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||
|
||||
export const ApiKeysPanel = (): React.JSX.Element => {
|
||||
const apiKeys = useStore((s) => s.apiKeys);
|
||||
const apiKeysLoading = useStore((s) => s.apiKeysLoading);
|
||||
const apiKeysError = useStore((s) => s.apiKeysError);
|
||||
const storageStatus = useStore((s) => s.apiKeyStorageStatus);
|
||||
const fetchStorageStatus = useStore((s) => s.fetchApiKeyStorageStatus);
|
||||
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore(
|
||||
useShallow((s) => ({
|
||||
apiKeys: s.apiKeys,
|
||||
apiKeysLoading: s.apiKeysLoading,
|
||||
apiKeysError: s.apiKeysError,
|
||||
storageStatus: s.apiKeyStorageStatus,
|
||||
fetchStorageStatus: s.fetchApiKeyStorageStatus,
|
||||
}))
|
||||
);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { ExtensionOperationState } from '@shared/types/extensions';
|
||||
|
|
@ -36,9 +37,25 @@ export const InstallButton = ({
|
|||
size = 'sm',
|
||||
errorMessage,
|
||||
}: InstallButtonProps) => {
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
||||
const isDisabled = disabled || cliMissing;
|
||||
const { cliStatus, cliStatusLoading } = useStore(
|
||||
useShallow((s) => ({
|
||||
cliStatus: s.cliStatus,
|
||||
cliStatusLoading: s.cliStatusLoading,
|
||||
}))
|
||||
);
|
||||
const cliUnknown = cliStatus === null;
|
||||
const cliMissing = cliStatus?.installed === false;
|
||||
const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn;
|
||||
const disableReason = cliStatusLoading
|
||||
? 'Checking Claude CLI status...'
|
||||
: cliUnknown
|
||||
? 'Checking Claude CLI availability...'
|
||||
: cliMissing
|
||||
? 'Claude CLI required. Install it from the Dashboard.'
|
||||
: authMissing
|
||||
? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.'
|
||||
: null;
|
||||
const isDisabled = disabled || Boolean(disableReason);
|
||||
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -91,23 +108,30 @@ export const InstallButton = ({
|
|||
</Button>
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
const tooltipMessage = disableReason ?? errorMessage;
|
||||
|
||||
if (tooltipMessage) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>{retryButton}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64 text-red-300">{errorMessage}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex max-w-64 flex-col items-end gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>{retryButton}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64 text-red-300">{tooltipMessage}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{errorMessage && !disableReason ? (
|
||||
<p className="text-right text-[11px] leading-4 text-red-300">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return retryButton;
|
||||
}
|
||||
|
||||
// idle — wrap in tooltip when CLI missing
|
||||
// idle — wrap in tooltip when install is unavailable
|
||||
const button = isInstalled ? (
|
||||
<Button
|
||||
size={size}
|
||||
|
|
@ -138,14 +162,14 @@ export const InstallButton = ({
|
|||
</Button>
|
||||
);
|
||||
|
||||
if (cliMissing) {
|
||||
if (disableReason) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>{button}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Claude CLI required</TooltipContent>
|
||||
<TooltipContent className="max-w-64">{disableReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||
|
|
@ -72,18 +73,35 @@ export const McpServersPanel = ({
|
|||
selectedMcpServerId,
|
||||
setSelectedMcpServerId,
|
||||
}: McpServersPanelProps): React.JSX.Element => {
|
||||
const browseCatalog = useStore((s) => s.mcpBrowseCatalog);
|
||||
const browseNextCursor = useStore((s) => s.mcpBrowseNextCursor);
|
||||
const browseLoading = useStore((s) => s.mcpBrowseLoading);
|
||||
const browseError = useStore((s) => s.mcpBrowseError);
|
||||
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
||||
const installedServers = useStore((s) => s.mcpInstalledServers);
|
||||
const fetchMcpGitHubStars = useStore((s) => s.fetchMcpGitHubStars);
|
||||
const mcpDiagnostics = useStore((s) => s.mcpDiagnostics);
|
||||
const mcpDiagnosticsLoading = useStore((s) => s.mcpDiagnosticsLoading);
|
||||
const mcpDiagnosticsError = useStore((s) => s.mcpDiagnosticsError);
|
||||
const mcpDiagnosticsLastCheckedAt = useStore((s) => s.mcpDiagnosticsLastCheckedAt);
|
||||
const runMcpDiagnostics = useStore((s) => s.runMcpDiagnostics);
|
||||
const {
|
||||
browseCatalog,
|
||||
browseNextCursor,
|
||||
browseLoading,
|
||||
browseError,
|
||||
mcpBrowse,
|
||||
installedServers,
|
||||
fetchMcpGitHubStars,
|
||||
mcpDiagnostics,
|
||||
mcpDiagnosticsLoading,
|
||||
mcpDiagnosticsError,
|
||||
mcpDiagnosticsLastCheckedAt,
|
||||
runMcpDiagnostics,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
browseCatalog: s.mcpBrowseCatalog,
|
||||
browseNextCursor: s.mcpBrowseNextCursor,
|
||||
browseLoading: s.mcpBrowseLoading,
|
||||
browseError: s.mcpBrowseError,
|
||||
mcpBrowse: s.mcpBrowse,
|
||||
installedServers: s.mcpInstalledServers,
|
||||
fetchMcpGitHubStars: s.fetchMcpGitHubStars,
|
||||
mcpDiagnostics: s.mcpDiagnostics,
|
||||
mcpDiagnosticsLoading: s.mcpDiagnosticsLoading,
|
||||
mcpDiagnosticsError: s.mcpDiagnosticsError,
|
||||
mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt,
|
||||
runMcpDiagnostics: s.runMcpDiagnostics,
|
||||
}))
|
||||
);
|
||||
|
||||
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
|||
</p>
|
||||
|
||||
{/* Footer: author + version + install button */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
|
||||
<span className="truncate">{plugin.author?.name ?? 'Unknown author'}</span>
|
||||
{plugin.version && (
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
getCapabilityLabel,
|
||||
inferCapabilities,
|
||||
|
|
@ -53,14 +54,18 @@ export const PluginDetailDialog = ({
|
|||
open,
|
||||
onClose,
|
||||
}: PluginDetailDialogProps): React.JSX.Element => {
|
||||
const fetchPluginReadme = useStore((s) => s.fetchPluginReadme);
|
||||
const readmes = useStore((s) => s.pluginReadmes);
|
||||
const readmeLoading = useStore((s) => s.pluginReadmeLoading);
|
||||
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
|
||||
useShallow((s) => ({
|
||||
fetchPluginReadme: s.fetchPluginReadme,
|
||||
readmes: s.pluginReadmes,
|
||||
readmeLoading: s.pluginReadmeLoading,
|
||||
installPlugin: s.installPlugin,
|
||||
uninstallPlugin: s.uninstallPlugin,
|
||||
}))
|
||||
);
|
||||
const installProgress = useStore(
|
||||
(s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle'
|
||||
);
|
||||
const installPlugin = useStore((s) => s.installPlugin);
|
||||
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
||||
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
|
||||
|
||||
const [scope, setScope] = useState<InstallScope>('user');
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
||||
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
||||
|
||||
|
|
@ -122,9 +123,13 @@ export const PluginsPanel = ({
|
|||
hasActiveFilters,
|
||||
setPluginSort,
|
||||
}: PluginsPanelProps): React.JSX.Element => {
|
||||
const catalog = useStore((s) => s.pluginCatalog);
|
||||
const loading = useStore((s) => s.pluginCatalogLoading);
|
||||
const error = useStore((s) => s.pluginCatalogError);
|
||||
const { catalog, loading, error } = useStore(
|
||||
useShallow((s) => ({
|
||||
catalog: s.pluginCatalog,
|
||||
loading: s.pluginCatalogLoading,
|
||||
error: s.pluginCatalogError,
|
||||
}))
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => selectFilteredPlugins(catalog, pluginFilters, pluginSort),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
interface SkillDetailDialogProps {
|
||||
|
|
@ -44,7 +45,7 @@ export const SkillDetailDialog = ({
|
|||
}: SkillDetailDialogProps): React.JSX.Element => {
|
||||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||
const deleteSkill = useStore((s) => s.deleteSkill);
|
||||
const detail = useStore((s) => (skillId ? s.skillsDetailsById[skillId] : undefined));
|
||||
const detail = useStore(useShallow((s) => (skillId ? s.skillsDetailsById[skillId] : undefined)));
|
||||
const loading = useStore((s) =>
|
||||
skillId ? (s.skillsDetailLoadingById[skillId] ?? false) : false
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpAZ,
|
||||
|
|
@ -94,10 +95,10 @@ export const SkillsPanel = ({
|
|||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
|
||||
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
||||
const detailById = useStore((s) => s.skillsDetailsById);
|
||||
const userSkills = useStore((s) => s.skillsUserCatalog);
|
||||
const projectSkills = useStore((s) =>
|
||||
projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : []
|
||||
const detailById = useStore(useShallow((s) => s.skillsDetailsById));
|
||||
const userSkills = useStore(useShallow((s) => s.skillsUserCatalog));
|
||||
const projectSkills = useStore(
|
||||
useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : []))
|
||||
);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@
|
|||
import { Fragment } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { PaneResizeHandle } from './PaneResizeHandle';
|
||||
import { PaneView } from './PaneView';
|
||||
|
||||
export const PaneContainer = (): React.JSX.Element => {
|
||||
const panes = useStore((s) => s.paneLayout.panes);
|
||||
const panes = useStore(useShallow((s) => s.paneLayout.panes));
|
||||
|
||||
return (
|
||||
<div id="pane-container" className="flex flex-1 overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
interface PaneResizeHandleProps {
|
||||
leftPaneId: string;
|
||||
|
|
@ -15,7 +16,7 @@ interface PaneResizeHandleProps {
|
|||
export const PaneResizeHandle = ({ leftPaneId }: PaneResizeHandleProps): React.JSX.Element => {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizePanes = useStore((s) => s.resizePanes);
|
||||
const paneLayout = useStore((s) => s.paneLayout);
|
||||
const paneLayout = useStore(useShallow((s) => s.paneLayout));
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
|
|
|
|||
|
|
@ -76,17 +76,19 @@ export const SortableTab = ({
|
|||
)
|
||||
);
|
||||
|
||||
const teamColorSet = useStore((s) => {
|
||||
if (tab.type !== 'team' || !tab.teamName) return null;
|
||||
const team = s.teamByName[tab.teamName];
|
||||
const explicitColor =
|
||||
team?.color ??
|
||||
(s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined);
|
||||
if (explicitColor) return getTeamColorSet(explicitColor);
|
||||
// Fallback: deterministic color derived from display name
|
||||
const displayName = team?.displayName ?? tab.label;
|
||||
return nameColorSet(displayName);
|
||||
});
|
||||
const teamColorSet = useStore(
|
||||
useShallow((s) => {
|
||||
if (tab.type !== 'team' || !tab.teamName) return null;
|
||||
const team = s.teamByName[tab.teamName];
|
||||
const explicitColor =
|
||||
team?.color ??
|
||||
(s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined);
|
||||
if (explicitColor) return getTeamColorSet(explicitColor);
|
||||
// Fallback: deterministic color derived from display name
|
||||
const displayName = team?.displayName ?? tab.label;
|
||||
return nameColorSet(displayName);
|
||||
})
|
||||
);
|
||||
const activeBorderColor = teamColorSet?.border ?? 'var(--color-accent, #6366f1)';
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { useFullScreen } from '@renderer/hooks/useFullScreen';
|
|||
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
|
||||
import { useZoomFactor } from '@renderer/hooks/useZoomFactor';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner';
|
||||
import { UpdateBanner } from '../common/UpdateBanner';
|
||||
|
|
@ -54,7 +55,7 @@ export const TabbedLayout = (): React.JSX.Element => {
|
|||
: getTrafficLightPaddingForZoom(zoomFactor);
|
||||
|
||||
// --- DnD state (lifted from PaneContainer) ---
|
||||
const panes = useStore((s) => s.paneLayout.panes);
|
||||
const panes = useStore(useShallow((s) => s.paneLayout.panes));
|
||||
const [activeTab, setActiveTab] = useState<Tab | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { computeTakeaways } from '@renderer/utils/reportAssessments';
|
||||
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
|
||||
|
||||
|
|
@ -25,11 +26,13 @@ interface SessionReportTabProps {
|
|||
|
||||
export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
|
||||
// Find session data from any session tab with matching sessionId
|
||||
const sessionDetail = useStore((s) => {
|
||||
const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs);
|
||||
const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId);
|
||||
return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null;
|
||||
});
|
||||
const sessionDetail = useStore(
|
||||
useShallow((s) => {
|
||||
const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs);
|
||||
const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId);
|
||||
return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null;
|
||||
})
|
||||
);
|
||||
|
||||
const report = useMemo(
|
||||
() => (sessionDetail ? analyzeSession(sessionDetail) : null),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
||||
import {
|
||||
|
|
@ -68,7 +69,7 @@ const ScheduleListItem = ({
|
|||
}: ScheduleListItemProps): React.JSX.Element => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedRun, setSelectedRun] = useState<ScheduleRun | null>(null);
|
||||
const runs = useStore((s) => s.scheduleRuns[schedule.id] ?? []);
|
||||
const runs = useStore(useShallow((s) => s.scheduleRuns[schedule.id] ?? []));
|
||||
const runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false);
|
||||
const fetchRunHistory = useStore((s) => s.fetchRunHistory);
|
||||
|
||||
|
|
@ -240,15 +241,29 @@ const ScheduleListItem = ({
|
|||
// =============================================================================
|
||||
|
||||
export const SchedulesView = (): React.JSX.Element => {
|
||||
const schedules = useStore((s) => s.schedules);
|
||||
const schedulesLoading = useStore((s) => s.schedulesLoading);
|
||||
const fetchSchedules = useStore((s) => s.fetchSchedules);
|
||||
const pauseSchedule = useStore((s) => s.pauseSchedule);
|
||||
const resumeSchedule = useStore((s) => s.resumeSchedule);
|
||||
const deleteSchedule = useStore((s) => s.deleteSchedule);
|
||||
const triggerNow = useStore((s) => s.triggerNow);
|
||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
||||
const teamByName = useStore((s) => s.teamByName);
|
||||
const {
|
||||
schedules,
|
||||
schedulesLoading,
|
||||
fetchSchedules,
|
||||
pauseSchedule,
|
||||
resumeSchedule,
|
||||
deleteSchedule,
|
||||
triggerNow,
|
||||
openTeamTab,
|
||||
teamByName,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
schedules: s.schedules,
|
||||
schedulesLoading: s.schedulesLoading,
|
||||
fetchSchedules: s.fetchSchedules,
|
||||
pauseSchedule: s.pauseSchedule,
|
||||
resumeSchedule: s.resumeSchedule,
|
||||
deleteSchedule: s.deleteSchedule,
|
||||
triggerNow: s.triggerNow,
|
||||
openTeamTab: s.openTeamTab,
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
|
||||
/** Resolve team color dot style for a given team name */
|
||||
const getTeamColor = useCallback(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
|
|||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useSettingsConfig, useSettingsHandlers } from './hooks';
|
||||
import {
|
||||
|
|
@ -19,8 +20,12 @@ import { type SettingsSection, SettingsTabs } from './SettingsTabs';
|
|||
|
||||
export const SettingsView = (): React.JSX.Element | null => {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general');
|
||||
const pendingSettingsSection = useStore((s) => s.pendingSettingsSection);
|
||||
const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection);
|
||||
const { pendingSettingsSection, clearPendingSettingsSection } = useStore(
|
||||
useShallow((s) => ({
|
||||
pendingSettingsSection: s.pendingSettingsSection,
|
||||
clearPendingSettingsSection: s.clearPendingSettingsSection,
|
||||
}))
|
||||
);
|
||||
|
||||
// Consume pending section (avoid setState during render)
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { api, isElectronMode } from '@renderer/api';
|
|||
import appIcon from '@renderer/favicon.png';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { CheckCircle, Code2, Download, FileEdit, Loader2, RefreshCw, Upload } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { SettingsSectionHeader } from '../components';
|
||||
|
||||
|
|
@ -32,9 +33,13 @@ export const AdvancedSection = ({
|
|||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const [version, setVersion] = useState<string>('');
|
||||
const [configEditorOpen, setConfigEditorOpen] = useState(false);
|
||||
const updateStatus = useStore((s) => s.updateStatus);
|
||||
const availableVersion = useStore((s) => s.availableVersion);
|
||||
const checkForUpdates = useStore((s) => s.checkForUpdates);
|
||||
const { updateStatus, availableVersion, checkForUpdates } = useStore(
|
||||
useShallow((s) => ({
|
||||
updateStatus: s.updateStatus,
|
||||
availableVersion: s.availableVersion,
|
||||
checkForUpdates: s.checkForUpdates,
|
||||
}))
|
||||
);
|
||||
|
||||
// Auto-revert "not-available" / "error" status back to idle after a brief display
|
||||
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { SettingRow } from '../components/SettingRow';
|
||||
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
||||
|
|
@ -37,16 +38,31 @@ const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
|
|||
];
|
||||
|
||||
export const ConnectionSection = (): React.JSX.Element => {
|
||||
const connectionState = useStore((s) => s.connectionState);
|
||||
const connectedHost = useStore((s) => s.connectedHost);
|
||||
const connectionError = useStore((s) => s.connectionError);
|
||||
const connectSsh = useStore((s) => s.connectSsh);
|
||||
const disconnectSsh = useStore((s) => s.disconnectSsh);
|
||||
const testConnection = useStore((s) => s.testConnection);
|
||||
const sshConfigHosts = useStore((s) => s.sshConfigHosts);
|
||||
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
|
||||
const lastSshConfig = useStore((s) => s.lastSshConfig);
|
||||
const loadLastConnection = useStore((s) => s.loadLastConnection);
|
||||
const {
|
||||
connectionState,
|
||||
connectedHost,
|
||||
connectionError,
|
||||
connectSsh,
|
||||
disconnectSsh,
|
||||
testConnection,
|
||||
sshConfigHosts,
|
||||
fetchSshConfigHosts,
|
||||
lastSshConfig,
|
||||
loadLastConnection,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
connectionState: s.connectionState,
|
||||
connectedHost: s.connectedHost,
|
||||
connectionError: s.connectionError,
|
||||
connectSsh: s.connectSsh,
|
||||
disconnectSsh: s.disconnectSsh,
|
||||
testConnection: s.testConnection,
|
||||
sshConfigHosts: s.sshConfigHosts,
|
||||
fetchSshConfigHosts: s.fetchSshConfigHosts,
|
||||
lastSshConfig: s.lastSshConfig,
|
||||
loadLastConnection: s.loadLastConnection,
|
||||
}))
|
||||
);
|
||||
|
||||
// Form state
|
||||
const [host, setHost] = useState('');
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useStore } from '@renderer/store';
|
|||
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
||||
import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { SettingRow, SettingsSectionHeader, SettingsToggle } from '../components';
|
||||
|
||||
|
|
@ -50,9 +51,13 @@ export const GeneralSection = ({
|
|||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Claude Root state
|
||||
const connectionMode = useStore((s) => s.connectionMode);
|
||||
const fetchProjects = useStore((s) => s.fetchProjects);
|
||||
const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups);
|
||||
const { connectionMode, fetchProjects, fetchRepositoryGroups } = useStore(
|
||||
useShallow((s) => ({
|
||||
connectionMode: s.connectionMode,
|
||||
fetchProjects: s.fetchProjects,
|
||||
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
||||
}))
|
||||
);
|
||||
|
||||
const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null);
|
||||
const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { projectColor } from '@renderer/utils/projectColor';
|
||||
|
|
@ -78,7 +79,7 @@ export const SidebarTaskItem = ({
|
|||
getDisplaySubject,
|
||||
}: SidebarTaskItemProps): React.JSX.Element => {
|
||||
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
||||
const teamMembers = useStore((s) => s.teamByName[task.teamName]?.members);
|
||||
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
|
||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
|||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
|
|
@ -69,15 +70,14 @@ export const TaskTooltip = ({
|
|||
children,
|
||||
side = 'top',
|
||||
}: TaskTooltipProps): React.JSX.Element => {
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
const selectedTeamData = useStore((s) => {
|
||||
if (teamName) {
|
||||
return s.selectedTeamName === teamName ? s.selectedTeamData : null;
|
||||
}
|
||||
return s.selectedTeamData;
|
||||
});
|
||||
const globalTasks = useStore((s) => s.globalTasks);
|
||||
const teamByName = useStore((s) => s.teamByName);
|
||||
const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore(
|
||||
useShallow((s) => ({
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
globalTasks: s.globalTasks,
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
|
||||
const task = useMemo(() => {
|
||||
if (teamName && selectedTeamName === teamName) {
|
||||
|
|
|
|||
|
|
@ -1051,6 +1051,9 @@ export const TeamDetailView = ({
|
|||
setMessagesPanelMode,
|
||||
setMessagesPanelWidth,
|
||||
setSidebarLogsHeight,
|
||||
selectReviewFile,
|
||||
pendingReviewRequest,
|
||||
setPendingReviewRequest,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
projects: s.projects,
|
||||
|
|
@ -1096,6 +1099,9 @@ export const TeamDetailView = ({
|
|||
setMessagesPanelMode: s.setMessagesPanelMode,
|
||||
setMessagesPanelWidth: s.setMessagesPanelWidth,
|
||||
setSidebarLogsHeight: s.setSidebarLogsHeight,
|
||||
selectReviewFile: s.selectReviewFile,
|
||||
pendingReviewRequest: s.pendingReviewRequest,
|
||||
setPendingReviewRequest: s.setPendingReviewRequest,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -1302,17 +1308,69 @@ export const TeamDetailView = ({
|
|||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Live git branch polling for the team's project path
|
||||
// Live git branch tracking for the lead project and member worktrees
|
||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||
const branchSyncPaths = useMemo(
|
||||
() => (teamProjectPath ? [teamProjectPath] : []),
|
||||
[teamProjectPath]
|
||||
);
|
||||
// Live branch sync now uses main-side background tracking instead of renderer polling.
|
||||
const leadProjectPath = useMemo(() => {
|
||||
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
|
||||
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
|
||||
}, [data?.members, teamProjectPath]);
|
||||
const branchSyncPaths = useMemo(() => {
|
||||
const uniquePaths = new Map<string, string>();
|
||||
const addPath = (candidate: string | null | undefined): void => {
|
||||
const trimmed = candidate?.trim();
|
||||
if (!trimmed) return;
|
||||
const key = normalizePath(trimmed);
|
||||
if (!key || uniquePaths.has(key)) return;
|
||||
uniquePaths.set(key, trimmed);
|
||||
};
|
||||
|
||||
addPath(leadProjectPath);
|
||||
for (const member of data?.members ?? []) {
|
||||
addPath(member.cwd);
|
||||
}
|
||||
|
||||
return Array.from(uniquePaths.values());
|
||||
}, [data?.members, leadProjectPath]);
|
||||
useBranchSync(branchSyncPaths, { live: true });
|
||||
const leadBranch = useStore((s) =>
|
||||
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null
|
||||
const trackedBranches = useStore(
|
||||
useShallow((s) =>
|
||||
Object.fromEntries(
|
||||
branchSyncPaths.map((projectPath) => {
|
||||
const normalizedPath = normalizePath(projectPath);
|
||||
return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const;
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
const leadBranch = leadProjectPath
|
||||
? (trackedBranches[normalizePath(leadProjectPath)] ?? null)
|
||||
: null;
|
||||
const membersWithLiveBranches = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return data.members.map((member) => {
|
||||
const memberPath = member.cwd?.trim();
|
||||
const nextGitBranch =
|
||||
memberPath && !isLeadMember(member) && leadBranch !== null
|
||||
? (() => {
|
||||
const branch = trackedBranches[normalizePath(memberPath)] ?? null;
|
||||
return branch && branch !== leadBranch ? branch : undefined;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
if (member.gitBranch === nextGitBranch) {
|
||||
return member;
|
||||
}
|
||||
|
||||
const nextMember: ResolvedTeamMember = { ...member };
|
||||
if (nextGitBranch) {
|
||||
nextMember.gitBranch = nextGitBranch;
|
||||
} else {
|
||||
delete nextMember.gitBranch;
|
||||
}
|
||||
return nextMember;
|
||||
});
|
||||
}, [data, leadBranch, trackedBranches]);
|
||||
|
||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||
const teamSessionIds = useMemo(() => {
|
||||
|
|
@ -1383,7 +1441,7 @@ export const TeamDetailView = ({
|
|||
return result;
|
||||
}, [data, timeWindow, kanbanFilter.selectedOwners]);
|
||||
|
||||
const activeMembers = useStableActiveMembers(data?.members);
|
||||
const activeMembers = useStableActiveMembers(membersWithLiveBranches);
|
||||
|
||||
const kanbanDisplayTasks = useMemo(() => {
|
||||
const query = kanbanSearch.trim();
|
||||
|
|
@ -1522,10 +1580,6 @@ export const TeamDetailView = ({
|
|||
}
|
||||
}, [teamName, refreshTeamData]);
|
||||
|
||||
const selectReviewFile = useStore((s) => s.selectReviewFile);
|
||||
const pendingReviewRequest = useStore((s) => s.pendingReviewRequest);
|
||||
const setPendingReviewRequest = useStore((s) => s.setPendingReviewRequest);
|
||||
|
||||
// Pick up pending review request from GlobalTaskDetailDialog
|
||||
useEffect(() => {
|
||||
if (!pendingReviewRequest) return;
|
||||
|
|
@ -1546,12 +1600,12 @@ export const TeamDetailView = ({
|
|||
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
|
||||
useEffect(() => {
|
||||
if (!pendingMemberProfile || !data) return;
|
||||
const member = data.members.find((m) => m.name === pendingMemberProfile);
|
||||
const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
}
|
||||
useStore.getState().closeMemberProfile();
|
||||
}, [pendingMemberProfile, data]);
|
||||
}, [pendingMemberProfile, membersWithLiveBranches]);
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
(taskId: string) => {
|
||||
|
|
@ -2102,7 +2156,7 @@ export const TeamDetailView = ({
|
|||
>
|
||||
<TeamMemberListBridge
|
||||
teamName={teamName}
|
||||
members={data.members}
|
||||
members={membersWithLiveBranches}
|
||||
memberTaskCounts={memberTaskCounts}
|
||||
taskMap={taskMap}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
|
|
@ -2348,7 +2402,7 @@ export const TeamDetailView = ({
|
|||
>
|
||||
<ProcessesSection
|
||||
teamName={teamName}
|
||||
members={data.members}
|
||||
members={membersWithLiveBranches}
|
||||
processes={data.processes}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
|
@ -2466,7 +2520,7 @@ export const TeamDetailView = ({
|
|||
currentName={data.config.name}
|
||||
currentDescription={data.config.description ?? ''}
|
||||
currentColor={data.config.color ?? ''}
|
||||
currentMembers={data.members.filter((m) => !isLeadMember(m))}
|
||||
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
|
||||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||||
projectPath={data.config.projectPath}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
|
|
@ -2476,8 +2530,8 @@ export const TeamDetailView = ({
|
|||
<AddMemberDialog
|
||||
open={addMemberDialogOpen}
|
||||
teamName={teamName}
|
||||
existingNames={data.members.map((m) => m.name)}
|
||||
existingMembers={data.members}
|
||||
existingNames={membersWithLiveBranches.map((m) => m.name)}
|
||||
existingMembers={membersWithLiveBranches}
|
||||
projectPath={data.config.projectPath}
|
||||
adding={addingMemberLoading}
|
||||
onClose={() => setAddMemberDialogOpen(false)}
|
||||
|
|
@ -2563,7 +2617,7 @@ export const TeamDetailView = ({
|
|||
mode="launch"
|
||||
open={launchDialogOpen}
|
||||
teamName={teamName}
|
||||
members={data?.members ?? []}
|
||||
members={membersWithLiveBranches}
|
||||
defaultProjectPath={data.config.projectPath}
|
||||
provisioningError={provisioningError}
|
||||
clearProvisioningError={clearProvisioningError}
|
||||
|
|
|
|||
|
|
@ -251,6 +251,8 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
fetchTeams,
|
||||
openTeamTab,
|
||||
deleteTeam,
|
||||
restoreTeam,
|
||||
permanentlyDeleteTeam,
|
||||
projects,
|
||||
globalTasks,
|
||||
fetchAllTasks,
|
||||
|
|
@ -259,6 +261,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
selectedRepositoryId,
|
||||
selectedWorktreeId,
|
||||
activeProjectId,
|
||||
branchByPath,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
teams: s.teams,
|
||||
|
|
@ -277,6 +280,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
selectedRepositoryId: s.selectedRepositoryId,
|
||||
selectedWorktreeId: s.selectedWorktreeId,
|
||||
activeProjectId: s.activeProjectId,
|
||||
branchByPath: s.branchByPath,
|
||||
}))
|
||||
);
|
||||
const {
|
||||
|
|
@ -461,10 +465,6 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
[filteredTeams]
|
||||
);
|
||||
useBranchSync(teamPaths, { live: false });
|
||||
const branchByPath = useStore((s) => s.branchByPath);
|
||||
|
||||
const restoreTeam = useStore((s) => s.restoreTeam);
|
||||
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
|
||||
|
||||
const handleDeleteTeam = useCallback(
|
||||
(teamName: string, isDraft: boolean, e: React.MouseEvent) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
||||
|
|
@ -141,12 +142,23 @@ function useElapsed(receivedAt: string): number {
|
|||
const RESPOND_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const ToolApprovalSheet: React.FC = () => {
|
||||
const pendingApprovals = useStore((s) => s.pendingApprovals);
|
||||
const respondToToolApproval = useStore((s) => s.respondToToolApproval);
|
||||
const updateToolApprovalSettings = useStore((s) => s.updateToolApprovalSettings);
|
||||
const teams = useStore((s) => s.teams);
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
const selectedTeamData = useStore((s) => s.selectedTeamData);
|
||||
const {
|
||||
pendingApprovals,
|
||||
respondToToolApproval,
|
||||
updateToolApprovalSettings,
|
||||
teams,
|
||||
selectedTeamName,
|
||||
selectedTeamData,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
pendingApprovals: s.pendingApprovals,
|
||||
respondToToolApproval: s.respondToToolApproval,
|
||||
updateToolApprovalSettings: s.updateToolApprovalSettings,
|
||||
teams: s.teams,
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
}))
|
||||
);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
const current: ToolApprovalRequest | undefined = pendingApprovals[0];
|
||||
|
|
@ -606,7 +618,7 @@ const ToolInputPreview = ({
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Element | null => {
|
||||
const settings = useStore((s) => s.toolApprovalSettings);
|
||||
const settings = useStore(useShallow((s) => s.toolApprovalSettings));
|
||||
const elapsed = useElapsed(receivedAt);
|
||||
|
||||
if (settings.timeoutAction === 'wait') return null;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
getMessageTypeLabel,
|
||||
getStructuredMessageSummary,
|
||||
|
|
@ -785,8 +786,8 @@ export const ActivityItem = memo(
|
|||
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
|
||||
|
||||
// Permission request status icon (check/x/clock)
|
||||
const pendingApprovals = useStore((s) => s.pendingApprovals);
|
||||
const resolvedApprovals = useStore((s) => s.resolvedApprovals);
|
||||
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
|
||||
const resolvedApprovals = useStore(useShallow((s) => s.resolvedApprovals));
|
||||
const permissionIcon = useMemo(() => {
|
||||
if (!structured) return null;
|
||||
const type = typeof structured.type === 'string' ? structured.type : null;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
|
|
@ -35,7 +36,7 @@ export const PendingRepliesBlock = ({
|
|||
onMemberClick,
|
||||
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
||||
const { isLight } = useTheme();
|
||||
const pendingApprovals = useStore((s) => s.pendingApprovals);
|
||||
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
const memberPending = Object.entries(pendingRepliesByMember)
|
||||
.map(([name, sentAtMs]) => ({
|
||||
|
|
|
|||
|
|
@ -40,9 +40,10 @@ import {
|
|||
isGeminiUiFrozen,
|
||||
normalizeCreateLaunchProviderForUi,
|
||||
} from '@renderer/utils/geminiUiFreeze';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import {
|
||||
|
|
@ -215,8 +216,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// Team name: always present for launch mode, may be absent in schedule mode (standalone page)
|
||||
const propsTeamName = props.teamName ?? '';
|
||||
const [selectedTeamName, setSelectedTeamName] = useState('');
|
||||
const teamByName = useStore((s) => s.teamByName);
|
||||
const openDashboard = useStore((s) => s.openDashboard);
|
||||
const { teamByName, openDashboard } = useStore(
|
||||
useShallow((s) => ({
|
||||
teamByName: s.teamByName,
|
||||
openDashboard: s.openDashboard,
|
||||
}))
|
||||
);
|
||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
||||
const teamOptions = useMemo(
|
||||
() =>
|
||||
|
|
@ -904,7 +909,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// Shared effects: projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const repositoryGroups = useStore((s) => s.repositoryGroups);
|
||||
const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
|
||||
|
||||
import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types';
|
||||
|
|
@ -42,7 +43,7 @@ export const ToolApprovalSettingsContent: React.FC<{
|
|||
teamName?: string;
|
||||
}> = ({ expanded, teamName }) => {
|
||||
const [localSeconds, setLocalSeconds] = useState<string>('');
|
||||
const settings = useStore((s) => s.toolApprovalSettings);
|
||||
const settings = useStore(useShallow((s) => s.toolApprovalSettings));
|
||||
const rawUpdateSettings = useStore((s) => s.updateToolApprovalSettings);
|
||||
const updateSettings = useCallback(
|
||||
(patch: Partial<ToolApprovalSettings>) => rawUpdateSettings(patch, teamName),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
|
|
@ -15,7 +17,7 @@ interface KanbanColumnProps {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const KanbanColumn = ({
|
||||
export const KanbanColumn = memo(function KanbanColumn({
|
||||
title,
|
||||
count,
|
||||
icon,
|
||||
|
|
@ -27,7 +29,7 @@ export const KanbanColumn = ({
|
|||
headerDragClassName,
|
||||
headerAccessory,
|
||||
children,
|
||||
}: KanbanColumnProps): React.JSX.Element => {
|
||||
}: KanbanColumnProps): React.JSX.Element {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
|
|
@ -64,4 +66,4 @@ export const KanbanColumn = ({
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
|
|
@ -68,10 +67,11 @@ export const MemberDetailDialog = ({
|
|||
[tasks, member]
|
||||
);
|
||||
|
||||
const memberMessages = useMemo(
|
||||
const seedMemberMessages = useMemo(
|
||||
() => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []),
|
||||
[messages, member]
|
||||
);
|
||||
const memberMessages = seedMemberMessages;
|
||||
|
||||
const inProgressTasks = useMemo(
|
||||
() => memberTasks.filter((t) => t.status === 'in_progress').length,
|
||||
|
|
@ -164,7 +164,11 @@ export const MemberDetailDialog = ({
|
|||
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
|
||||
</TabsContent>
|
||||
<TabsContent value="messages">
|
||||
<MemberMessagesTab messages={memberMessages} teamName={teamName} />
|
||||
<MemberMessagesTab
|
||||
messages={memberMessages}
|
||||
teamName={teamName}
|
||||
memberName={member.name}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="stats">
|
||||
<MemberStatsTab
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatTeamModelSummary,
|
||||
|
|
@ -230,15 +230,19 @@ export const MemberList = memo(function MemberList({
|
|||
}, [handleResize]);
|
||||
|
||||
const gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1';
|
||||
const activeMembers = members
|
||||
.filter((m) => !m.removedAt)
|
||||
.sort((a, b) => {
|
||||
if (isLeadMember(a)) return -1;
|
||||
if (isLeadMember(b)) return 1;
|
||||
return 0;
|
||||
});
|
||||
const removedMembers = members.filter((m) => m.removedAt);
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
const activeMembers = useMemo(
|
||||
() =>
|
||||
members
|
||||
.filter((m) => !m.removedAt)
|
||||
.sort((a, b) => {
|
||||
if (isLeadMember(a)) return -1;
|
||||
if (isLeadMember(b)) return 1;
|
||||
return 0;
|
||||
}),
|
||||
[members]
|
||||
);
|
||||
const removedMembers = useMemo(() => members.filter((m) => m.removedAt), [members]);
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
|
||||
const buildRuntimeSummary = useCallback(
|
||||
(member: ResolvedTeamMember): string | undefined => {
|
||||
|
|
@ -259,17 +263,24 @@ export const MemberList = memo(function MemberList({
|
|||
);
|
||||
}
|
||||
|
||||
// Pre-compute reviewer→task map to avoid O(n×m) scan per member
|
||||
const reviewTaskByMember = useMemo(() => {
|
||||
const result = new Map<string, TeamTaskWithKanban>();
|
||||
if (!taskMap) return result;
|
||||
for (const task of taskMap.values()) {
|
||||
if (task.reviewer && (task.reviewState === 'review' || task.kanbanColumn === 'review')) {
|
||||
result.set(task.reviewer, task);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [taskMap]);
|
||||
|
||||
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
|
||||
const currentTask =
|
||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||
const reviewTask = taskMap
|
||||
? (Array.from(taskMap.values()).find(
|
||||
(task) =>
|
||||
task.reviewer === member.name &&
|
||||
task.id !== member.currentTaskId &&
|
||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
||||
) ?? null)
|
||||
: null;
|
||||
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
||||
const reviewTask =
|
||||
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
|
||||
const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]);
|
||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||
import {
|
||||
|
|
@ -107,6 +110,11 @@ export const MemberLogsTab = ({
|
|||
showLeadPreview = false,
|
||||
onPreviewOnlineChange,
|
||||
}: MemberLogsTabProps): React.JSX.Element => {
|
||||
// Visibility check: skip polling when tab is hidden (display:none) to avoid OOM
|
||||
const tabId = useTabIdOptional();
|
||||
const activeTabId = useStore((s) => s.activeTabId);
|
||||
const isTabActive = tabId ? activeTabId === tabId : true; // default true when no tab context (e.g. standalone dialog)
|
||||
|
||||
const MIN_REFRESH_VISIBLE_MS = 250;
|
||||
const intervalsKey = useMemo(
|
||||
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
|
||||
|
|
@ -466,23 +474,33 @@ export const MemberLogsTab = ({
|
|||
setError(e instanceof Error ? e.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
if (didBeginRefreshing) endRefreshing();
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
if (didBeginRefreshing) endRefreshing();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
if (isTabActive || !hasLoadedRef.current) {
|
||||
void load();
|
||||
}
|
||||
|
||||
const interval = shouldAutoRefresh ? setInterval(() => void load(), 5000) : null;
|
||||
const interval = shouldAutoRefresh && isTabActive ? setInterval(() => void load(), 5000) : null;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (interval) clearInterval(interval);
|
||||
// Reset refresh state so the indicator doesn't stay latched
|
||||
// when the effect tears down mid-refresh (e.g. tab switch).
|
||||
refreshCountRef.current = 0;
|
||||
if (refreshHideTimeoutRef.current) {
|
||||
clearTimeout(refreshHideTimeoutRef.current);
|
||||
refreshHideTimeoutRef.current = null;
|
||||
}
|
||||
setRefreshing(false);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey + taskSince drive refresh; deps intentionally minimal to avoid refetch loops
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey, taskSince]);
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey, taskSince, isTabActive]);
|
||||
|
||||
const fetchDetailForLog = useCallback(
|
||||
async (
|
||||
|
|
@ -537,7 +555,7 @@ export const MemberLogsTab = ({
|
|||
if (!previewLog) return;
|
||||
|
||||
const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing;
|
||||
if (!shouldAutoRefreshPreview) return;
|
||||
if (!shouldAutoRefreshPreview || !isTabActive) return;
|
||||
|
||||
let cancelled = false;
|
||||
const interval = setInterval(async () => {
|
||||
|
|
@ -566,12 +584,14 @@ export const MemberLogsTab = ({
|
|||
shouldShowPreview,
|
||||
taskStatus,
|
||||
intervalsKey,
|
||||
isTabActive,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||
if (!expandedLogSummary) return;
|
||||
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
|
||||
if (!isTabActive) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
|
|
@ -604,6 +624,7 @@ export const MemberLogsTab = ({
|
|||
taskId,
|
||||
taskStatus,
|
||||
intervalsKey,
|
||||
isTabActive,
|
||||
]);
|
||||
|
||||
const handleExpand = useCallback(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||
|
||||
import { ActivityItem } from '../activity/ActivityItem';
|
||||
|
|
@ -9,44 +12,122 @@ import type { InboxMessage } from '@shared/types';
|
|||
interface MemberMessagesTabProps {
|
||||
messages: InboxMessage[];
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
}
|
||||
|
||||
const MAX_MESSAGES = 100;
|
||||
const MEMBER_MESSAGES_PAGE_SIZE = 50;
|
||||
|
||||
export const MemberMessagesTab = ({
|
||||
messages,
|
||||
teamName,
|
||||
memberName,
|
||||
onCreateTask,
|
||||
}: MemberMessagesTabProps): React.JSX.Element => {
|
||||
const [pagedMessages, setPagedMessages] = useState<InboxMessage[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPagedMessages([]);
|
||||
setNextCursor(null);
|
||||
setHasMore(false);
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, { limit: MEMBER_MESSAGES_PAGE_SIZE });
|
||||
if (cancelled) return;
|
||||
const memberPageMessages = page.messages.filter(
|
||||
(message) => message.from === memberName || message.to === memberName
|
||||
);
|
||||
setPagedMessages(memberPageMessages);
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPagedMessages([]);
|
||||
setNextCursor(null);
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teamName, memberName]);
|
||||
|
||||
const loadOlderMessages = useCallback(async () => {
|
||||
if (!nextCursor || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, {
|
||||
beforeTimestamp: nextCursor,
|
||||
limit: MEMBER_MESSAGES_PAGE_SIZE,
|
||||
});
|
||||
const memberPageMessages = page.messages.filter(
|
||||
(message) => message.from === memberName || message.to === memberName
|
||||
);
|
||||
setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages));
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [teamName, memberName, nextCursor, loading]);
|
||||
|
||||
const effectiveMessages = useMemo(
|
||||
() => mergeTeamMessages(messages, pagedMessages),
|
||||
[messages, pagedMessages]
|
||||
);
|
||||
|
||||
const displayMessages = useMemo(
|
||||
() =>
|
||||
filterTeamMessages(messages, {
|
||||
filterTeamMessages(effectiveMessages, {
|
||||
timeWindow: null,
|
||||
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||
searchQuery: '',
|
||||
}).slice(0, MAX_MESSAGES),
|
||||
[messages]
|
||||
[effectiveMessages]
|
||||
);
|
||||
|
||||
if (displayMessages.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] px-4 py-6 text-center text-sm text-[var(--color-text-muted)]">
|
||||
No messages with this member
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const emptyStateText = loading
|
||||
? 'Loading messages...'
|
||||
: hasMore
|
||||
? 'No loaded messages for this member yet'
|
||||
: 'No messages with this member';
|
||||
|
||||
return (
|
||||
<div className="max-h-[320px] space-y-2 overflow-y-auto">
|
||||
{displayMessages.map((msg, idx) => (
|
||||
<ActivityItem
|
||||
key={msg.messageId ?? idx}
|
||||
message={msg}
|
||||
teamName={teamName}
|
||||
onCreateTask={onCreateTask}
|
||||
/>
|
||||
))}
|
||||
{displayMessages.length > 0 ? (
|
||||
displayMessages.map((msg, idx) => (
|
||||
<ActivityItem
|
||||
key={msg.messageId ?? idx}
|
||||
message={msg}
|
||||
teamName={teamName}
|
||||
onCreateTask={onCreateTask}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--color-border)] px-4 py-6 text-center text-sm text-[var(--color-text-muted)]">
|
||||
{emptyStateText}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button variant="ghost" size="sm" className="text-xs" disabled={loading} onClick={() => void loadOlderMessages()}>
|
||||
{loading ? 'Loading...' : 'Load older messages'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
|||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
|
|
@ -104,7 +105,7 @@ export const MessageComposer = ({
|
|||
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
|
||||
const [teamSelectorOpen, setTeamSelectorOpen] = useState(false);
|
||||
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
|
||||
const allCrossTeamTargets = useStore((s) => s.crossTeamTargets);
|
||||
const allCrossTeamTargets = useStore(useShallow((s) => s.crossTeamTargets));
|
||||
const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Checkbox } from '@renderer/components/ui/checkbox';
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { Filter } from 'lucide-react';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -7,6 +8,8 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe
|
|||
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -111,13 +114,96 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
onRestartTeam,
|
||||
onTaskIdClick,
|
||||
}: MessagesPanelProps): React.JSX.Element {
|
||||
const sendTeamMessage = useStore((s) => s.sendTeamMessage);
|
||||
const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage);
|
||||
const sendingMessage = useStore((s) => s.sendingMessage);
|
||||
const sendMessageError = useStore((s) => s.sendMessageError);
|
||||
const lastSendMessageResult = useStore((s) => s.lastSendMessageResult);
|
||||
const teams = useStore((s) => s.teams);
|
||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
||||
const {
|
||||
sendTeamMessage,
|
||||
sendCrossTeamMessage,
|
||||
sendingMessage,
|
||||
sendMessageError,
|
||||
lastSendMessageResult,
|
||||
teams,
|
||||
openTeamTab,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
sendTeamMessage: s.sendTeamMessage,
|
||||
sendCrossTeamMessage: s.sendCrossTeamMessage,
|
||||
sendingMessage: s.sendingMessage,
|
||||
sendMessageError: s.sendMessageError,
|
||||
lastSendMessageResult: s.lastSendMessageResult,
|
||||
teams: s.teams,
|
||||
openTeamTab: s.openTeamTab,
|
||||
}))
|
||||
);
|
||||
|
||||
// ── Paginated message fetching ──
|
||||
// Messages are now fetched via getMessagesPage API instead of coming
|
||||
// from getTeamData. The `messages` prop is used as initial seed if non-empty.
|
||||
const PAGE_SIZE = 50;
|
||||
const [fetchedMessages, setFetchedMessages] = useState<InboxMessage[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
// Initial fetch on mount or team change
|
||||
useEffect(() => {
|
||||
const id = ++fetchIdRef.current;
|
||||
setMessagesLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
|
||||
if (fetchIdRef.current !== id) return;
|
||||
setFetchedMessages(page.messages);
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// Fallback: use prop messages if API fails
|
||||
if (fetchIdRef.current === id && messages.length > 0) {
|
||||
setFetchedMessages(messages);
|
||||
}
|
||||
} finally {
|
||||
if (fetchIdRef.current === id) setMessagesLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change
|
||||
|
||||
// Auto-refresh: poll for NEW messages only (prepend to head).
|
||||
// Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow.
|
||||
useEffect(() => {
|
||||
if (!isTeamAlive && leadActivity !== 'active') return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
|
||||
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [teamName, isTeamAlive, leadActivity]);
|
||||
|
||||
const loadOlderMessages = useCallback(async () => {
|
||||
if (!nextCursor || messagesLoading) return;
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, {
|
||||
beforeTimestamp: nextCursor,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, [teamName, nextCursor, messagesLoading]);
|
||||
|
||||
// Use fetched messages, fall back to prop messages during initial load
|
||||
const effectiveMessages = useMemo(() => {
|
||||
if (fetchedMessages.length === 0) return messages;
|
||||
return mergeTeamMessages(fetchedMessages, messages);
|
||||
}, [fetchedMessages, messages]);
|
||||
|
||||
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -189,7 +275,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
|
||||
const filteredMessages = useMemo(() => {
|
||||
const startedAt = performance.now();
|
||||
const result = filterTeamMessages(messages, {
|
||||
const result = filterTeamMessages(effectiveMessages, {
|
||||
timeWindow,
|
||||
filter: messagesFilter,
|
||||
searchQuery: messagesSearchQuery,
|
||||
|
|
@ -197,17 +283,17 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
const ms = performance.now() - startedAt;
|
||||
if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) {
|
||||
logger.warn(
|
||||
`[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${messages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
|
||||
`[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
|
||||
messagesFilter.showNoise ? 'on' : 'off'
|
||||
}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||
}, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]);
|
||||
|
||||
const activityTimelineMessages = useMemo(() => {
|
||||
const startedAt = performance.now();
|
||||
const result = filterTeamMessages(messages, {
|
||||
const result = filterTeamMessages(effectiveMessages, {
|
||||
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
||||
timeWindow,
|
||||
filter: messagesFilter,
|
||||
|
|
@ -216,22 +302,22 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
const ms = performance.now() - startedAt;
|
||||
if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) {
|
||||
logger.warn(
|
||||
`[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${messages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
|
||||
`[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
|
||||
messagesFilter.showNoise ? 'on' : 'off'
|
||||
}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||
}, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]);
|
||||
|
||||
const replyCandidateMessages = useMemo(
|
||||
() =>
|
||||
messages.filter(
|
||||
effectiveMessages.filter(
|
||||
(m) =>
|
||||
m.messageKind !== 'task_comment_notification' &&
|
||||
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
|
||||
),
|
||||
[messages]
|
||||
[effectiveMessages]
|
||||
);
|
||||
|
||||
// Resolve the expanded item from filtered messages
|
||||
|
|
@ -320,7 +406,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
}
|
||||
}
|
||||
if (changed) onPendingReplyChange(() => next);
|
||||
}, [replyCandidateMessages, pendingRepliesByMember, onPendingReplyChange]);
|
||||
}, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(
|
||||
|
|
@ -403,7 +489,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
teamName={teamName}
|
||||
members={members}
|
||||
filter={messagesFilter}
|
||||
messages={messages}
|
||||
messages={effectiveMessages}
|
||||
open={messagesFilterOpen}
|
||||
onOpenChange={setMessagesFilterOpen}
|
||||
onApply={setMessagesFilter}
|
||||
|
|
@ -451,7 +537,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
<StatusBlock
|
||||
members={members}
|
||||
tasks={tasks}
|
||||
messages={messages}
|
||||
messages={effectiveMessages}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
position="inline"
|
||||
onMemberClick={onMemberClick}
|
||||
|
|
@ -482,6 +568,19 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
onExpandItem={handleExpandItem}
|
||||
onExpandContent={handleExpandContent}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-text-muted"
|
||||
disabled={messagesLoading}
|
||||
onClick={() => void loadOlderMessages()}
|
||||
>
|
||||
{messagesLoading ? 'Loading...' : 'Load older messages'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MessageExpandDialog
|
||||
expandedItem={expandedItem}
|
||||
open={expandedItemKey !== null}
|
||||
|
|
@ -616,7 +715,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
<StatusBlock
|
||||
members={members}
|
||||
tasks={tasks}
|
||||
messages={messages}
|
||||
messages={effectiveMessages}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
position="sidebar"
|
||||
onMemberClick={onMemberClick}
|
||||
|
|
@ -648,6 +747,19 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
onExpandItem={handleExpandItem}
|
||||
onExpandContent={handleExpandContent}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-text-muted"
|
||||
disabled={messagesLoading}
|
||||
onClick={() => void loadOlderMessages()}
|
||||
>
|
||||
{messagesLoading ? 'Loading...' : 'Load older messages'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MessageExpandDialog
|
||||
expandedItem={expandedItem}
|
||||
open={expandedItemKey !== null}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react';
|
||||
|
||||
import { CliLogsRichView } from '../CliLogsRichView';
|
||||
|
|
@ -72,11 +73,13 @@ export const ScheduleRunLogDialog = ({
|
|||
onClose,
|
||||
}: ScheduleRunLogDialogProps): React.JSX.Element => {
|
||||
// Read live run data from store — falls back to initial prop if not found
|
||||
const liveRun = useStore((s) => {
|
||||
if (!initialRun) return null;
|
||||
const runs = s.scheduleRuns[scheduleId] ?? [];
|
||||
return runs.find((r) => r.id === initialRun.id) ?? initialRun;
|
||||
});
|
||||
const liveRun = useStore(
|
||||
useShallow((s) => {
|
||||
if (!initialRun) return null;
|
||||
const runs = s.scheduleRuns[scheduleId] ?? [];
|
||||
return runs.find((r) => r.id === initialRun.id) ?? initialRun;
|
||||
})
|
||||
);
|
||||
const run = liveRun ?? initialRun;
|
||||
|
||||
const [logs, setLogs] = useState<{ stdout: string; stderr: string } | null>(null);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
||||
import {
|
||||
ChevronDown,
|
||||
|
|
@ -57,7 +58,7 @@ const ScheduleRow = ({
|
|||
}: ScheduleRowProps): React.JSX.Element => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedRun, setSelectedRun] = useState<ScheduleRun | null>(null);
|
||||
const runs = useStore((s) => s.scheduleRuns[schedule.id] ?? []);
|
||||
const runs = useStore(useShallow((s) => s.scheduleRuns[schedule.id] ?? []));
|
||||
const runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false);
|
||||
const fetchRunHistory = useStore((s) => s.fetchRunHistory);
|
||||
|
||||
|
|
@ -207,17 +208,22 @@ const ScheduleRow = ({
|
|||
// =============================================================================
|
||||
|
||||
export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.Element => {
|
||||
const schedules = useStore((s) => s.schedules.filter((sch) => sch.teamName === teamName));
|
||||
const pauseSchedule = useStore((s) => s.pauseSchedule);
|
||||
const resumeSchedule = useStore((s) => s.resumeSchedule);
|
||||
const deleteSchedule = useStore((s) => s.deleteSchedule);
|
||||
const triggerNow = useStore((s) => s.triggerNow);
|
||||
const { schedules, pauseSchedule, resumeSchedule, deleteSchedule, triggerNow, fetchSchedules } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
schedules: s.schedules.filter((sch) => sch.teamName === teamName),
|
||||
pauseSchedule: s.pauseSchedule,
|
||||
resumeSchedule: s.resumeSchedule,
|
||||
deleteSchedule: s.deleteSchedule,
|
||||
triggerNow: s.triggerNow,
|
||||
fetchSchedules: s.fetchSchedules,
|
||||
}))
|
||||
);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
||||
|
||||
// Fetch schedules on mount
|
||||
const fetchSchedules = useStore((s) => s.fetchSchedules);
|
||||
useEffect(() => {
|
||||
void fetchSchedules();
|
||||
}, [fetchSchedules]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createContext, useContext, useId, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import {
|
||||
removeTeamSidebarHost,
|
||||
|
|
@ -33,10 +34,12 @@ export const TeamSidebarHost = ({
|
|||
}: TeamSidebarHostProps): React.JSX.Element => {
|
||||
const hostId = useId();
|
||||
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||
const { messagesPanelMode, messagesPanelWidth } = useStore((s) => ({
|
||||
messagesPanelMode: s.messagesPanelMode,
|
||||
messagesPanelWidth: s.messagesPanelWidth,
|
||||
}));
|
||||
const { messagesPanelMode, messagesPanelWidth } = useStore(
|
||||
useShallow((s) => ({
|
||||
messagesPanelMode: s.messagesPanelMode,
|
||||
messagesPanelWidth: s.messagesPanelWidth,
|
||||
}))
|
||||
);
|
||||
const snapshot = useTeamSidebarPortalSnapshot();
|
||||
const isVisible = messagesPanelMode === 'sidebar';
|
||||
const isOwner = isVisible && snapshot.activeHostIdByTeam[teamName] === hostId;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useMemo } from 'react';
|
|||
|
||||
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -84,9 +85,13 @@ export const GraphTaskCard = ({
|
|||
}: GraphTaskCardProps): React.JSX.Element => {
|
||||
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
|
||||
|
||||
const task = useStore((s) => s.selectedTeamData?.tasks.find((t) => t.id === taskId));
|
||||
const tasks = useStore((s) => s.selectedTeamData?.tasks ?? []);
|
||||
const members = useStore((s) => s.selectedTeamData?.members ?? []);
|
||||
const { task, tasks, members } = useStore(
|
||||
useShallow((s) => ({
|
||||
task: s.selectedTeamData?.tasks.find((t) => t.id === taskId),
|
||||
tasks: s.selectedTeamData?.tasks ?? [],
|
||||
members: s.selectedTeamData?.members ?? [],
|
||||
}))
|
||||
);
|
||||
|
||||
const taskMap = useMemo(() => {
|
||||
const map = new Map<string, TeamTask>();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { CliInstallationStatus, CliProviderId } from '@shared/types';
|
||||
|
||||
|
|
@ -39,23 +40,45 @@ export function useCliInstaller(): {
|
|||
installCli: () => void;
|
||||
isBusy: boolean;
|
||||
} {
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading);
|
||||
const cliStatusError = useStore((s) => s.cliStatusError);
|
||||
const installerState = useStore((s) => s.cliInstallerState);
|
||||
const downloadProgress = useStore((s) => s.cliDownloadProgress);
|
||||
const downloadTransferred = useStore((s) => s.cliDownloadTransferred);
|
||||
const downloadTotal = useStore((s) => s.cliDownloadTotal);
|
||||
const installerError = useStore((s) => s.cliInstallerError);
|
||||
const installerDetail = useStore((s) => s.cliInstallerDetail);
|
||||
const installerRawChunks = useStore((s) => s.cliInstallerRawChunks);
|
||||
const completedVersion = useStore((s) => s.cliCompletedVersion);
|
||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||
const fetchCliProviderStatus = useStore((s) => s.fetchCliProviderStatus);
|
||||
const invalidateCliStatus = useStore((s) => s.invalidateCliStatus);
|
||||
const installCli = useStore((s) => s.installCli);
|
||||
const {
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
cliStatusError,
|
||||
installerState,
|
||||
downloadProgress,
|
||||
downloadTransferred,
|
||||
downloadTotal,
|
||||
installerError,
|
||||
installerDetail,
|
||||
installerRawChunks,
|
||||
completedVersion,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchCliProviderStatus,
|
||||
invalidateCliStatus,
|
||||
installCli,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
cliStatus: s.cliStatus,
|
||||
cliStatusLoading: s.cliStatusLoading,
|
||||
cliProviderStatusLoading: s.cliProviderStatusLoading,
|
||||
cliStatusError: s.cliStatusError,
|
||||
installerState: s.cliInstallerState,
|
||||
downloadProgress: s.cliDownloadProgress,
|
||||
downloadTransferred: s.cliDownloadTransferred,
|
||||
downloadTotal: s.cliDownloadTotal,
|
||||
installerError: s.cliInstallerError,
|
||||
installerDetail: s.cliInstallerDetail,
|
||||
installerRawChunks: s.cliInstallerRawChunks,
|
||||
completedVersion: s.cliCompletedVersion,
|
||||
bootstrapCliStatus: s.bootstrapCliStatus,
|
||||
fetchCliStatus: s.fetchCliStatus,
|
||||
fetchCliProviderStatus: s.fetchCliProviderStatus,
|
||||
invalidateCliStatus: s.invalidateCliStatus,
|
||||
installCli: s.installCli,
|
||||
}))
|
||||
);
|
||||
|
||||
const isBusy =
|
||||
installerState !== 'idle' && installerState !== 'error' && installerState !== 'completed';
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function useTabUI(): UseTabUIReturn {
|
|||
|
||||
// Subscribe to tabUIStates MAP directly for reactivity
|
||||
// This ensures re-renders when any tab state changes
|
||||
const tabUIStates = useStore((s) => s.tabUIStates);
|
||||
const tabUIStates = useStore(useShallow((s) => s.tabUIStates));
|
||||
|
||||
// Get the current tab's state (derived from subscribed state)
|
||||
const tabState = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
|
||||
|
|
@ -56,10 +57,14 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean {
|
|||
}
|
||||
|
||||
export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult {
|
||||
const globalTasks = useStore((s) => s.globalTasks);
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
const selectedTeamData = useStore((s) => s.selectedTeamData);
|
||||
const teamByName = useStore((s) => s.teamByName);
|
||||
const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore(
|
||||
useShallow((s) => ({
|
||||
globalTasks: s.globalTasks,
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
|
||||
const suggestions = useMemo<MentionSuggestion[]>(() => {
|
||||
const tasks: TaskWithTeamContext[] = [];
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ export interface UseTeamSuggestionsResult {
|
|||
* @param currentTeamName - The current team name to exclude from suggestions
|
||||
*/
|
||||
export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult {
|
||||
const teams = useStore((s) => s.teams);
|
||||
const teams = useStore(useShallow((s) => s.teams));
|
||||
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -407,6 +407,16 @@ export function initializeNotificationListeners(): () => void {
|
|||
return;
|
||||
}
|
||||
|
||||
// Cleanup cursors for teams that no longer exist (prevent unbounded growth)
|
||||
if (inProgressChangePresenceCursorByTeam.size > 50) {
|
||||
const teamNames = new Set(useStore.getState().teams.map((t) => t.teamName));
|
||||
for (const key of inProgressChangePresenceCursorByTeam.keys()) {
|
||||
if (!teamNames.has(key)) {
|
||||
inProgressChangePresenceCursorByTeam.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const candidateTasks = selectedTeamData.tasks.filter((task) => {
|
||||
if (task.status !== 'in_progress') {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type {
|
||||
|
|
@ -143,6 +144,10 @@ function getSkillsCatalogKey(projectPath?: string): string {
|
|||
|
||||
/** Duration to show "success" state before returning to idle */
|
||||
const SUCCESS_DISPLAY_MS = 2_000;
|
||||
const CLI_AUTH_REQUIRED_MESSAGE =
|
||||
'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.';
|
||||
const CLI_STATUS_UNKNOWN_MESSAGE =
|
||||
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
|
||||
|
||||
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||
set,
|
||||
|
|
@ -552,8 +557,36 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
installPlugin: async (request: PluginInstallRequest) => {
|
||||
if (!api.plugins) return;
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||
}
|
||||
}
|
||||
|
||||
const cliStatus = get().cliStatus;
|
||||
const preflightError =
|
||||
cliStatus === null
|
||||
? CLI_STATUS_UNKNOWN_MESSAGE
|
||||
: !cliStatus.installed
|
||||
? CLI_NOT_FOUND_MESSAGE
|
||||
: !cliStatus.authLoggedIn
|
||||
? CLI_AUTH_REQUIRED_MESSAGE
|
||||
: null;
|
||||
|
||||
if (preflightError) {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
||||
}));
|
||||
|
||||
try {
|
||||
|
|
|
|||
27
src/renderer/utils/mergeTeamMessages.ts
Normal file
27
src/renderer/utils/mergeTeamMessages.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { toMessageKey } from './teamMessageKey';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
function compareMessages(a: InboxMessage, b: InboxMessage): number {
|
||||
const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
|
||||
if (diff !== 0) return diff;
|
||||
return toMessageKey(a).localeCompare(toMessageKey(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple message arrays into one newest-first list with stable deduplication.
|
||||
*
|
||||
* Later arrays win for duplicate keys so callers can overlay fresher/live message data
|
||||
* on top of paginated history without losing already-loaded older pages.
|
||||
*/
|
||||
export function mergeTeamMessages(...messageLists: readonly InboxMessage[][]): InboxMessage[] {
|
||||
const merged = new Map<string, InboxMessage>();
|
||||
|
||||
for (const list of messageLists) {
|
||||
for (const message of list) {
|
||||
merged.set(toMessageKey(message), message);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort(compareMessages);
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ import type {
|
|||
ReplaceMembersRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
MessagesPage,
|
||||
TaskAttachmentMeta,
|
||||
TaskChangePresenceState,
|
||||
TaskComment,
|
||||
|
|
@ -437,6 +438,10 @@ export interface TeamsAPI {
|
|||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||
cancelProvisioning: (runId: string) => Promise<void>;
|
||||
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
||||
getMessagesPage: (
|
||||
teamName: string,
|
||||
options?: { beforeTimestamp?: string; limit?: number }
|
||||
) => Promise<MessagesPage>;
|
||||
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -406,6 +406,14 @@ export interface InboxMessage {
|
|||
commandOutput?: CommandOutputMeta;
|
||||
}
|
||||
|
||||
/** Cursor-based paginated messages response. */
|
||||
export interface MessagesPage {
|
||||
messages: InboxMessage[];
|
||||
/** Opaque cursor string for fetching older messages. Null when no more pages. */
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export type AgentActionMode = 'do' | 'ask' | 'delegate';
|
||||
|
||||
export interface SendMessageRequest {
|
||||
|
|
|
|||
|
|
@ -651,6 +651,14 @@ describe('ChangeExtractorService', () => {
|
|||
const stalePromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
await service.invalidateTaskChangeSummaries(TEAM_NAME, [TASK_ID], { deletePersisted: true });
|
||||
const freshPromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
// Flush microtasks so freshPromise advances past its internal awaits
|
||||
// and reaches the worker mock before we resolve the stale deferred.
|
||||
// Without this, CI timing can cause the stale resolution to race with
|
||||
// the fresh worker call, making the test flaky.
|
||||
await vi.advanceTimersByTimeAsync?.(0).catch(() => undefined);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
first.resolve(makeTaskChangeResult());
|
||||
const stale = await stalePromise;
|
||||
const fresh = await freshPromise;
|
||||
|
|
|
|||
|
|
@ -3721,4 +3721,97 @@ describe('TeamDataService', () => {
|
|||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
describe('getMessagesPage', () => {
|
||||
function createPaginationService(messages: Array<{ from: string; text: string; timestamp: string; messageId?: string; source?: string; leadSessionId?: string }>) {
|
||||
return new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'My team',
|
||||
members: [{ name: 'team-lead', role: 'Lead' }],
|
||||
leadSessionId: 'lead-1',
|
||||
})),
|
||||
} as never,
|
||||
{ getTasks: vi.fn(async () => []) } as never,
|
||||
{
|
||||
listInboxNames: vi.fn(async () => []),
|
||||
getMessages: vi.fn(async () =>
|
||||
messages.map((m) => ({ ...m, read: true }))
|
||||
),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolveMembers: vi.fn(() => []) } as never,
|
||||
{ getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ readMessages: vi.fn(async () => []) } as never,
|
||||
);
|
||||
}
|
||||
|
||||
it('returns first page with cursor and hasMore', async () => {
|
||||
const msgs = Array.from({ length: 5 }, (_, i) => ({
|
||||
from: 'alice',
|
||||
text: `msg-${i}`,
|
||||
timestamp: `2026-01-01T00:00:0${i}.000Z`,
|
||||
messageId: `m${i}`,
|
||||
source: 'inbox' as const,
|
||||
}));
|
||||
const service = createPaginationService(msgs);
|
||||
const page = await service.getMessagesPage('my-team', { limit: 3 });
|
||||
|
||||
expect(page.messages).toHaveLength(3);
|
||||
expect(page.hasMore).toBe(true);
|
||||
expect(page.nextCursor).toBeTruthy();
|
||||
// Newest first
|
||||
expect(page.messages[0].messageId).toBe('m4');
|
||||
});
|
||||
|
||||
it('cursor excludes already-seen messages without losing same-timestamp messages', async () => {
|
||||
const msgs = [
|
||||
{ from: 'a', text: '1', timestamp: '2026-01-01T00:00:02.000Z', messageId: 'x1' },
|
||||
{ from: 'b', text: '2', timestamp: '2026-01-01T00:00:02.000Z', messageId: 'x2' },
|
||||
{ from: 'c', text: '3', timestamp: '2026-01-01T00:00:01.000Z', messageId: 'x3' },
|
||||
];
|
||||
const service = createPaginationService(msgs);
|
||||
const page1 = await service.getMessagesPage('my-team', { limit: 1 });
|
||||
expect(page1.messages).toHaveLength(1);
|
||||
expect(page1.hasMore).toBe(true);
|
||||
|
||||
const page2 = await service.getMessagesPage('my-team', {
|
||||
beforeTimestamp: page1.nextCursor!,
|
||||
limit: 10,
|
||||
});
|
||||
// Should get the remaining 2 messages, not lose the one with same timestamp
|
||||
expect(page2.messages.length).toBeGreaterThanOrEqual(1);
|
||||
const allIds = [...page1.messages, ...page2.messages].map((m) => m.messageId);
|
||||
expect(new Set(allIds).size).toBe(allIds.length); // no duplicates
|
||||
});
|
||||
|
||||
it('annotates slash command results in paginated path', async () => {
|
||||
const msgs = [
|
||||
{
|
||||
from: 'user',
|
||||
text: '/cost',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
messageId: 'cmd1',
|
||||
source: 'user_sent',
|
||||
leadSessionId: 'lead-1',
|
||||
},
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Total cost: $1.05',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
messageId: 'resp1',
|
||||
source: 'lead_process',
|
||||
leadSessionId: 'lead-1',
|
||||
},
|
||||
];
|
||||
const service = createPaginationService(msgs);
|
||||
const page = await service.getMessagesPage('my-team', { limit: 10 });
|
||||
const result = page.messages.find((m) => m.messageId === 'resp1');
|
||||
expect(result?.messageKind).toBe('slash_command_result');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -262,6 +262,24 @@ describe('extensionsSlice', () => {
|
|||
|
||||
describe('installPlugin', () => {
|
||||
it('sets progress to pending then success', async () => {
|
||||
store.setState({
|
||||
cliStatus: {
|
||||
flavor: 'claude',
|
||||
displayName: 'Claude',
|
||||
supportsSelfUpdate: true,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
installed: true,
|
||||
installedVersion: '1.0.0',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
latestVersion: '1.0.0',
|
||||
updateAvailable: false,
|
||||
authLoggedIn: true,
|
||||
authStatusChecking: false,
|
||||
authMethod: 'oauth_token',
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
const plugins = [makePlugin({ pluginId: 'a@m' })];
|
||||
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
|
||||
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||
|
|
@ -276,6 +294,24 @@ describe('extensionsSlice', () => {
|
|||
});
|
||||
|
||||
it('sets progress to error on failure', async () => {
|
||||
store.setState({
|
||||
cliStatus: {
|
||||
flavor: 'claude',
|
||||
displayName: 'Claude',
|
||||
supportsSelfUpdate: true,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
installed: true,
|
||||
installedVersion: '1.0.0',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
latestVersion: '1.0.0',
|
||||
updateAvailable: false,
|
||||
authLoggedIn: true,
|
||||
authStatusChecking: false,
|
||||
authMethod: 'oauth_token',
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
state: 'error',
|
||||
error: 'Not found',
|
||||
|
|
|
|||
62
test/renderer/utils/mergeTeamMessages.test.ts
Normal file
62
test/renderer/utils/mergeTeamMessages.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mergeTeamMessages } from '../../../src/renderer/utils/mergeTeamMessages';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
function makeMessage(
|
||||
overrides: Partial<InboxMessage> & Pick<InboxMessage, 'from' | 'text' | 'timestamp'>
|
||||
): InboxMessage {
|
||||
const { from, text, timestamp, ...rest } = overrides;
|
||||
return {
|
||||
from,
|
||||
text,
|
||||
timestamp,
|
||||
read: rest.read ?? true,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeTeamMessages', () => {
|
||||
it('deduplicates by stable message key and keeps newest-first order', () => {
|
||||
const older = makeMessage({
|
||||
from: 'alice',
|
||||
text: 'older',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
messageId: 'm1',
|
||||
});
|
||||
const newer = makeMessage({
|
||||
from: 'bob',
|
||||
text: 'newer',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
messageId: 'm2',
|
||||
});
|
||||
const merged = mergeTeamMessages([older], [newer]);
|
||||
|
||||
expect(merged.map((message) => message.messageId)).toEqual(['m2', 'm1']);
|
||||
});
|
||||
|
||||
it('lets later arrays overlay duplicate messages', () => {
|
||||
const persisted = makeMessage({
|
||||
from: 'team-lead',
|
||||
text: 'hello',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
messageId: 'm1',
|
||||
summary: 'persisted',
|
||||
});
|
||||
const live = makeMessage({
|
||||
from: 'team-lead',
|
||||
text: 'hello',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
messageId: 'm1',
|
||||
summary: 'live',
|
||||
source: 'lead_process',
|
||||
});
|
||||
|
||||
const merged = mergeTeamMessages([persisted], [live]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].summary).toBe('live');
|
||||
expect(merged[0].source).toBe('lead_process');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue