perf: offload heavy I/O to worker thread, reduce renderer re-renders
Main process — worker thread for team data: - New team-data-worker thread handles getTeamData and findLogsForTask, isolating heavy file I/O (scanning 300+ subagent JSONL files) from Electron's main event loop. getTeamData dropped from ~2000ms on the main thread to ~110ms via the worker. - Worker-side dedup and 10s result cache for findLogsForTask prevents redundant scans when the same task is queried multiple times. - Discovery cache TTL raised from 5s to 30s — avoids re-scanning the entire project directory on every call. - Message cap at 200 in TeamDataService to keep IPC payloads under 1MB (was sending 2200+ messages / ~3MB, stalling Chromium IPC serialization). - IPC handlers fall back to main-thread execution if the worker is unavailable (graceful degradation). Renderer — useShallow and memoization (55 files): - Added useShallow to store selectors across 55 renderer files. Batched individual useStore() calls (e.g. 17 calls in ExtensionStoreView, 10 in ConnectionSection) into single useShallow selectors, cutting unnecessary re-render checks on every store update. - MemberLogsTab: three 5-second polling intervals now pause when the parent tab is hidden (display:none). Previously 5 hidden tabs × 3 intervals = 15 polling timers firing continuously. - KanbanColumn wrapped in React.memo to skip re-renders when props haven't changed. - MemberList: memoized activeMembers/removedMembers/colorMap; replaced O(n×m) per-member task scan with a pre-computed reviewer map. - Bounded timer Maps in store initialization to prevent unbounded growth of debounce/throttle tracking maps during long sessions.
This commit is contained in:
parent
7ff9317b6f
commit
da58917032
61 changed files with 843 additions and 232 deletions
|
|
@ -77,7 +77,8 @@ export default defineConfig({
|
||||||
input: {
|
input: {
|
||||||
index: resolve(__dirname, 'src/main/index.ts'),
|
index: resolve(__dirname, 'src/main/index.ts'),
|
||||||
'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.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: {
|
output: {
|
||||||
// CJS format so bundled deps can use __dirname/require.
|
// CJS format so bundled deps can use __dirname/require.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { addMainBreadcrumb } from '@main/sentry';
|
import { addMainBreadcrumb } from '@main/sentry';
|
||||||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||||
|
import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient';
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||||
|
|
@ -535,7 +536,20 @@ async function handleGetData(
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let data: TeamData;
|
let data: TeamData;
|
||||||
try {
|
try {
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
// 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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (
|
if (
|
||||||
|
|
@ -555,6 +569,7 @@ async function handleGetData(
|
||||||
return { success: false, error: message };
|
return { success: false, error: message };
|
||||||
}
|
}
|
||||||
const getDataMs = Date.now() - startedAt;
|
const getDataMs = Date.now() - startedAt;
|
||||||
|
|
||||||
if (getDataMs >= 1500) {
|
if (getDataMs >= 1500) {
|
||||||
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`);
|
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`);
|
||||||
}
|
}
|
||||||
|
|
@ -2138,6 +2153,19 @@ async function handleGetLogsForTask(
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// Prefer worker thread to keep main event loop responsive
|
||||||
|
const worker = getTeamDataWorkerClient();
|
||||||
|
if (worker.isAvailable()) {
|
||||||
|
try {
|
||||||
|
return await wrapTeamHandler('getLogsForTask', () =>
|
||||||
|
worker.findLogsForTask(vTeam.value!, vTask.value!, opts)
|
||||||
|
);
|
||||||
|
} catch (workerErr) {
|
||||||
|
logger.warn(
|
||||||
|
`[teams:getLogsForTask] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return wrapTeamHandler('getLogsForTask', () =>
|
return wrapTeamHandler('getLogsForTask', () =>
|
||||||
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts)
|
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -728,12 +728,19 @@ export class TeamDataService {
|
||||||
this.processHealthTeams.delete(teamName);
|
this.processHealthTeams.delete(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap messages to keep IPC/postMessage payloads under ~300KB.
|
||||||
|
// Without this, teams with 2000+ messages produce 3MB+ payloads that
|
||||||
|
// stall Chromium's IPC serialization for ~1 second per transfer.
|
||||||
|
const MAX_RETURN_MESSAGES = 200;
|
||||||
|
const cappedMessages =
|
||||||
|
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
config,
|
config,
|
||||||
tasks: tasksWithKanban,
|
tasks: tasksWithKanban,
|
||||||
members,
|
members,
|
||||||
messages,
|
messages: cappedMessages,
|
||||||
kanbanState,
|
kanbanState,
|
||||||
processes,
|
processes,
|
||||||
warnings: warnings.length > 0 ? warnings : undefined,
|
warnings: warnings.length > 0 ? warnings : undefined,
|
||||||
|
|
|
||||||
169
src/main/services/team/TeamDataWorkerClient.ts
Normal file
169
src/main/services/team/TeamDataWorkerClient.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.warn('team-data-worker not found in expected locations');
|
||||||
|
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>();
|
||||||
|
|
||||||
|
isAvailable(): boolean {
|
||||||
|
if (!this.workerPath && !this.warnedUnavailable) {
|
||||||
|
this.warnedUnavailable = true;
|
||||||
|
logger.warn('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;
|
||||||
|
|
||||||
|
this.worker = new Worker(this.workerPath);
|
||||||
|
|
||||||
|
this.worker.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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker.on('error', (err) => {
|
||||||
|
logger.error('Worker error', err);
|
||||||
|
for (const [, entry] of this.pending) {
|
||||||
|
entry.reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
this.worker = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker.on('exit', (code) => {
|
||||||
|
if (code !== 0) logger.warn(`Worker exited with code ${code}`);
|
||||||
|
for (const [, entry] of this.pending) {
|
||||||
|
entry.reject(new Error(`Worker exited with code ${code}`));
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
this.worker = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
this.worker?.terminate().catch(() => undefined);
|
||||||
|
this.worker = null;
|
||||||
|
reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`));
|
||||||
|
}, 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> {
|
||||||
|
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[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ const ATTRIBUTION_CACHE_MAX = 5_000;
|
||||||
const SCAN_CONCURRENCY = 15;
|
const SCAN_CONCURRENCY = 15;
|
||||||
|
|
||||||
/** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */
|
/** 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. */
|
/** Signal sources for subagent member attribution, ordered by reliability. */
|
||||||
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
|
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 };
|
||||||
87
src/main/workers/team-data-worker.ts
Normal file
87
src/main/workers/team-data-worker.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* 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 cacheKey = `${teamName}:${taskId}:${options?.owner ?? ''}`;
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
logsInFlight.delete(cacheKey);
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
|
@ -171,7 +171,7 @@ const AIChatGroupInner = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
// Notification color map for tool item dots
|
// Notification color map for tool item dots
|
||||||
const notifications = useStore((s) => s.notifications);
|
const notifications = useStore(useShallow((s) => s.notifications));
|
||||||
const notificationColorMap = useMemo(() => {
|
const notificationColorMap = useMemo(() => {
|
||||||
const map = new Map<string, TriggerColor>();
|
const map = new Map<string, TriggerColor>();
|
||||||
for (const n of notifications) {
|
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 thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null;
|
||||||
const pendingNavigation = thisTab?.pendingNavigation;
|
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
|
// Look up whether this session belongs to a team
|
||||||
const sessionTeam = useMemo(() => {
|
const sessionTeam = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -388,15 +388,17 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
||||||
return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath;
|
return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get team members for @mention highlighting
|
// Get team members for @mention highlighting and team names for @team linkification
|
||||||
const members = useStore((s) => s.selectedTeamData?.members);
|
const { members, teams } = useStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
members: s.selectedTeamData?.members,
|
||||||
|
teams: s.teams,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const memberColorMap = useMemo(
|
const memberColorMap = useMemo(
|
||||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||||
[members]
|
[members]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get team names for @team linkification
|
|
||||||
const teams = useStore((s) => s.teams);
|
|
||||||
const teamNames = useMemo(
|
const teamNames = useMemo(
|
||||||
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
||||||
[teams]
|
[teams]
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
||||||
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
|
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
|
||||||
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
|
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;
|
const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description;
|
||||||
|
|
||||||
// Agent configs from .claude/agents/ for color lookup
|
// 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)
|
// Team member colors (when this subagent is a team member)
|
||||||
const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null;
|
const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null;
|
||||||
|
|
@ -171,7 +172,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
||||||
}, [subagent.messages]);
|
}, [subagent.messages]);
|
||||||
|
|
||||||
// Search expansion
|
// Search expansion
|
||||||
const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds);
|
const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds));
|
||||||
const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId);
|
const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId);
|
||||||
const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id);
|
const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -85,14 +86,14 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
// Get team members for @mention highlighting
|
// Get team members for @mention highlighting
|
||||||
const members = useStore((s) => s.selectedTeamData?.members);
|
const members = useStore(useShallow((s) => s.selectedTeamData?.members));
|
||||||
const memberColorMap = useMemo(
|
const memberColorMap = useMemo(
|
||||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||||
[members]
|
[members]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get team names for @team linkification
|
// Get team names for @team linkification
|
||||||
const teams = useStore((s) => s.teams);
|
const teams = useStore(useShallow((s) => s.teams));
|
||||||
const teamNames = useMemo(
|
const teamNames = useMemo(
|
||||||
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
||||||
[teams]
|
[teams]
|
||||||
|
|
|
||||||
|
|
@ -690,7 +690,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
const [showRaw, setShowRaw] = React.useState(false);
|
const [showRaw, setShowRaw] = React.useState(false);
|
||||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||||
const { isLight } = useTheme();
|
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 openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
|
||||||
|
|
||||||
const fallbackTeamColorByName = React.useMemo(() => {
|
const fallbackTeamColorByName = React.useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@
|
||||||
|
|
||||||
import { isElectronMode } from '@renderer/api';
|
import { isElectronMode } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
export const CliInstallWarningBanner = (): React.JSX.Element | null => {
|
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);
|
const openDashboard = useStore((s) => s.openDashboard);
|
||||||
|
|
||||||
// Returns a primitive boolean — minimizes re-renders
|
// Returns a primitive boolean — minimizes re-renders
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
interface ConnectionStatusBadgeProps {
|
interface ConnectionStatusBadgeProps {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
|
|
@ -21,10 +22,12 @@ export const ConnectionStatusBadge = ({
|
||||||
contextId,
|
contextId,
|
||||||
className,
|
className,
|
||||||
}: Readonly<ConnectionStatusBadgeProps>): React.JSX.Element => {
|
}: Readonly<ConnectionStatusBadgeProps>): React.JSX.Element => {
|
||||||
const { connectionState, connectedHost } = useStore((s) => ({
|
const { connectionState, connectedHost } = useStore(
|
||||||
connectionState: s.connectionState,
|
useShallow((s) => ({
|
||||||
connectedHost: s.connectedHost,
|
connectionState: s.connectionState,
|
||||||
}));
|
connectedHost: s.connectedHost,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// Local context always shows Monitor icon
|
// Local context always shows Monitor icon
|
||||||
if (contextId === 'local') {
|
if (contextId === 'local') {
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,26 @@
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { CheckCircle, Loader2, X } from 'lucide-react';
|
import { CheckCircle, Loader2, X } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
export const UpdateBanner = (): React.JSX.Element | null => {
|
export const UpdateBanner = (): React.JSX.Element | null => {
|
||||||
const showUpdateBanner = useStore((s) => s.showUpdateBanner);
|
const {
|
||||||
const updateStatus = useStore((s) => s.updateStatus);
|
showUpdateBanner,
|
||||||
const downloadProgress = useStore((s) => s.downloadProgress);
|
updateStatus,
|
||||||
const availableVersion = useStore((s) => s.availableVersion);
|
downloadProgress,
|
||||||
const installUpdate = useStore((s) => s.installUpdate);
|
availableVersion,
|
||||||
const dismissUpdateBanner = useStore((s) => s.dismissUpdateBanner);
|
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')) {
|
if (!showUpdateBanner || (updateStatus !== 'downloading' && updateStatus !== 'downloaded')) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,28 @@ import { useStore } from '@renderer/store';
|
||||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||||
import { ExternalLink, X } from 'lucide-react';
|
import { ExternalLink, X } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
export const UpdateDialog = (): React.JSX.Element | null => {
|
export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
|
const {
|
||||||
const updateStatus = useStore((s) => s.updateStatus);
|
showUpdateDialog,
|
||||||
const availableVersion = useStore((s) => s.availableVersion);
|
updateStatus,
|
||||||
const releaseNotes = useStore((s) => s.releaseNotes);
|
availableVersion,
|
||||||
const downloadUpdate = useStore((s) => s.downloadUpdate);
|
releaseNotes,
|
||||||
const installUpdate = useStore((s) => s.installUpdate);
|
downloadUpdate,
|
||||||
const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog);
|
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);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||||
|
|
||||||
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
||||||
|
|
@ -29,18 +30,35 @@ import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
|
||||||
|
|
||||||
export const ExtensionStoreView = (): React.JSX.Element => {
|
export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
const tabId = useTabIdOptional();
|
const tabId = useTabIdOptional();
|
||||||
const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog);
|
const {
|
||||||
const fetchApiKeys = useStore((s) => s.fetchApiKeys);
|
fetchPluginCatalog,
|
||||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
fetchApiKeys,
|
||||||
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
fetchSkillsCatalog,
|
||||||
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
|
mcpBrowse,
|
||||||
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
|
mcpFetchInstalled,
|
||||||
const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading);
|
pluginCatalogLoading,
|
||||||
const skillsLoading = useStore((s) => s.skillsLoading);
|
mcpBrowseLoading,
|
||||||
const cliStatus = useStore((s) => s.cliStatus);
|
skillsLoading,
|
||||||
const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked
|
cliStatus,
|
||||||
const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing));
|
sessions,
|
||||||
const projects = useStore((s) => s.projects);
|
projects,
|
||||||
|
} = useStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
fetchPluginCatalog: s.fetchPluginCatalog,
|
||||||
|
fetchApiKeys: s.fetchApiKeys,
|
||||||
|
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
||||||
|
mcpBrowse: s.mcpBrowse,
|
||||||
|
mcpFetchInstalled: s.mcpFetchInstalled,
|
||||||
|
pluginCatalogLoading: s.pluginCatalogLoading,
|
||||||
|
mcpBrowseLoading: s.mcpBrowseLoading,
|
||||||
|
skillsLoading: s.skillsLoading,
|
||||||
|
cliStatus: s.cliStatus,
|
||||||
|
sessions: s.sessions,
|
||||||
|
projects: s.projects,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const cliInstalled = cliStatus?.installed ?? true;
|
||||||
|
const hasOngoingSessions = sessions.some((sess) => sess.isOngoing);
|
||||||
const extensionsTabProjectId = useStore((s) =>
|
const extensionsTabProjectId = useStore((s) =>
|
||||||
tabId
|
tabId
|
||||||
? (s.paneLayout.panes.flatMap((pane) => pane.tabs).find((tab) => tab.id === tabId)
|
? (s.paneLayout.panes.flatMap((pane) => pane.tabs).find((tab) => tab.id === tabId)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
|
import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { ApiKeyCard } from './ApiKeyCard';
|
import { ApiKeyCard } from './ApiKeyCard';
|
||||||
|
|
@ -15,11 +16,15 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog';
|
||||||
import type { ApiKeyEntry } from '@shared/types/extensions';
|
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
export const ApiKeysPanel = (): React.JSX.Element => {
|
export const ApiKeysPanel = (): React.JSX.Element => {
|
||||||
const apiKeys = useStore((s) => s.apiKeys);
|
const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore(
|
||||||
const apiKeysLoading = useStore((s) => s.apiKeysLoading);
|
useShallow((s) => ({
|
||||||
const apiKeysError = useStore((s) => s.apiKeysError);
|
apiKeys: s.apiKeys,
|
||||||
const storageStatus = useStore((s) => s.apiKeyStorageStatus);
|
apiKeysLoading: s.apiKeysLoading,
|
||||||
const fetchStorageStatus = useStore((s) => s.fetchApiKeyStorageStatus);
|
apiKeysError: s.apiKeysError,
|
||||||
|
storageStatus: s.apiKeyStorageStatus,
|
||||||
|
fetchStorageStatus: s.fetchApiKeyStorageStatus,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { ExtensionOperationState } from '@shared/types/extensions';
|
import type { ExtensionOperationState } from '@shared/types/extensions';
|
||||||
|
|
@ -36,7 +37,7 @@ export const InstallButton = ({
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}: InstallButtonProps) => {
|
}: InstallButtonProps) => {
|
||||||
const cliStatus = useStore((s) => s.cliStatus);
|
const cliStatus = useStore(useShallow((s) => s.cliStatus));
|
||||||
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
||||||
const isDisabled = disabled || cliMissing;
|
const isDisabled = disabled || cliMissing;
|
||||||
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||||
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
||||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
@ -72,18 +73,35 @@ export const McpServersPanel = ({
|
||||||
selectedMcpServerId,
|
selectedMcpServerId,
|
||||||
setSelectedMcpServerId,
|
setSelectedMcpServerId,
|
||||||
}: McpServersPanelProps): React.JSX.Element => {
|
}: McpServersPanelProps): React.JSX.Element => {
|
||||||
const browseCatalog = useStore((s) => s.mcpBrowseCatalog);
|
const {
|
||||||
const browseNextCursor = useStore((s) => s.mcpBrowseNextCursor);
|
browseCatalog,
|
||||||
const browseLoading = useStore((s) => s.mcpBrowseLoading);
|
browseNextCursor,
|
||||||
const browseError = useStore((s) => s.mcpBrowseError);
|
browseLoading,
|
||||||
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
browseError,
|
||||||
const installedServers = useStore((s) => s.mcpInstalledServers);
|
mcpBrowse,
|
||||||
const fetchMcpGitHubStars = useStore((s) => s.fetchMcpGitHubStars);
|
installedServers,
|
||||||
const mcpDiagnostics = useStore((s) => s.mcpDiagnostics);
|
fetchMcpGitHubStars,
|
||||||
const mcpDiagnosticsLoading = useStore((s) => s.mcpDiagnosticsLoading);
|
mcpDiagnostics,
|
||||||
const mcpDiagnosticsError = useStore((s) => s.mcpDiagnosticsError);
|
mcpDiagnosticsLoading,
|
||||||
const mcpDiagnosticsLastCheckedAt = useStore((s) => s.mcpDiagnosticsLastCheckedAt);
|
mcpDiagnosticsError,
|
||||||
const runMcpDiagnostics = useStore((s) => s.runMcpDiagnostics);
|
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');
|
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import {
|
import {
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
|
|
@ -53,14 +54,18 @@ export const PluginDetailDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
}: PluginDetailDialogProps): React.JSX.Element => {
|
}: PluginDetailDialogProps): React.JSX.Element => {
|
||||||
const fetchPluginReadme = useStore((s) => s.fetchPluginReadme);
|
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
|
||||||
const readmes = useStore((s) => s.pluginReadmes);
|
useShallow((s) => ({
|
||||||
const readmeLoading = useStore((s) => s.pluginReadmeLoading);
|
fetchPluginReadme: s.fetchPluginReadme,
|
||||||
|
readmes: s.pluginReadmes,
|
||||||
|
readmeLoading: s.pluginReadmeLoading,
|
||||||
|
installPlugin: s.installPlugin,
|
||||||
|
uninstallPlugin: s.uninstallPlugin,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const installProgress = useStore(
|
const installProgress = useStore(
|
||||||
(s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle'
|
(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 installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
|
||||||
|
|
||||||
const [scope, setScope] = useState<InstallScope>('user');
|
const [scope, setScope] = useState<InstallScope>('user');
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
||||||
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -122,9 +123,13 @@ export const PluginsPanel = ({
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
setPluginSort,
|
setPluginSort,
|
||||||
}: PluginsPanelProps): React.JSX.Element => {
|
}: PluginsPanelProps): React.JSX.Element => {
|
||||||
const catalog = useStore((s) => s.pluginCatalog);
|
const { catalog, loading, error } = useStore(
|
||||||
const loading = useStore((s) => s.pluginCatalogLoading);
|
useShallow((s) => ({
|
||||||
const error = useStore((s) => s.pluginCatalogError);
|
catalog: s.pluginCatalog,
|
||||||
|
loading: s.pluginCatalogLoading,
|
||||||
|
error: s.pluginCatalogError,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => selectFilteredPlugins(catalog, pluginFilters, pluginSort),
|
() => selectFilteredPlugins(catalog, pluginFilters, pluginSort),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
|
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
interface SkillDetailDialogProps {
|
interface SkillDetailDialogProps {
|
||||||
|
|
@ -44,7 +45,7 @@ export const SkillDetailDialog = ({
|
||||||
}: SkillDetailDialogProps): React.JSX.Element => {
|
}: SkillDetailDialogProps): React.JSX.Element => {
|
||||||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||||
const deleteSkill = useStore((s) => s.deleteSkill);
|
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) =>
|
const loading = useStore((s) =>
|
||||||
skillId ? (s.skillsDetailLoadingById[skillId] ?? false) : false
|
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 { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowUpAZ,
|
ArrowUpAZ,
|
||||||
|
|
@ -94,10 +95,10 @@ export const SkillsPanel = ({
|
||||||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||||
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
|
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
|
||||||
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
||||||
const detailById = useStore((s) => s.skillsDetailsById);
|
const detailById = useStore(useShallow((s) => s.skillsDetailsById));
|
||||||
const userSkills = useStore((s) => s.skillsUserCatalog);
|
const userSkills = useStore(useShallow((s) => s.skillsUserCatalog));
|
||||||
const projectSkills = useStore((s) =>
|
const projectSkills = useStore(
|
||||||
projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : []
|
useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : []))
|
||||||
);
|
);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { PaneResizeHandle } from './PaneResizeHandle';
|
import { PaneResizeHandle } from './PaneResizeHandle';
|
||||||
import { PaneView } from './PaneView';
|
import { PaneView } from './PaneView';
|
||||||
|
|
||||||
export const PaneContainer = (): React.JSX.Element => {
|
export const PaneContainer = (): React.JSX.Element => {
|
||||||
const panes = useStore((s) => s.paneLayout.panes);
|
const panes = useStore(useShallow((s) => s.paneLayout.panes));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="pane-container" className="flex flex-1 overflow-hidden">
|
<div id="pane-container" className="flex flex-1 overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
interface PaneResizeHandleProps {
|
interface PaneResizeHandleProps {
|
||||||
leftPaneId: string;
|
leftPaneId: string;
|
||||||
|
|
@ -15,7 +16,7 @@ interface PaneResizeHandleProps {
|
||||||
export const PaneResizeHandle = ({ leftPaneId }: PaneResizeHandleProps): React.JSX.Element => {
|
export const PaneResizeHandle = ({ leftPaneId }: PaneResizeHandleProps): React.JSX.Element => {
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const resizePanes = useStore((s) => s.resizePanes);
|
const resizePanes = useStore((s) => s.resizePanes);
|
||||||
const paneLayout = useStore((s) => s.paneLayout);
|
const paneLayout = useStore(useShallow((s) => s.paneLayout));
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -76,17 +76,19 @@ export const SortableTab = ({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const teamColorSet = useStore((s) => {
|
const teamColorSet = useStore(
|
||||||
if (tab.type !== 'team' || !tab.teamName) return null;
|
useShallow((s) => {
|
||||||
const team = s.teamByName[tab.teamName];
|
if (tab.type !== 'team' || !tab.teamName) return null;
|
||||||
const explicitColor =
|
const team = s.teamByName[tab.teamName];
|
||||||
team?.color ??
|
const explicitColor =
|
||||||
(s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined);
|
team?.color ??
|
||||||
if (explicitColor) return getTeamColorSet(explicitColor);
|
(s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined);
|
||||||
// Fallback: deterministic color derived from display name
|
if (explicitColor) return getTeamColorSet(explicitColor);
|
||||||
const displayName = team?.displayName ?? tab.label;
|
// Fallback: deterministic color derived from display name
|
||||||
return nameColorSet(displayName);
|
const displayName = team?.displayName ?? tab.label;
|
||||||
});
|
return nameColorSet(displayName);
|
||||||
|
})
|
||||||
|
);
|
||||||
const activeBorderColor = teamColorSet?.border ?? 'var(--color-accent, #6366f1)';
|
const activeBorderColor = teamColorSet?.border ?? 'var(--color-accent, #6366f1)';
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
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 { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
|
||||||
import { useZoomFactor } from '@renderer/hooks/useZoomFactor';
|
import { useZoomFactor } from '@renderer/hooks/useZoomFactor';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner';
|
import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner';
|
||||||
import { UpdateBanner } from '../common/UpdateBanner';
|
import { UpdateBanner } from '../common/UpdateBanner';
|
||||||
|
|
@ -54,7 +55,7 @@ export const TabbedLayout = (): React.JSX.Element => {
|
||||||
: getTrafficLightPaddingForZoom(zoomFactor);
|
: getTrafficLightPaddingForZoom(zoomFactor);
|
||||||
|
|
||||||
// --- DnD state (lifted from PaneContainer) ---
|
// --- 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 [activeTab, setActiveTab] = useState<Tab | null>(null);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { computeTakeaways } from '@renderer/utils/reportAssessments';
|
import { computeTakeaways } from '@renderer/utils/reportAssessments';
|
||||||
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
|
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
|
||||||
|
|
||||||
|
|
@ -25,11 +26,13 @@ interface SessionReportTabProps {
|
||||||
|
|
||||||
export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
|
export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
|
||||||
// Find session data from any session tab with matching sessionId
|
// Find session data from any session tab with matching sessionId
|
||||||
const sessionDetail = useStore((s) => {
|
const sessionDetail = useStore(
|
||||||
const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs);
|
useShallow((s) => {
|
||||||
const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId);
|
const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs);
|
||||||
return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null;
|
const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId);
|
||||||
});
|
return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const report = useMemo(
|
const report = useMemo(
|
||||||
() => (sessionDetail ? analyzeSession(sessionDetail) : null),
|
() => (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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
||||||
import {
|
import {
|
||||||
|
|
@ -68,7 +69,7 @@ const ScheduleListItem = ({
|
||||||
}: ScheduleListItemProps): React.JSX.Element => {
|
}: ScheduleListItemProps): React.JSX.Element => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [selectedRun, setSelectedRun] = useState<ScheduleRun | null>(null);
|
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 runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false);
|
||||||
const fetchRunHistory = useStore((s) => s.fetchRunHistory);
|
const fetchRunHistory = useStore((s) => s.fetchRunHistory);
|
||||||
|
|
||||||
|
|
@ -240,15 +241,29 @@ const ScheduleListItem = ({
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const SchedulesView = (): React.JSX.Element => {
|
export const SchedulesView = (): React.JSX.Element => {
|
||||||
const schedules = useStore((s) => s.schedules);
|
const {
|
||||||
const schedulesLoading = useStore((s) => s.schedulesLoading);
|
schedules,
|
||||||
const fetchSchedules = useStore((s) => s.fetchSchedules);
|
schedulesLoading,
|
||||||
const pauseSchedule = useStore((s) => s.pauseSchedule);
|
fetchSchedules,
|
||||||
const resumeSchedule = useStore((s) => s.resumeSchedule);
|
pauseSchedule,
|
||||||
const deleteSchedule = useStore((s) => s.deleteSchedule);
|
resumeSchedule,
|
||||||
const triggerNow = useStore((s) => s.triggerNow);
|
deleteSchedule,
|
||||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
triggerNow,
|
||||||
const teamByName = useStore((s) => s.teamByName);
|
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 */
|
/** Resolve team color dot style for a given team name */
|
||||||
const getTeamColor = useCallback(
|
const getTeamColor = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { useSettingsConfig, useSettingsHandlers } from './hooks';
|
import { useSettingsConfig, useSettingsHandlers } from './hooks';
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,8 +20,12 @@ import { type SettingsSection, SettingsTabs } from './SettingsTabs';
|
||||||
|
|
||||||
export const SettingsView = (): React.JSX.Element | null => {
|
export const SettingsView = (): React.JSX.Element | null => {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general');
|
const [activeSection, setActiveSection] = useState<SettingsSection>('general');
|
||||||
const pendingSettingsSection = useStore((s) => s.pendingSettingsSection);
|
const { pendingSettingsSection, clearPendingSettingsSection } = useStore(
|
||||||
const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection);
|
useShallow((s) => ({
|
||||||
|
pendingSettingsSection: s.pendingSettingsSection,
|
||||||
|
clearPendingSettingsSection: s.clearPendingSettingsSection,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// Consume pending section (avoid setState during render)
|
// Consume pending section (avoid setState during render)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { api, isElectronMode } from '@renderer/api';
|
||||||
import appIcon from '@renderer/favicon.png';
|
import appIcon from '@renderer/favicon.png';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { CheckCircle, Code2, Download, FileEdit, Loader2, RefreshCw, Upload } from 'lucide-react';
|
import { CheckCircle, Code2, Download, FileEdit, Loader2, RefreshCw, Upload } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { SettingsSectionHeader } from '../components';
|
import { SettingsSectionHeader } from '../components';
|
||||||
|
|
||||||
|
|
@ -32,9 +33,13 @@ export const AdvancedSection = ({
|
||||||
const isElectron = useMemo(() => isElectronMode(), []);
|
const isElectron = useMemo(() => isElectronMode(), []);
|
||||||
const [version, setVersion] = useState<string>('');
|
const [version, setVersion] = useState<string>('');
|
||||||
const [configEditorOpen, setConfigEditorOpen] = useState(false);
|
const [configEditorOpen, setConfigEditorOpen] = useState(false);
|
||||||
const updateStatus = useStore((s) => s.updateStatus);
|
const { updateStatus, availableVersion, checkForUpdates } = useStore(
|
||||||
const availableVersion = useStore((s) => s.availableVersion);
|
useShallow((s) => ({
|
||||||
const checkForUpdates = useStore((s) => s.checkForUpdates);
|
updateStatus: s.updateStatus,
|
||||||
|
availableVersion: s.availableVersion,
|
||||||
|
checkForUpdates: s.checkForUpdates,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-revert "not-available" / "error" status back to idle after a brief display
|
// Auto-revert "not-available" / "error" status back to idle after a brief display
|
||||||
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
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 { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
|
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { SettingRow } from '../components/SettingRow';
|
import { SettingRow } from '../components/SettingRow';
|
||||||
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
||||||
|
|
@ -37,16 +38,31 @@ const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ConnectionSection = (): React.JSX.Element => {
|
export const ConnectionSection = (): React.JSX.Element => {
|
||||||
const connectionState = useStore((s) => s.connectionState);
|
const {
|
||||||
const connectedHost = useStore((s) => s.connectedHost);
|
connectionState,
|
||||||
const connectionError = useStore((s) => s.connectionError);
|
connectedHost,
|
||||||
const connectSsh = useStore((s) => s.connectSsh);
|
connectionError,
|
||||||
const disconnectSsh = useStore((s) => s.disconnectSsh);
|
connectSsh,
|
||||||
const testConnection = useStore((s) => s.testConnection);
|
disconnectSsh,
|
||||||
const sshConfigHosts = useStore((s) => s.sshConfigHosts);
|
testConnection,
|
||||||
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
|
sshConfigHosts,
|
||||||
const lastSshConfig = useStore((s) => s.lastSshConfig);
|
fetchSshConfigHosts,
|
||||||
const loadLastConnection = useStore((s) => s.loadLastConnection);
|
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
|
// Form state
|
||||||
const [host, setHost] = useState('');
|
const [host, setHost] = useState('');
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { useStore } from '@renderer/store';
|
||||||
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
||||||
import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
|
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { SettingRow, SettingsSectionHeader, SettingsToggle } from '../components';
|
import { SettingRow, SettingsSectionHeader, SettingsToggle } from '../components';
|
||||||
|
|
||||||
|
|
@ -50,9 +51,13 @@ export const GeneralSection = ({
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// Claude Root state
|
// Claude Root state
|
||||||
const connectionMode = useStore((s) => s.connectionMode);
|
const { connectionMode, fetchProjects, fetchRepositoryGroups } = useStore(
|
||||||
const fetchProjects = useStore((s) => s.fetchProjects);
|
useShallow((s) => ({
|
||||||
const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups);
|
connectionMode: s.connectionMode,
|
||||||
|
fetchProjects: s.fetchProjects,
|
||||||
|
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null);
|
const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null);
|
||||||
const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false);
|
const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { projectColor } from '@renderer/utils/projectColor';
|
import { projectColor } from '@renderer/utils/projectColor';
|
||||||
|
|
@ -78,7 +79,7 @@ export const SidebarTaskItem = ({
|
||||||
getDisplaySubject,
|
getDisplaySubject,
|
||||||
}: SidebarTaskItemProps): React.JSX.Element => {
|
}: SidebarTaskItemProps): React.JSX.Element => {
|
||||||
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
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 unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { formatDistanceToNowStrict } from 'date-fns';
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -25,7 +26,7 @@ function formatShortTime(date: Date): string {
|
||||||
|
|
||||||
export const ProcessesSection = (): React.JSX.Element | null => {
|
export const ProcessesSection = (): React.JSX.Element | null => {
|
||||||
const teamName = useStore((s) => s.selectedTeamName);
|
const teamName = useStore((s) => s.selectedTeamName);
|
||||||
const data = useStore((s) => s.selectedTeamData);
|
const data = useStore(useShallow((s) => s.selectedTeamData));
|
||||||
|
|
||||||
if (!teamName || !data?.processes?.length) return null;
|
if (!teamName || !data?.processes?.length) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
|
|
@ -69,10 +70,14 @@ export const TaskTooltip = ({
|
||||||
children,
|
children,
|
||||||
side = 'top',
|
side = 'top',
|
||||||
}: TaskTooltipProps): React.JSX.Element => {
|
}: TaskTooltipProps): React.JSX.Element => {
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore(
|
||||||
const selectedTeamData = useStore((s) => s.selectedTeamData);
|
useShallow((s) => ({
|
||||||
const globalTasks = useStore((s) => s.globalTasks);
|
selectedTeamName: s.selectedTeamName,
|
||||||
const teamByName = useStore((s) => s.teamByName);
|
selectedTeamData: s.selectedTeamData,
|
||||||
|
globalTasks: s.globalTasks,
|
||||||
|
teamByName: s.teamByName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const task = useMemo(() => {
|
const task = useMemo(() => {
|
||||||
if (teamName && selectedTeamName === teamName) {
|
if (teamName && selectedTeamName === teamName) {
|
||||||
|
|
|
||||||
|
|
@ -457,6 +457,9 @@ export const TeamDetailView = ({
|
||||||
messagesPanelWidth,
|
messagesPanelWidth,
|
||||||
setMessagesPanelMode,
|
setMessagesPanelMode,
|
||||||
setMessagesPanelWidth,
|
setMessagesPanelWidth,
|
||||||
|
selectReviewFile,
|
||||||
|
pendingReviewRequest,
|
||||||
|
setPendingReviewRequest,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
data: s.selectedTeamData,
|
data: s.selectedTeamData,
|
||||||
|
|
@ -506,6 +509,9 @@ export const TeamDetailView = ({
|
||||||
messagesPanelWidth: s.messagesPanelWidth,
|
messagesPanelWidth: s.messagesPanelWidth,
|
||||||
setMessagesPanelMode: s.setMessagesPanelMode,
|
setMessagesPanelMode: s.setMessagesPanelMode,
|
||||||
setMessagesPanelWidth: s.setMessagesPanelWidth,
|
setMessagesPanelWidth: s.setMessagesPanelWidth,
|
||||||
|
selectReviewFile: s.selectReviewFile,
|
||||||
|
pendingReviewRequest: s.pendingReviewRequest,
|
||||||
|
setPendingReviewRequest: s.setPendingReviewRequest,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -961,10 +967,6 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
}, [teamName, refreshTeamData]);
|
}, [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
|
// Pick up pending review request from GlobalTaskDetailDialog
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingReviewRequest) return;
|
if (!pendingReviewRequest) return;
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,8 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
fetchTeams,
|
fetchTeams,
|
||||||
openTeamTab,
|
openTeamTab,
|
||||||
deleteTeam,
|
deleteTeam,
|
||||||
|
restoreTeam,
|
||||||
|
permanentlyDeleteTeam,
|
||||||
projects,
|
projects,
|
||||||
globalTasks,
|
globalTasks,
|
||||||
fetchAllTasks,
|
fetchAllTasks,
|
||||||
|
|
@ -232,6 +234,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
selectedRepositoryId,
|
selectedRepositoryId,
|
||||||
selectedWorktreeId,
|
selectedWorktreeId,
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
|
branchByPath,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
|
|
@ -250,6 +253,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
selectedRepositoryId: s.selectedRepositoryId,
|
selectedRepositoryId: s.selectedRepositoryId,
|
||||||
selectedWorktreeId: s.selectedWorktreeId,
|
selectedWorktreeId: s.selectedWorktreeId,
|
||||||
activeProjectId: s.activeProjectId,
|
activeProjectId: s.activeProjectId,
|
||||||
|
branchByPath: s.branchByPath,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
|
|
@ -432,10 +436,6 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
[filteredTeams]
|
[filteredTeams]
|
||||||
);
|
);
|
||||||
useBranchSync(teamPaths, { live: false });
|
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(
|
const handleDeleteTeam = useCallback(
|
||||||
(teamName: string, isDraft: boolean, e: React.MouseEvent) => {
|
(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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
||||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||||
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
||||||
|
|
@ -141,12 +142,23 @@ function useElapsed(receivedAt: string): number {
|
||||||
const RESPOND_TIMEOUT_MS = 10_000;
|
const RESPOND_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export const ToolApprovalSheet: React.FC = () => {
|
export const ToolApprovalSheet: React.FC = () => {
|
||||||
const pendingApprovals = useStore((s) => s.pendingApprovals);
|
const {
|
||||||
const respondToToolApproval = useStore((s) => s.respondToToolApproval);
|
pendingApprovals,
|
||||||
const updateToolApprovalSettings = useStore((s) => s.updateToolApprovalSettings);
|
respondToToolApproval,
|
||||||
const teams = useStore((s) => s.teams);
|
updateToolApprovalSettings,
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
teams,
|
||||||
const selectedTeamData = useStore((s) => s.selectedTeamData);
|
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 { isLight } = useTheme();
|
||||||
|
|
||||||
const current: ToolApprovalRequest | undefined = pendingApprovals[0];
|
const current: ToolApprovalRequest | undefined = pendingApprovals[0];
|
||||||
|
|
@ -606,7 +618,7 @@ const ToolInputPreview = ({
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Element | null => {
|
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);
|
const elapsed = useElapsed(receivedAt);
|
||||||
|
|
||||||
if (settings.timeoutAction === 'wait') return null;
|
if (settings.timeoutAction === 'wait') return null;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import {
|
import {
|
||||||
getMessageTypeLabel,
|
getMessageTypeLabel,
|
||||||
getStructuredMessageSummary,
|
getStructuredMessageSummary,
|
||||||
|
|
@ -601,8 +602,8 @@ export const ActivityItem = memo(
|
||||||
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
|
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
|
||||||
|
|
||||||
// Permission request status icon (check/x/clock)
|
// Permission request status icon (check/x/clock)
|
||||||
const pendingApprovals = useStore((s) => s.pendingApprovals);
|
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
|
||||||
const resolvedApprovals = useStore((s) => s.resolvedApprovals);
|
const resolvedApprovals = useStore(useShallow((s) => s.resolvedApprovals));
|
||||||
const permissionIcon = useMemo(() => {
|
const permissionIcon = useMemo(() => {
|
||||||
if (!structured) return null;
|
if (!structured) return null;
|
||||||
const type = typeof structured.type === 'string' ? structured.type : 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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
|
@ -33,7 +34,7 @@ export const PendingRepliesBlock = ({
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const pendingApprovals = useStore((s) => s.pendingApprovals);
|
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
|
||||||
const colorMap = buildMemberColorMap(members);
|
const colorMap = buildMemberColorMap(members);
|
||||||
const memberPending = Object.entries(pendingRepliesByMember)
|
const memberPending = Object.entries(pendingRepliesByMember)
|
||||||
.map(([name, sentAtMs]) => ({
|
.map(([name, sentAtMs]) => ({
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
|
@ -119,8 +120,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// Team name: always present for launch mode, may be absent in schedule mode (standalone page)
|
// Team name: always present for launch mode, may be absent in schedule mode (standalone page)
|
||||||
const propsTeamName = props.teamName ?? '';
|
const propsTeamName = props.teamName ?? '';
|
||||||
const [selectedTeamName, setSelectedTeamName] = useState('');
|
const [selectedTeamName, setSelectedTeamName] = useState('');
|
||||||
const teamByName = useStore((s) => s.teamByName);
|
const { teamByName, openDashboard } = useStore(
|
||||||
const openDashboard = useStore((s) => s.openDashboard);
|
useShallow((s) => ({
|
||||||
|
teamByName: s.teamByName,
|
||||||
|
openDashboard: s.openDashboard,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const teamOptions = useMemo(
|
const teamOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.values(teamByName)
|
Object.values(teamByName)
|
||||||
|
|
@ -401,7 +406,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// Shared effects: projects
|
// Shared effects: projects
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const repositoryGroups = useStore((s) => s.repositoryGroups);
|
const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
@ -490,7 +495,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// Mention suggestions (shared — from props in launch, from store in schedule)
|
// Mention suggestions (shared — from props in launch, from store in schedule)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
const storeMembers = useStore(useShallow((s) => s.selectedTeamData?.members ?? []));
|
||||||
const members = isLaunch ? props.members : storeMembers;
|
const members = isLaunch ? props.members : storeMembers;
|
||||||
|
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
|
||||||
|
|
||||||
import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types';
|
import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types';
|
||||||
|
|
@ -42,7 +43,7 @@ export const ToolApprovalSettingsContent: React.FC<{
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
}> = ({ expanded, teamName }) => {
|
}> = ({ expanded, teamName }) => {
|
||||||
const [localSeconds, setLocalSeconds] = useState<string>('');
|
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 rawUpdateSettings = useStore((s) => s.updateToolApprovalSettings);
|
||||||
const updateSettings = useCallback(
|
const updateSettings = useCallback(
|
||||||
(patch: Partial<ToolApprovalSettings>) => rawUpdateSettings(patch, teamName),
|
(patch: Partial<ToolApprovalSettings>) => rawUpdateSettings(patch, teamName),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
||||||
|
|
@ -15,7 +17,7 @@ interface KanbanColumnProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanbanColumn = ({
|
export const KanbanColumn = memo(function KanbanColumn({
|
||||||
title,
|
title,
|
||||||
count,
|
count,
|
||||||
icon,
|
icon,
|
||||||
|
|
@ -27,7 +29,7 @@ export const KanbanColumn = ({
|
||||||
headerDragClassName,
|
headerDragClassName,
|
||||||
headerAccessory,
|
headerAccessory,
|
||||||
children,
|
children,
|
||||||
}: KanbanColumnProps): React.JSX.Element => {
|
}: KanbanColumnProps): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -64,4 +66,4 @@ export const KanbanColumn = ({
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from '@renderer/constants/teamColors';
|
} from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
|
@ -44,14 +45,19 @@ export const MemberHoverCard = ({
|
||||||
children,
|
children,
|
||||||
}: MemberHoverCardProps): React.JSX.Element => {
|
}: MemberHoverCardProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const member = useStore((s) => s.selectedTeamData?.members.find((m) => m.name === name) ?? null);
|
const { member, isTeamAlive, teamName, leadActivity, openMemberProfile, tasks } = useStore(
|
||||||
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive);
|
useShallow((s) => {
|
||||||
const teamName = useStore((s) => s.selectedTeamName);
|
const tn = s.selectedTeamName;
|
||||||
const leadActivity: LeadActivityState | undefined = useStore((s) =>
|
return {
|
||||||
teamName ? s.leadActivityByTeam[teamName] : undefined
|
member: s.selectedTeamData?.members.find((m) => m.name === name) ?? null,
|
||||||
|
isTeamAlive: s.selectedTeamData?.isAlive,
|
||||||
|
teamName: tn,
|
||||||
|
leadActivity: tn ? s.leadActivityByTeam[tn] : undefined,
|
||||||
|
openMemberProfile: s.openMemberProfile,
|
||||||
|
tasks: s.selectedTeamData?.tasks,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
|
||||||
const tasks = useStore((s) => s.selectedTeamData?.tasks);
|
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
@ -67,15 +67,19 @@ export const MemberList = ({
|
||||||
}, [handleResize]);
|
}, [handleResize]);
|
||||||
|
|
||||||
const gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1';
|
const gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1';
|
||||||
const activeMembers = members
|
const activeMembers = useMemo(
|
||||||
.filter((m) => !m.removedAt)
|
() =>
|
||||||
.sort((a, b) => {
|
members
|
||||||
if (isLeadMember(a)) return -1;
|
.filter((m) => !m.removedAt)
|
||||||
if (isLeadMember(b)) return 1;
|
.sort((a, b) => {
|
||||||
return 0;
|
if (isLeadMember(a)) return -1;
|
||||||
});
|
if (isLeadMember(b)) return 1;
|
||||||
const removedMembers = members.filter((m) => m.removedAt);
|
return 0;
|
||||||
const colorMap = buildMemberColorMap(members);
|
}),
|
||||||
|
[members]
|
||||||
|
);
|
||||||
|
const removedMembers = useMemo(() => members.filter((m) => m.removedAt), [members]);
|
||||||
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -85,17 +89,24 @@ export const 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 renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
|
||||||
const currentTask =
|
const currentTask =
|
||||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||||
const reviewTask = taskMap
|
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
||||||
? (Array.from(taskMap.values()).find(
|
const reviewTask =
|
||||||
(task) =>
|
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
|
||||||
task.reviewer === member.name &&
|
|
||||||
task.id !== member.currentTaskId &&
|
|
||||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
|
||||||
) ?? null)
|
|
||||||
: null;
|
|
||||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { api } from '@renderer/api';
|
||||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||||
import {
|
import {
|
||||||
|
|
@ -107,6 +110,11 @@ export const MemberLogsTab = ({
|
||||||
showLeadPreview = false,
|
showLeadPreview = false,
|
||||||
onPreviewOnlineChange,
|
onPreviewOnlineChange,
|
||||||
}: MemberLogsTabProps): React.JSX.Element => {
|
}: 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 MIN_REFRESH_VISIBLE_MS = 250;
|
||||||
const intervalsKey = useMemo(
|
const intervalsKey = useMemo(
|
||||||
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
|
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
|
||||||
|
|
@ -475,14 +483,14 @@ export const MemberLogsTab = ({
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
|
|
||||||
const interval = shouldAutoRefresh ? setInterval(() => void load(), 5000) : null;
|
const interval = shouldAutoRefresh && isTabActive ? setInterval(() => void load(), 5000) : null;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (interval) clearInterval(interval);
|
if (interval) clearInterval(interval);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey + taskSince drive refresh; deps intentionally minimal to avoid refetch loops
|
// 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(
|
const fetchDetailForLog = useCallback(
|
||||||
async (
|
async (
|
||||||
|
|
@ -537,7 +545,7 @@ export const MemberLogsTab = ({
|
||||||
if (!previewLog) return;
|
if (!previewLog) return;
|
||||||
|
|
||||||
const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing;
|
const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing;
|
||||||
if (!shouldAutoRefreshPreview) return;
|
if (!shouldAutoRefreshPreview || !isTabActive) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
|
|
@ -566,12 +574,14 @@ export const MemberLogsTab = ({
|
||||||
shouldShowPreview,
|
shouldShowPreview,
|
||||||
taskStatus,
|
taskStatus,
|
||||||
intervalsKey,
|
intervalsKey,
|
||||||
|
isTabActive,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||||
if (!expandedLogSummary) return;
|
if (!expandedLogSummary) return;
|
||||||
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
|
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
|
||||||
|
if (!isTabActive) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
|
@ -604,6 +614,7 @@ export const MemberLogsTab = ({
|
||||||
taskId,
|
taskId,
|
||||||
taskStatus,
|
taskStatus,
|
||||||
intervalsKey,
|
intervalsKey,
|
||||||
|
isTabActive,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleExpand = useCallback(
|
const handleExpand = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
|
@ -104,7 +105,7 @@ export const MessageComposer = ({
|
||||||
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
|
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
|
||||||
const [teamSelectorOpen, setTeamSelectorOpen] = useState(false);
|
const [teamSelectorOpen, setTeamSelectorOpen] = useState(false);
|
||||||
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
|
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);
|
const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { Filter } from 'lucide-react';
|
import { Filter } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -67,7 +68,7 @@ export const MessagesFilterPopover = ({
|
||||||
}
|
}
|
||||||
}, [open, filter.from, filter.to, filter.showNoise]);
|
}, [open, filter.from, filter.to, filter.showNoise]);
|
||||||
|
|
||||||
const members = useStore((s) => s.selectedTeamData?.members ?? []);
|
const members = useStore(useShallow((s) => s.selectedTeamData?.members ?? []));
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
||||||
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
|
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe
|
||||||
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import {
|
import {
|
||||||
|
|
@ -105,13 +106,25 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onRestartTeam,
|
onRestartTeam,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
}: MessagesPanelProps): React.JSX.Element {
|
}: MessagesPanelProps): React.JSX.Element {
|
||||||
const sendTeamMessage = useStore((s) => s.sendTeamMessage);
|
const {
|
||||||
const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage);
|
sendTeamMessage,
|
||||||
const sendingMessage = useStore((s) => s.sendingMessage);
|
sendCrossTeamMessage,
|
||||||
const sendMessageError = useStore((s) => s.sendMessageError);
|
sendingMessage,
|
||||||
const lastSendMessageResult = useStore((s) => s.lastSendMessageResult);
|
sendMessageError,
|
||||||
const teams = useStore((s) => s.teams);
|
lastSendMessageResult,
|
||||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
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,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react';
|
import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
import { CliLogsRichView } from '../CliLogsRichView';
|
import { CliLogsRichView } from '../CliLogsRichView';
|
||||||
|
|
@ -72,11 +73,13 @@ export const ScheduleRunLogDialog = ({
|
||||||
onClose,
|
onClose,
|
||||||
}: ScheduleRunLogDialogProps): React.JSX.Element => {
|
}: ScheduleRunLogDialogProps): React.JSX.Element => {
|
||||||
// Read live run data from store — falls back to initial prop if not found
|
// Read live run data from store — falls back to initial prop if not found
|
||||||
const liveRun = useStore((s) => {
|
const liveRun = useStore(
|
||||||
if (!initialRun) return null;
|
useShallow((s) => {
|
||||||
const runs = s.scheduleRuns[scheduleId] ?? [];
|
if (!initialRun) return null;
|
||||||
return runs.find((r) => r.id === initialRun.id) ?? initialRun;
|
const runs = s.scheduleRuns[scheduleId] ?? [];
|
||||||
});
|
return runs.find((r) => r.id === initialRun.id) ?? initialRun;
|
||||||
|
})
|
||||||
|
);
|
||||||
const run = liveRun ?? initialRun;
|
const run = liveRun ?? initialRun;
|
||||||
|
|
||||||
const [logs, setLogs] = useState<{ stdout: string; stderr: string } | null>(null);
|
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 { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -57,7 +58,7 @@ const ScheduleRow = ({
|
||||||
}: ScheduleRowProps): React.JSX.Element => {
|
}: ScheduleRowProps): React.JSX.Element => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [selectedRun, setSelectedRun] = useState<ScheduleRun | null>(null);
|
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 runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false);
|
||||||
const fetchRunHistory = useStore((s) => s.fetchRunHistory);
|
const fetchRunHistory = useStore((s) => s.fetchRunHistory);
|
||||||
|
|
||||||
|
|
@ -207,17 +208,22 @@ const ScheduleRow = ({
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.Element => {
|
export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.Element => {
|
||||||
const schedules = useStore((s) => s.schedules.filter((sch) => sch.teamName === teamName));
|
const { schedules, pauseSchedule, resumeSchedule, deleteSchedule, triggerNow, fetchSchedules } =
|
||||||
const pauseSchedule = useStore((s) => s.pauseSchedule);
|
useStore(
|
||||||
const resumeSchedule = useStore((s) => s.resumeSchedule);
|
useShallow((s) => ({
|
||||||
const deleteSchedule = useStore((s) => s.deleteSchedule);
|
schedules: s.schedules.filter((sch) => sch.teamName === teamName),
|
||||||
const triggerNow = useStore((s) => s.triggerNow);
|
pauseSchedule: s.pauseSchedule,
|
||||||
|
resumeSchedule: s.resumeSchedule,
|
||||||
|
deleteSchedule: s.deleteSchedule,
|
||||||
|
triggerNow: s.triggerNow,
|
||||||
|
fetchSchedules: s.fetchSchedules,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
||||||
|
|
||||||
// Fetch schedules on mount
|
// Fetch schedules on mount
|
||||||
const fetchSchedules = useStore((s) => s.fetchSchedules);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchSchedules();
|
void fetchSchedules();
|
||||||
}, [fetchSchedules]);
|
}, [fetchSchedules]);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createContext, useContext, useId, useLayoutEffect, useState } from 'react';
|
import { createContext, useContext, useId, useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
removeTeamSidebarHost,
|
removeTeamSidebarHost,
|
||||||
|
|
@ -33,10 +34,12 @@ export const TeamSidebarHost = ({
|
||||||
}: TeamSidebarHostProps): React.JSX.Element => {
|
}: TeamSidebarHostProps): React.JSX.Element => {
|
||||||
const hostId = useId();
|
const hostId = useId();
|
||||||
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||||
const { messagesPanelMode, messagesPanelWidth } = useStore((s) => ({
|
const { messagesPanelMode, messagesPanelWidth } = useStore(
|
||||||
messagesPanelMode: s.messagesPanelMode,
|
useShallow((s) => ({
|
||||||
messagesPanelWidth: s.messagesPanelWidth,
|
messagesPanelMode: s.messagesPanelMode,
|
||||||
}));
|
messagesPanelWidth: s.messagesPanelWidth,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const snapshot = useTeamSidebarPortalSnapshot();
|
const snapshot = useTeamSidebarPortalSnapshot();
|
||||||
const isVisible = messagesPanelMode === 'sidebar';
|
const isVisible = messagesPanelMode === 'sidebar';
|
||||||
const isOwner = isVisible && snapshot.activeHostIdByTeam[teamName] === hostId;
|
const isOwner = isVisible && snapshot.activeHostIdByTeam[teamName] === hostId;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useMemo } from 'react';
|
||||||
|
|
||||||
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -84,9 +85,13 @@ export const GraphTaskCard = ({
|
||||||
}: GraphTaskCardProps): React.JSX.Element => {
|
}: GraphTaskCardProps): React.JSX.Element => {
|
||||||
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
|
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
|
||||||
|
|
||||||
const task = useStore((s) => s.selectedTeamData?.tasks.find((t) => t.id === taskId));
|
const { task, tasks, members } = useStore(
|
||||||
const tasks = useStore((s) => s.selectedTeamData?.tasks ?? []);
|
useShallow((s) => ({
|
||||||
const members = useStore((s) => s.selectedTeamData?.members ?? []);
|
task: s.selectedTeamData?.tasks.find((t) => t.id === taskId),
|
||||||
|
tasks: s.selectedTeamData?.tasks ?? [],
|
||||||
|
members: s.selectedTeamData?.members ?? [],
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const taskMap = useMemo(() => {
|
const taskMap = useMemo(() => {
|
||||||
const map = new Map<string, TeamTask>();
|
const map = new Map<string, TeamTask>();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { CliInstallationStatus } from '@shared/types';
|
import type { CliInstallationStatus } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -33,20 +34,39 @@ export function useCliInstaller(): {
|
||||||
installCli: () => void;
|
installCli: () => void;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
} {
|
} {
|
||||||
const cliStatus = useStore((s) => s.cliStatus);
|
const {
|
||||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
cliStatus,
|
||||||
const cliStatusError = useStore((s) => s.cliStatusError);
|
cliStatusLoading,
|
||||||
const installerState = useStore((s) => s.cliInstallerState);
|
cliStatusError,
|
||||||
const downloadProgress = useStore((s) => s.cliDownloadProgress);
|
installerState,
|
||||||
const downloadTransferred = useStore((s) => s.cliDownloadTransferred);
|
downloadProgress,
|
||||||
const downloadTotal = useStore((s) => s.cliDownloadTotal);
|
downloadTransferred,
|
||||||
const installerError = useStore((s) => s.cliInstallerError);
|
downloadTotal,
|
||||||
const installerDetail = useStore((s) => s.cliInstallerDetail);
|
installerError,
|
||||||
const installerRawChunks = useStore((s) => s.cliInstallerRawChunks);
|
installerDetail,
|
||||||
const completedVersion = useStore((s) => s.cliCompletedVersion);
|
installerRawChunks,
|
||||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
completedVersion,
|
||||||
const invalidateCliStatus = useStore((s) => s.invalidateCliStatus);
|
fetchCliStatus,
|
||||||
const installCli = useStore((s) => s.installCli);
|
invalidateCliStatus,
|
||||||
|
installCli,
|
||||||
|
} = useStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
cliStatus: s.cliStatus,
|
||||||
|
cliStatusLoading: s.cliStatusLoading,
|
||||||
|
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,
|
||||||
|
fetchCliStatus: s.fetchCliStatus,
|
||||||
|
invalidateCliStatus: s.invalidateCliStatus,
|
||||||
|
installCli: s.installCli,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const isBusy = installerState !== 'idle' && installerState !== 'error';
|
const isBusy = installerState !== 'idle' && installerState !== 'error';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function useTabUI(): UseTabUIReturn {
|
||||||
|
|
||||||
// Subscribe to tabUIStates MAP directly for reactivity
|
// Subscribe to tabUIStates MAP directly for reactivity
|
||||||
// This ensures re-renders when any tab state changes
|
// 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)
|
// Get the current tab's state (derived from subscribed state)
|
||||||
const tabState = useMemo(() => {
|
const tabState = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
|
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
|
|
@ -56,10 +57,14 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult {
|
export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult {
|
||||||
const globalTasks = useStore((s) => s.globalTasks);
|
const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore(
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
useShallow((s) => ({
|
||||||
const selectedTeamData = useStore((s) => s.selectedTeamData);
|
globalTasks: s.globalTasks,
|
||||||
const teamByName = useStore((s) => s.teamByName);
|
selectedTeamName: s.selectedTeamName,
|
||||||
|
selectedTeamData: s.selectedTeamData,
|
||||||
|
teamByName: s.teamByName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const suggestions = useMemo<MentionSuggestion[]>(() => {
|
const suggestions = useMemo<MentionSuggestion[]>(() => {
|
||||||
const tasks: TaskWithTeamContext[] = [];
|
const tasks: TaskWithTeamContext[] = [];
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ export interface UseTeamSuggestionsResult {
|
||||||
* @param currentTeamName - The current team name to exclude from suggestions
|
* @param currentTeamName - The current team name to exclude from suggestions
|
||||||
*/
|
*/
|
||||||
export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult {
|
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 [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,19 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400;
|
const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400;
|
||||||
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
||||||
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
||||||
|
/** Cap a Map at maxSize by clearing oldest entries (FIFO via insertion order). */
|
||||||
|
const capTimerMap = (map: Map<string, ReturnType<typeof setTimeout>>, maxSize: number): void => {
|
||||||
|
if (map.size <= maxSize) return;
|
||||||
|
const excess = map.size - maxSize;
|
||||||
|
let cleared = 0;
|
||||||
|
for (const [key, value] of map) {
|
||||||
|
if (cleared >= excess) break;
|
||||||
|
clearTimeout(value);
|
||||||
|
map.delete(key);
|
||||||
|
cleared++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const buildToolActivityTimerKey = (
|
const buildToolActivityTimerKey = (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
|
|
@ -209,6 +222,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
cb();
|
cb();
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
toolActivityTimers.set(key, timer);
|
toolActivityTimers.set(key, timer);
|
||||||
|
capTimerMap(toolActivityTimers, 200);
|
||||||
};
|
};
|
||||||
const clearToolActivityTimersForTeam = (teamName: string): void => {
|
const clearToolActivityTimersForTeam = (teamName: string): void => {
|
||||||
for (const [key, timer] of toolActivityTimers.entries()) {
|
for (const [key, timer] of toolActivityTimers.entries()) {
|
||||||
|
|
@ -318,6 +332,16 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return;
|
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) => {
|
const candidateTasks = selectedTeamData.tasks.filter((task) => {
|
||||||
if (task.status !== 'in_progress') {
|
if (task.status !== 'in_progress') {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -376,6 +400,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
void state.refreshSessionInPlace(projectId, sessionId);
|
void state.refreshSessionInPlace(projectId, sessionId);
|
||||||
}, SESSION_REFRESH_DEBOUNCE_MS);
|
}, SESSION_REFRESH_DEBOUNCE_MS);
|
||||||
pendingSessionRefreshTimers.set(key, timer);
|
pendingSessionRefreshTimers.set(key, timer);
|
||||||
|
capTimerMap(pendingSessionRefreshTimers, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleProjectRefresh = (projectId: string): void => {
|
const scheduleProjectRefresh = (projectId: string): void => {
|
||||||
|
|
@ -389,6 +414,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
void state.refreshSessionsInPlace(projectId);
|
void state.refreshSessionsInPlace(projectId);
|
||||||
}, PROJECT_REFRESH_DEBOUNCE_MS);
|
}, PROJECT_REFRESH_DEBOUNCE_MS);
|
||||||
pendingProjectRefreshTimers.set(projectId, timer);
|
pendingProjectRefreshTimers.set(projectId, timer);
|
||||||
|
capTimerMap(pendingProjectRefreshTimers, 20);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for new notifications from main process
|
// Listen for new notifications from main process
|
||||||
|
|
@ -933,6 +959,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
void current.refreshTeamData(event.teamName);
|
void current.refreshTeamData(event.teamName);
|
||||||
}, TEAM_REFRESH_THROTTLE_MS);
|
}, TEAM_REFRESH_THROTTLE_MS);
|
||||||
teamRefreshTimers.set(event.teamName, timer);
|
teamRefreshTimers.set(event.teamName, timer);
|
||||||
|
capTimerMap(teamRefreshTimers, 20);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -949,6 +976,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
void current.refreshSelectedTeamChangePresence(event.teamName);
|
void current.refreshSelectedTeamChangePresence(event.teamName);
|
||||||
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
|
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
|
||||||
teamPresenceRefreshTimers.set(event.teamName, timer);
|
teamPresenceRefreshTimers.set(event.teamName, timer);
|
||||||
|
capTimerMap(teamPresenceRefreshTimers, 20);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -984,6 +1012,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
void current.refreshTeamData(event.teamName);
|
void current.refreshTeamData(event.teamName);
|
||||||
}, TEAM_REFRESH_THROTTLE_MS);
|
}, TEAM_REFRESH_THROTTLE_MS);
|
||||||
teamRefreshTimers.set(event.teamName, timer);
|
teamRefreshTimers.set(event.teamName, timer);
|
||||||
|
capTimerMap(teamRefreshTimers, 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof cleanup === 'function') {
|
if (typeof cleanup === 'function') {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue