Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest';

import {
isAgentIslandVisibleSessionOwnedByWorkdirBrowseRoute,
isAgentIslandVisibleSessionOwnedByBotRoute,
resolveAgentIslandVisibleSessionsFromPath,
resolveAgentIslandVisibleSessionFromRouteTarget,
resolveAgentIslandVisibleSessionIdForWorkdirBrowseRail,
resolveAgentIslandVisibleSessionIdFromPath,
Expand Down Expand Up @@ -35,6 +37,44 @@ const ccAgentSessionViewSource = readTextLf(
'utf8',
);

describe('bot and split route visibility', () => {
it.each([
['/bots/bot-a/session/chat-a', true],
['/bots/bot-a/history/history-a', true],
['/bots/bot-a', false],
['/bots', false],
['/bots/roster', false],
['/bots/bot-a/session', false],
])('delegates %s to the validating view', (pathname, expected) => {
expect(isAgentIslandVisibleSessionOwnedByBotRoute(pathname)).toBe(expected);
});

it('keeps stale splits out of bot pages across navigation and settings', () => {
const splits = ['task-a', 'task-b'];
const targets = [
'/cc-agent/task-a',
'/bots/bot-a',
'/bots/bot-a?settings=1',
'/bots/bot-a/session/chat-a',
// The session settings drawer keeps the chat mounted underneath it.
'/bots/bot-a/session/chat-a?settings=1',
'/bots/bot-a/history/history-a',
'/settings',
'/cc-agent/files/task-a',
'/cc-agent/task-a',
];
expect(targets.map((target) => resolveAgentIslandVisibleSessionsFromPath(
new URL(target, 'https://cindy.invalid').pathname,
splits,
))).toEqual([
['task-a', 'task-b'], null, null, null, null, null, null, null,
['task-a', 'task-b'],
]);
expect(resolveAgentIslandVisibleSessionsFromPath('/cc-agent/task-a', [])).toBe('task-a');
expect(resolveAgentIslandVisibleSessionsFromPath('/cc-agent/task-a', ['task-b'])).toBe('task-a');
});
});

describe('resolveAgentIslandVisibleSessionIdFromPath', () => {
it('returns the session id only for routes that visibly show a session', () => {
expect(resolveAgentIslandVisibleSessionIdFromPath('/cc-agent/session-a')).toBe('session-a');
Expand Down
21 changes: 12 additions & 9 deletions apps/desktop/src/renderer/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ import { botRouteForOwnedSession } from '@/features/bots/botSessionOwners';
import { remoteProjectsStore } from '@/features/device-link/remoteProjectsStore';
import {
isAgentIslandVisibleSessionOwnedByWorkdirBrowseRoute,
isAgentIslandVisibleSessionOwnedByBotRoute,
resolveAgentIslandVisibleSessionFromRouteTarget,
resolveAgentIslandVisibleSessionsFromPath,
resolveAgentIslandVisibleSessionIdFromPath,
} from '@/lib/agentIslandVisibleSessionRoute';

Expand Down Expand Up @@ -422,22 +424,23 @@ export function MainLayout() {
}
}, [sidebarPeek.peekState, isRailMode, handleRailModeChange]);

const routeSessionId = resolveAgentIslandVisibleSessionIdFromPath(location.pathname);
const splitVisibleSessionIds = useMemo(() => {
const splitSessionIds = getSplitSessionIds(splitGroup.root);
return routeSessionId && splitSessionIds.length >= 2
? [...new Set([routeSessionId, ...splitSessionIds])]
: [];
}, [routeSessionId, splitGroup.root]);
const visibleSessions = useMemo(
() => resolveAgentIslandVisibleSessionsFromPath(
location.pathname,
getSplitSessionIds(splitGroup.root),
),
[location.pathname, splitGroup.root],
);

const syncAgentIslandVisibleSession = useCallback(() => {
if (!isAgentIslandSupported()) return;
if (!document.hasFocus()) return;
if (isAgentIslandVisibleSessionOwnedByWorkdirBrowseRoute(location.pathname)) return;
if (isAgentIslandVisibleSessionOwnedByBotRoute(location.pathname)) return;
void window.electronAPI.agentIsland?.setVisibleSession?.(
splitVisibleSessionIds.length >= 2 ? splitVisibleSessionIds : routeSessionId,
visibleSessions,
);
}, [location.pathname, routeSessionId, splitVisibleSessionIds]);
}, [location.pathname, visibleSessions]);

useEffect(() => {
syncAgentIslandVisibleSession();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';

import { CCAgentSessionView } from '@/features/cc-agent/CCAgentSessionView';
import type { BotChatIdentity } from './BotSessionContentHeader';
import { useBotIslandVisibleSession } from './useBotIslandVisibleSession';

function readBotChatIdentity(bot: unknown, botId: string): BotChatIdentity | null {
if (!bot || typeof bot !== 'object') return null;
Expand All @@ -18,9 +19,15 @@ function readBotChatIdentity(bot: unknown, botId: string): BotChatIdentity | nul

/** Historical Bot transcripts are reviewable but never writable from the history route. */
export function BotHistorySessionView() {
const { botId, sessionId } = useParams();
return <BotHistorySessionGateView key={JSON.stringify([botId, sessionId])} />;
}

function BotHistorySessionGateView() {
const { t } = useTranslation();
const { botId, sessionId } = useParams();
const [allowed, setAllowed] = useState<boolean | null>(null);
useBotIslandVisibleSession(allowed === true ? sessionId ?? null : null);
/**
* 归档的对话里,那个伙伴仍然是那个伙伴:气泡挂 TA 的头像,顶栏是 TA 的 lockup。
* 这个视图本来就已经查过 `bots.history(botId)` 确认归属,顺手把身份取回来即可
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/features/bots/BotSessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CCAgentSessionView } from '@/features/cc-agent/CCAgentSessionView';
import type { ComposerBotMention } from '@/lib/fileTypes';
import { getBotLastReadAt, markBotRead } from './botReadState';
import type { BotChatIdentity } from './BotSessionContentHeader';
import { useBotIslandVisibleSession } from './useBotIslandVisibleSession';

type BotSessionGate =
| { kind: 'loading' }
Expand Down Expand Up @@ -65,11 +66,17 @@ function readBotMention(value: unknown, currentBotId: string): ComposerBotMentio
* Cindy task. Check the durable Bot link before mounting the writable chat.
*/
export function BotSessionView() {
const { botId, sessionId } = useParams();
return <BotSessionGateView key={JSON.stringify([botId, sessionId])} />;
}

function BotSessionGateView() {
const { t } = useTranslation();
const navigate = useNavigate();
const { botId, sessionId } = useParams();
const [reloadVersion, setReloadVersion] = useState(0);
const [gate, setGate] = useState<BotSessionGate>({ kind: 'loading' });
useBotIslandVisibleSession(gate.kind === 'ready' ? sessionId ?? null : null);

useEffect(() => {
let cancelled = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import { act, cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
params: { botId: 'bot-a', sessionId: 'chat-a' },
supported: true,
}));
vi.mock('react-router-dom', () => ({
useParams: () => mocks.params,
useNavigate: () => vi.fn(),
Navigate: () => null,
}));
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (s: string) => s }) }));
vi.mock('@/hooks/useAgentIslandSettings', () => ({
isAgentIslandSupported: () => mocks.supported,
}));
vi.mock('@/features/cc-agent/CCAgentSessionView', () => ({
CCAgentSessionView: () => <div data-testid="chat" />,
}));
vi.mock('../botReadState', () => ({ getBotLastReadAt: () => null, markBotRead: vi.fn() }));

import { BotSessionView } from '../BotSessionView';
import { BotHistorySessionView } from '../BotHistorySessionView';

const report = vi.fn();
const get = vi.fn();
const history = vi.fn();
const profile = {
id: 'bot-a', status: 'active',
sessions: [{ id: 'chat-a', kind: 'chat', status: 'active' }],
};

beforeEach(() => {
vi.clearAllMocks();
mocks.params = { botId: 'bot-a', sessionId: 'chat-a' };
mocks.supported = true;
vi.spyOn(document, 'hasFocus').mockReturnValue(true);
get.mockResolvedValue(profile);
history.mockResolvedValue([{ id: 'chat-a' }]);
Object.defineProperty(window, 'electronAPI', { configurable: true, value: {
agentIsland: { setVisibleSession: report },
localDb: { bots: { get, history, list: vi.fn(async () => []) } },
} });
});
afterEach(() => { cleanup(); vi.restoreAllMocks(); });

describe.each([
['chat', BotSessionView], ['history', BotHistorySessionView],
] as const)('%s ownership and visibility', (_, View) => {
it('waits for validation and discards the previous grant on route changes', async () => {
let resolveGate!: (value: never) => void;
const pending = new Promise((resolve) => { resolveGate = resolve; });
if (View === BotSessionView) get.mockReturnValueOnce(pending);
else history.mockReturnValueOnce(pending);
const view = render(<View />);
expect(report).not.toHaveBeenCalledWith('chat-a');
await act(async () => resolveGate((View === BotSessionView ? profile : [{ id: 'chat-a' }]) as never));
await waitFor(() => expect(report).toHaveBeenCalledWith('chat-a'));
report.mockClear();
mocks.params = { botId: 'bot-b', sessionId: 'not-owned' };
view.rerender(<View />);
await act(async () => {});
expect(report).not.toHaveBeenCalledWith('not-owned');
expect(view.queryByTestId('chat')).toBeNull();
expect(report).toHaveBeenLastCalledWith(null);
});

it('ignores a late validation result after navigation', async () => {
let resolveGate!: (value: never) => void;
const pending = new Promise((resolve) => { resolveGate = resolve; });
if (View === BotSessionView) get.mockReturnValueOnce(pending);
else history.mockReturnValueOnce(pending);
const view = render(<View />);
mocks.params = { botId: 'bot-b', sessionId: 'not-owned' };
view.rerender(<View />);
await act(async () => resolveGate((View === BotSessionView ? profile : [{ id: 'chat-a' }]) as never));
expect(report.mock.calls.every(([id]) => id === null)).toBe(true);
});

it('does not report a failed lookup', async () => {
get.mockRejectedValue(new Error('unavailable'));
history.mockRejectedValue(new Error('unavailable'));
render(<View />);
await act(async () => {});
expect(report.mock.calls.every(([id]) => id === null)).toBe(true);
});

it('reports validated ownership before DOM focus settles, then resyncs and cleans up', async () => {
vi.mocked(document.hasFocus).mockReturnValue(false);
let resolveGate!: (value: never) => void;
const pending = new Promise((resolve) => { resolveGate = resolve; });
if (View === BotSessionView) get.mockReturnValueOnce(pending);
else history.mockReturnValueOnce(pending);
const view = render(<View />);
expect(view.queryByTestId('chat')).toBeNull();
expect(report).not.toHaveBeenCalledWith('chat-a');
// Settle ownership and flush its passive effects before dispatching focus.
// Observing the chat DOM alone can precede the visibility effect's commit.
await act(async () => resolveGate((View === BotSessionView ? profile : [{ id: 'chat-a' }]) as never));
expect(view.getByTestId('chat')).toBeTruthy();
// Main owns foreground/pending-focus acceptance; do not lose its first ack.
expect(report).toHaveBeenLastCalledWith('chat-a');
report.mockClear();
vi.mocked(document.hasFocus).mockReturnValue(true);
act(() => window.dispatchEvent(new Event('focus')));
expect(report).toHaveBeenLastCalledWith('chat-a');
view.unmount();
expect(report).toHaveBeenLastCalledWith(null);
report.mockClear();
act(() => window.dispatchEvent(new Event('focus')));
expect(report).not.toHaveBeenCalled();
});

it('does not call the bridge when Agent Island is unsupported', async () => {
mocks.supported = false;
const view = render(<View />);
await waitFor(() => expect(view.getByTestId('chat')).toBeTruthy());
expect(report).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useEffect } from 'react';
import { isAgentIslandSupported } from '@/hooks/useAgentIslandSettings';

/** Called by the route's ownership gate, never by URL-only navigation. */
export function useBotIslandVisibleSession(sessionId: string | null) {
useEffect(() => {
if (!isAgentIslandSupported()) return;
const sync = () => {
// Main accepts only a focused window or its pending notification target.
// A DOM focus check here can drop that ack before focus has settled.
void window.electronAPI.agentIsland?.setVisibleSession?.(sessionId);
};
sync();
window.addEventListener('focus', sync);
return () => {
window.removeEventListener('focus', sync);
void window.electronAPI.agentIsland?.setVisibleSession?.(null);
};
}, [sessionId]);
}
19 changes: 19 additions & 0 deletions apps/desktop/src/renderer/lib/agentIslandVisibleSessionRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ export function resolveAgentIslandVisibleSessionIdFromPath(
return sessionId;
}

export function isAgentIslandVisibleSessionOwnedByBotRoute(pathname: string): boolean {
const sessionMatch = matchPath('/bots/:botId/session/:sessionId', pathname) ??
matchPath('/bots/:botId/history/:sessionId', pathname);
// The matching view must validate ownership before reporting a visible session.
return Boolean(sessionMatch?.params.sessionId);
}

export function resolveAgentIslandVisibleSessionsFromPath(
pathname: string,
splitSessionIds: readonly string[],
): string | string[] | null {
const sessionId = resolveAgentIslandVisibleSessionIdFromPath(pathname);
// SplitGroup stays in the store after leaving cc-agent, but is no longer mounted.
if (sessionId && splitSessionIds.length >= 2) {
return [...new Set([sessionId, ...splitSessionIds])];
}
return sessionId;
}

/**
* 通知/deep link 的目标路由还没渲染时,直接从完整 route target 解析首个可见
* session payload。Orca worker query 表示 Lead 与 Worker 会同时可见。
Expand Down
4 changes: 2 additions & 2 deletions docs/design-rules/design-inventory.md

Large diffs are not rendered by default.

Loading