fix(graph): refresh runtime state in graph views

This commit is contained in:
777genius 2026-05-19 11:17:17 +03:00
parent dffc527424
commit 9dd1572763
16 changed files with 513 additions and 46 deletions

1
.gitignore vendored
View file

@ -53,6 +53,7 @@ temp/
eslint-fix/
.eslintcache
.eslintcache-fast
remotion/*
.home/

View file

@ -22,6 +22,13 @@ Default local run target:
- Do not start the browser/web dev mode for normal development or smoke checks. The browser path is limited and lacks the full desktop runtime, IPC, terminal, provider auth, and team lifecycle behavior.
- When documenting or recommending startup commands, point contributors to the desktop app unless a task explicitly asks for browser-mode internals.
Fast local lint:
- Use `pnpm lint:fast:files -- <changed files>` for quick preflight on files you touched.
- Use `pnpm lint:fast` for a faster source-tree lint pass when full type-aware lint is too slow.
- `lint:fast` intentionally uses `eslint.fast.config.js` without TypeScript project-service rules. It is not a replacement for `pnpm typecheck` or the full `pnpm lint` gate.
- Keep using `pnpm typecheck` after TypeScript changes, and use full `pnpm lint` when validating a broad PR or changing lint-sensitive architecture boundaries.
For new features:
- Default home for medium and large features: `src/features/<feature-name>/`

256
eslint.fast.config.js Normal file
View file

@ -0,0 +1,256 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import boundaries from 'eslint-plugin-boundaries';
import reactPlugin from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
import importPlugin from 'eslint-plugin-import';
import security from 'eslint-plugin-security';
import sonarjs from 'eslint-plugin-sonarjs';
import tailwindcss from 'eslint-plugin-tailwindcss';
import globals from 'globals';
export default defineConfig([
{
name: 'fast-linter-options',
linterOptions: {
reportUnusedDisableDirectives: 'off',
},
},
globalIgnores([
'dist/**',
'dist-electron/**',
'build/**',
'node_modules/**',
'out/**',
'landing/.nuxt/**',
]),
js.configs.recommended,
...tseslint.configs.recommended,
{
name: 'fast-known-plugin-namespaces',
plugins: {
boundaries,
import: importPlugin,
security,
sonarjs,
tailwindcss,
},
rules: {
'@typescript-eslint/no-require-imports': 'warn',
'no-control-regex': 'warn',
'no-unsafe-finally': 'warn',
'no-useless-escape': 'warn',
},
},
{
name: 'fast-typescript',
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: false,
},
},
rules: {
'no-undef': 'off',
'prefer-const': 'error',
'no-var': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
{
name: 'fast-imports',
files: ['src/**/*.{js,jsx,ts,tsx}', 'test/**/*.{ts,tsx}', 'packages/agent-graph/src/**/*.{ts,tsx}'],
plugins: {
'simple-import-sort': simpleImportSort,
},
rules: {
'simple-import-sort/imports': [
'error',
{
groups: [
['^\\u0000'],
['^node:'],
['^react', '^react-dom'],
['^@?\\w'],
['^@/'],
['^\\.\\.(?!/?$)', '^\\.\\./?$'],
['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
['^.+\\u0000$'],
],
},
],
'simple-import-sort/exports': 'error',
},
},
{
name: 'fast-node-globals',
files: ['src/main/**/*.ts', 'src/preload/**/*.ts', 'scripts/**/*.{js,mjs,ts}', 'test/**/*.ts'],
languageOptions: {
globals: {
...globals.node,
},
},
},
{
name: 'fast-browser-globals',
files: ['src/renderer/**/*.{ts,tsx}', 'src/features/**/renderer/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
},
},
},
{
name: 'fast-react',
files: ['src/renderer/**/*.{tsx,ts}', 'src/features/**/renderer/**/*.{tsx,ts}'],
plugins: {
react: reactPlugin,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'jsx-a11y': jsxA11y,
},
settings: {
react: {
version: 'detect',
},
},
rules: {
...reactPlugin.configs.recommended.rules,
...reactPlugin.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
...jsxA11y.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'react/prop-types': 'off',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/rules-of-hooks': 'warn',
'react-hooks/globals': 'off',
'react-hooks/purity': 'off',
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/preserve-manual-memoization': 'off',
'react-hooks/immutability': 'off',
'jsx-a11y/click-events-have-key-events': 'warn',
'jsx-a11y/no-static-element-interactions': 'warn',
'jsx-a11y/label-has-associated-control': 'warn',
'jsx-a11y/no-noninteractive-tabindex': 'warn',
'jsx-a11y/no-autofocus': 'off',
'react/function-component-definition': [
'warn',
{
namedComponents: 'arrow-function',
unnamedComponents: 'arrow-function',
},
],
'react/jsx-key': [
'error',
{
checkFragmentShorthand: true,
checkKeyMustBeforeSpread: true,
},
],
'react/self-closing-comp': ['error', { component: true, html: true }],
},
},
{
name: 'fast-feature-entrypoints',
files: [
'src/main/**/*.{ts,tsx}',
'src/preload/**/*.{ts,tsx}',
'src/renderer/**/*.{ts,tsx}',
'src/shared/**/*.{ts,tsx}',
],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'@features/*/contracts/*',
'@features/*/core/**',
'@features/*/main/*',
'@features/*/preload/*',
'@features/*/renderer/*',
],
message: 'Import feature public entrypoints only.',
},
],
},
],
},
},
{
name: 'fast-feature-core-guards',
files: ['src/features/*/core/{domain,application}/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{ name: 'electron', message: 'Feature core must stay Electron-free.' },
{ name: 'fastify', message: 'Feature core must stay transport-free.' },
{ name: 'child_process', message: 'Feature core must not spawn processes directly.' },
{
name: 'node:child_process',
message: 'Feature core must not spawn processes directly.',
},
],
patterns: [
{
group: ['@main/*', '@preload/*', '@renderer/*'],
message: 'Feature core must stay process-agnostic.',
},
{
group: ['@features/*/main/**', '@features/*/preload/**', '@features/*/renderer/**'],
message: 'Feature core must not import runtime or transport layers.',
},
],
},
],
},
},
{
name: 'fast-feature-renderer-ui-guards',
files: ['src/features/*/renderer/ui/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{ name: '@renderer/api', message: 'renderer/ui must stay presentational.' },
{ name: '@renderer/store', message: 'renderer/ui must stay store-free.' },
{ name: 'electron', message: 'renderer/ui must stay Electron-free.' },
],
patterns: [
{ group: ['@main/*'], message: 'renderer/ui must not import main modules.' },
{ group: ['@renderer/store/*'], message: 'renderer/ui must stay store-free.' },
],
},
],
},
},
]);

View file

@ -51,6 +51,8 @@
"typecheck": "tsc --noEmit",
"typecheck:workspace": "pnpm typecheck && pnpm --filter agent-teams-mcp typecheck && pnpm --filter agent-teams-mcp typecheck:test",
"lint": "eslint src/ --cache --cache-location .eslintcache --cache-strategy content",
"lint:fast": "eslint --config eslint.fast.config.js --cache --cache-location .eslintcache-fast --cache-strategy content src/",
"lint:fast:files": "eslint --config eslint.fast.config.js --cache --cache-location .eslintcache-fast --cache-strategy content",
"lint:mcp": "pnpm --filter agent-teams-mcp lint",
"lint:fix": "eslint src/ --fix --cache --cache-location .eslintcache --cache-strategy content",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",

View file

@ -1,27 +1,27 @@
{
"version": "0.0.39",
"sourceRef": "v0.0.39",
"version": "0.0.40",
"sourceRef": "v0.0.40",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v2.0.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.39.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.40.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.39.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.40.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.39.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.40.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.39.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.40.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -67,6 +67,7 @@ import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamProcess,
TeamProvisioningProgress,
TeamViewSnapshot,
@ -76,6 +77,7 @@ import type { LeadContextUsage } from '@shared/types/team';
export interface TeamGraphData extends TeamViewSnapshot {
members: ResolvedTeamMember[];
messageFeed: InboxMessage[];
runtimeEntriesByMember?: Record<string, TeamAgentRuntimeEntry>;
}
function toGraphLaunchVisualState(
@ -438,6 +440,7 @@ export class TeamGraphAdapter {
): void {
const percent = leadContext?.contextUsedPercent;
const leadMember = data.members.find((member) => member.name === leadName);
const runtimeEntry = data.runtimeEntriesByMember?.[leadName];
const isTeamVisualOnline = data.isAlive || isTeamProvisioning;
const activeTool = TeamGraphAdapter.#selectVisibleTool(
activeTools?.[leadName],
@ -454,6 +457,7 @@ export class TeamGraphAdapter {
spawnLivenessSource: undefined,
spawnRuntimeAlive: undefined,
spawnBootstrapStalled: undefined,
runtimeEntry,
runtimeAdvisory: leadMember.runtimeAdvisory,
isLaunchSettling: false,
isTeamAlive: data.isAlive,
@ -545,6 +549,7 @@ export class TeamGraphAdapter {
const memberId =
memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member);
const spawn = spawnStatuses?.[member.name];
const runtimeEntry = data.runtimeEntriesByMember?.[member.name];
const activeTool = TeamGraphAdapter.#selectVisibleTool(
activeTools?.[member.name],
finishedVisible?.[member.name]
@ -576,6 +581,9 @@ export class TeamGraphAdapter {
spawnAgentToolAccepted: spawn?.agentToolAccepted,
spawnHardFailure: spawn?.hardFailure,
spawnLivenessKind: spawn?.livenessKind,
spawnFirstSpawnAcceptedAt: spawn?.firstSpawnAcceptedAt,
spawnUpdatedAt: spawn?.updatedAt,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive: data.isAlive,

View file

@ -17,6 +17,7 @@ interface GraphMemberPopoverContext {
| null;
teamMembers: ReturnType<typeof selectResolvedMembersForTeamName>;
spawnEntry: AppState['memberSpawnStatusesByTeam'][string][string] | undefined;
runtimeEntry: AppState['teamAgentRuntimeByTeam'][string]['members'][string] | undefined;
leadActivity: AppState['leadActivityByTeam'][string] | undefined;
progress: ReturnType<typeof getCurrentProvisioningProgressForTeam> | null;
memberSpawnSnapshot: AppState['memberSpawnSnapshotsByTeam'][string] | undefined;
@ -41,6 +42,9 @@ function selectGraphMemberPopoverContext(
: null,
teamMembers,
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
runtimeEntry: teamName
? state.teamAgentRuntimeByTeam[teamName]?.members[memberName]
: undefined,
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,

View file

@ -5,6 +5,7 @@
import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { useTeamAgentRuntimeWatcher } from '@renderer/components/team/useTeamAgentRuntimeWatcher';
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import {
@ -73,6 +74,7 @@ export function useTeamGraphAdapter(
members,
messages,
spawnStatuses,
runtimeSnapshot,
leadActivity,
leadContext,
pendingApprovals,
@ -93,6 +95,7 @@ export function useTeamGraphAdapter(
members: isActive ? selectResolvedMembersForTeamName(s, teamName) : EMPTY_MEMBERS,
messages: isActive ? selectTeamMessages(s, teamName) : EMPTY_MESSAGES,
spawnStatuses: isActive && teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
runtimeSnapshot: isActive && teamName ? s.teamAgentRuntimeByTeam[teamName] : undefined,
leadActivity: isActive && teamName ? s.leadActivityByTeam[teamName] : undefined,
leadContext: isActive && teamName ? s.leadContextByTeam[teamName] : undefined,
pendingApprovals: isActive ? s.pendingApprovals : EMPTY_PENDING_APPROVALS,
@ -113,6 +116,11 @@ export function useTeamGraphAdapter(
}))
);
useTeamAgentRuntimeWatcher({
teamName,
enabled: isActive,
});
const pendingApprovalAgents = useMemo(() => {
if (!isActive) {
return EMPTY_PENDING_APPROVAL_AGENTS;
@ -134,8 +142,9 @@ export function useTeamGraphAdapter(
...teamSnapshot,
members,
messageFeed: messages,
runtimeEntriesByMember: runtimeSnapshot?.members,
};
}, [members, messages, teamSnapshot]);
}, [members, messages, runtimeSnapshot?.members, teamSnapshot]);
const commentReadState = useSyncExternalStore(
isActive ? subscribe : subscribeNoop,

View file

@ -29,6 +29,7 @@ const ACTIVITY_SHELL_HEIGHT =
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
ACTIVITY_LANE.overflowHeight;
const NEW_ACTIVITY_HIGHLIGHT_MS = 1_000;
const INTERACTIVE_ACTIVITY_CONTROL_CLASS = 'pointer-events-auto';
interface GraphActivityHudProps {
teamName: string;
@ -456,7 +457,7 @@ export const GraphActivityHud = ({
key={entry.graphItem.id}
data-activity-entry-id={entry.graphItem.id}
className={[
'h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden rounded-md border transition-[border-color,background-color,box-shadow] duration-500',
`${INTERACTIVE_ACTIVITY_CONTROL_CLASS} h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden rounded-md border transition-[border-color,background-color,box-shadow] duration-500`,
isHighlighted
? 'border-sky-300/70 bg-[rgba(14,34,62,0.56)] shadow-[0_0_0_1px_rgba(125,211,252,0.30),0_0_18px_rgba(56,189,248,0.22)]'
: 'border-transparent',
@ -536,7 +537,7 @@ export const GraphActivityHud = ({
ref={(element) => {
shellRefs.current.set(lane.node.id, element);
}}
className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0"
className="pointer-events-none absolute z-10 origin-top-left select-none opacity-0"
style={{
width: `${laneWidth}px`,
maxWidth: `${laneWidth}px`,
@ -561,7 +562,7 @@ export const GraphActivityHud = ({
{lane.overflowCount > 0 ? (
<button
type="button"
className="h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
className={`${INTERACTIVE_ACTIVITY_CONTROL_CLASS} h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]`}
onClick={() => handleOpenOwnerActivity(lane.node)}
>
+{lane.overflowCount} more

View file

@ -315,6 +315,7 @@ const MemberPopoverContent = ({
teamData,
teamMembers,
spawnEntry,
runtimeEntry,
leadActivity,
progress,
memberSpawnSnapshot,
@ -358,6 +359,9 @@ const MemberPopoverContent = ({
spawnAgentToolAccepted: spawnEntry?.agentToolAccepted,
spawnHardFailure: spawnEntry?.hardFailure,
spawnLivenessKind: spawnEntry?.livenessKind,
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
spawnUpdatedAt: spawnEntry?.updatedAt,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false,
isTeamAlive: teamData?.isAlive,

View file

@ -144,6 +144,7 @@ import { TeamChangesSection } from './TeamChangesSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { loadTeamSessionMetadata } from './teamSessionFetchGuards';
import { TeamSessionsSection } from './TeamSessionsSection';
import { useTeamAgentRuntimeWatcher } from './useTeamAgentRuntimeWatcher';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { KanbanSortState } from './kanban/KanbanSortPopover';
@ -915,8 +916,6 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
return null;
});
const TEAM_AGENT_RUNTIME_REFRESH_MS = 5_000;
const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({
teamName,
isTeamProvisioning,
@ -928,37 +927,12 @@ const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({
isTeamAlive?: boolean;
isThisTabActive: boolean;
}): null {
const { leadActivity, fetchTeamAgentRuntime } = useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
fetchTeamAgentRuntime: s.fetchTeamAgentRuntime,
}))
);
useEffect(() => {
if (!isThisTabActive) return;
const shouldWatch =
isTeamProvisioning ||
isTeamAlive === true ||
leadActivity === 'active' ||
leadActivity === 'idle';
if (!shouldWatch) return;
void fetchTeamAgentRuntime(teamName);
const timer = window.setInterval(() => {
void fetchTeamAgentRuntime(teamName);
}, TEAM_AGENT_RUNTIME_REFRESH_MS);
return () => {
window.clearInterval(timer);
};
}, [
fetchTeamAgentRuntime,
useTeamAgentRuntimeWatcher({
teamName,
enabled: isThisTabActive,
isTeamAlive,
isTeamProvisioning,
isThisTabActive,
leadActivity,
teamName,
]);
});
return null;
});

View file

@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { useStore } from '@renderer/store';
import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow';
const TEAM_AGENT_RUNTIME_REFRESH_MS = 5_000;
interface TeamAgentRuntimeWatcherOptions {
teamName: string;
enabled: boolean;
isTeamProvisioning?: boolean;
isTeamAlive?: boolean;
}
export function useTeamAgentRuntimeWatcher({
teamName,
enabled,
isTeamProvisioning,
isTeamAlive,
}: TeamAgentRuntimeWatcherOptions): void {
const { leadActivity, storeIsTeamAlive, storeIsTeamProvisioning, fetchTeamAgentRuntime } =
useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
storeIsTeamAlive: selectTeamDataForName(s, teamName)?.isAlive,
storeIsTeamProvisioning: isTeamProvisioningActive(s, teamName),
fetchTeamAgentRuntime: s.fetchTeamAgentRuntime,
}))
);
const effectiveIsTeamAlive = isTeamAlive ?? storeIsTeamAlive;
const effectiveIsTeamProvisioning = isTeamProvisioning ?? storeIsTeamProvisioning;
useEffect(() => {
if (!enabled) return;
const shouldWatch =
effectiveIsTeamProvisioning ||
effectiveIsTeamAlive === true ||
leadActivity === 'active' ||
leadActivity === 'idle';
if (!shouldWatch) return;
void fetchTeamAgentRuntime(teamName);
const timer = window.setInterval(() => {
void fetchTeamAgentRuntime(teamName);
}, TEAM_AGENT_RUNTIME_REFRESH_MS);
return () => {
window.clearInterval(timer);
};
}, [
effectiveIsTeamAlive,
effectiveIsTeamProvisioning,
enabled,
fetchTeamAgentRuntime,
leadActivity,
teamName,
]);
}

View file

@ -3070,6 +3070,33 @@ export interface TeamSlice {
// --- Per-team launch params persistence ---
const LAUNCH_PARAMS_PREFIX = 'team:launchParams:';
const MESSAGES_PANEL_MODE_STORAGE_KEY = 'team:messagesPanelMode';
const DEFAULT_MESSAGES_PANEL_MODE: TeamMessagesPanelMode = 'sidebar';
const VALID_MESSAGES_PANEL_MODES: ReadonlySet<TeamMessagesPanelMode> = new Set([
'sidebar',
'inline',
'bottom-sheet',
'floating-composer',
]);
export function loadPersistedMessagesPanelMode(): TeamMessagesPanelMode {
try {
const persisted = localStorage.getItem(MESSAGES_PANEL_MODE_STORAGE_KEY);
return VALID_MESSAGES_PANEL_MODES.has(persisted as TeamMessagesPanelMode)
? (persisted as TeamMessagesPanelMode)
: DEFAULT_MESSAGES_PANEL_MODE;
} catch {
return DEFAULT_MESSAGES_PANEL_MODE;
}
}
export function savePersistedMessagesPanelMode(mode: TeamMessagesPanelMode): void {
try {
localStorage.setItem(MESSAGES_PANEL_MODE_STORAGE_KEY, mode);
} catch {
// ignore - best-effort UI preference persistence
}
}
export function getCurrentProvisioningProgressForTeam(
state: Pick<TeamSlice, 'currentProvisioningRunIdByTeam' | 'provisioningRuns'>,
@ -3440,10 +3467,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
toolApprovalSettings: loadToolApprovalSettings(),
// Messages panel UI state
messagesPanelMode: 'sidebar' as const,
messagesPanelMode: loadPersistedMessagesPanelMode(),
messagesPanelWidth: 340,
sidebarLogsHeight: 213,
setMessagesPanelMode: (mode: TeamMessagesPanelMode) => set({ messagesPanelMode: mode }),
setMessagesPanelMode: (mode: TeamMessagesPanelMode) => {
savePersistedMessagesPanelMode(mode);
set({ messagesPanelMode: mode });
},
setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }),
setSidebarLogsHeight: (height: number) => set({ sidebarLogsHeight: height }),

View file

@ -254,6 +254,13 @@ describe('GraphActivityHud', () => {
button.textContent?.includes('+1 more')
);
expect(moreButton).not.toBeUndefined();
expect(moreButton?.className).toContain('pointer-events-auto');
const shell = host.querySelector('.z-10');
expect(shell?.className).toContain('pointer-events-none');
expect(host.querySelector('[data-activity-entry-id="item-1"]')?.className).toContain(
'pointer-events-auto'
);
await act(async () => {
moreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));

View file

@ -1,12 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
TeamGraphAdapter,
type TeamGraphData,
} from '@features/agent-graph/renderer/adapters/TeamGraphAdapter';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InboxMessage, TeamTaskWithKanban } from '@shared/types/team';
import type { GraphDataPort } from '@claude-teams/agent-graph';
import type {
InboxMessage,
MemberSpawnStatusEntry,
TeamAgentRuntimeEntry,
TeamTaskWithKanban,
} from '@shared/types/team';
function createBaseTeamData(
overrides?: Partial<TeamGraphData> & {
@ -62,6 +66,23 @@ function findNode(graph: GraphDataPort, nodeId: string) {
return graph.nodes.find((node) => node.id === nodeId);
}
function createLiveRuntimeEntry(
memberName: string,
overrides: Partial<TeamAgentRuntimeEntry> = {}
): TeamAgentRuntimeEntry {
return {
memberName,
alive: true,
restartable: true,
providerId: 'codex',
providerBackendId: 'codex-native',
livenessKind: 'runtime_process',
pid: 12345,
updatedAt: '2026-03-28T19:00:00.000Z',
...overrides,
};
}
function adaptWithActiveTaskLogActivity(
adapter: TeamGraphAdapter,
teamData: TeamGraphData,
@ -185,6 +206,62 @@ describe('TeamGraphAdapter particles', () => {
expect(graph.layout?.mode).toBe('grid-under-lead');
});
it('uses runtime entries when deriving member launch status', () => {
const adapter = TeamGraphAdapter.create();
const spawnStatuses: Record<string, MemberSpawnStatusEntry> = {
alice: {
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
livenessKind: 'runtime_process',
updatedAt: '2026-03-28T19:00:00.000Z',
},
};
const teamData = createBaseTeamData({
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
providerId: 'codex',
providerBackendId: 'codex-native',
},
],
});
const withoutRuntime = adapter.adapt(teamData, 'my-team', spawnStatuses);
expect(findNode(withoutRuntime, 'member:my-team:alice')?.launchStatusLabel).toBe(
'stale runtime'
);
const withRuntime = adapter.adapt(
{
...teamData,
runtimeEntriesByMember: {
alice: createLiveRuntimeEntry('alice'),
},
},
'my-team',
spawnStatuses
);
expect(findNode(withRuntime, 'member:my-team:alice')?.launchStatusLabel).toBeUndefined();
expect(findNode(withRuntime, 'member:my-team:alice')?.launchVisualState).toBeUndefined();
});
it('applies saved grid owner order only in grid-under-lead mode', () => {
const adapter = TeamGraphAdapter.create();
const teamData = createBaseTeamData({

View file

@ -8,6 +8,8 @@ import {
getActiveTeamPendingReplyWaits,
hasActiveTeamPendingReplyWait,
getCurrentProvisioningProgressForTeam,
loadPersistedMessagesPanelMode,
savePersistedMessagesPanelMode,
selectMemberMessagesForTeamMember,
selectResolvedMemberForTeamName,
selectResolvedMembersForTeamName,
@ -344,6 +346,7 @@ describe('teamSlice actions', () => {
});
hoisted.restartMember.mockResolvedValue(undefined);
hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
window.localStorage.removeItem('team:messagesPanelMode');
});
afterEach(() => {
@ -351,6 +354,31 @@ describe('teamSlice actions', () => {
vi.useRealTimers();
});
it('restores the selected messages panel mode from localStorage', () => {
window.localStorage.setItem('team:messagesPanelMode', 'bottom-sheet');
const store = createSliceStore();
expect(store.getState().messagesPanelMode).toBe('bottom-sheet');
});
it('persists messages panel mode changes and ignores invalid stored values', () => {
const store = createSliceStore();
store.getState().setMessagesPanelMode('floating-composer');
expect(window.localStorage.getItem('team:messagesPanelMode')).toBe('floating-composer');
expect(loadPersistedMessagesPanelMode()).toBe('floating-composer');
window.localStorage.setItem('team:messagesPanelMode', 'bad-mode');
expect(loadPersistedMessagesPanelMode()).toBe('sidebar');
savePersistedMessagesPanelMode('inline');
expect(window.localStorage.getItem('team:messagesPanelMode')).toBe('inline');
});
it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => {
const store = createSliceStore();
const fetchTeams = vi.fn(async () => undefined);