Select a repository to view details
diff --git a/packages/coc/src/server/spa/client/react/utils/config.ts b/packages/coc/src/server/spa/client/react/utils/config.ts
index b08ae4359..7f1b0cfaa 100644
--- a/packages/coc/src/server/spa/client/react/utils/config.ts
+++ b/packages/coc/src/server/spa/client/react/utils/config.ts
@@ -68,6 +68,8 @@ interface DashboardConfig {
effortLevelsEnabled?: boolean;
/** Whether the read-only native CLI sessions tab is enabled (feature flag). */
nativeCliSessionsEnabled?: boolean;
+ /** Whether the remote-first two-row dashboard shell is enabled (feature flag). */
+ remoteShellEnabled?: boolean;
}
/** Cached runtime config loaded from the API. */
@@ -302,6 +304,11 @@ export function isNativeCliSessionsEnabled(): boolean {
return getConfig().nativeCliSessionsEnabled === true;
}
+/** Returns true when the remote-first two-row dashboard shell is enabled. */
+export function isRemoteShellEnabled(): boolean {
+ return getConfig().remoteShellEnabled === true;
+}
+
export function isExcalidrawEnabled(): boolean {
return getConfig().excalidrawEnabled === true;
}
diff --git a/packages/coc/test/config.test.ts b/packages/coc/test/config.test.ts
index 367e0f7c3..3b91042bf 100644
--- a/packages/coc/test/config.test.ts
+++ b/packages/coc/test/config.test.ts
@@ -1019,6 +1019,7 @@ timeout: 300
' autoAgentProviderRouting: true',
' ralphMultiAgentGrill: true',
' nativeCliSessions: true',
+ ' remoteShell: true',
'memoryPromotion:',
' batchSize: 25',
' timeoutMs: 80000',
@@ -1233,6 +1234,7 @@ timeout: 300
"gitCrossCloneCherryPick": true,
"nativeCliSessions": false,
"ralphMultiAgentGrill": false,
+ "remoteShell": false,
"sessionContextAttachments": false,
},
"forEach": {
@@ -1398,6 +1400,7 @@ timeout: 300
"features.gitCrossCloneCherryPick": "default",
"features.nativeCliSessions": "default",
"features.ralphMultiAgentGrill": "default",
+ "features.remoteShell": "default",
"features.sessionContextAttachments": "default",
"forEach.enabled": "file",
"groupSingleLineMessages": "file",
diff --git a/packages/coc/test/server/executors-prompt-builder.test.ts b/packages/coc/test/server/executors-prompt-builder.test.ts
index 4dc32b1c8..7977c7a78 100644
--- a/packages/coc/test/server/executors-prompt-builder.test.ts
+++ b/packages/coc/test/server/executors-prompt-builder.test.ts
@@ -608,14 +608,14 @@ describe('buildSearchConversationsAddon', () => {
expect(result.tools).toHaveLength(2);
const names = result.tools.map(t => t.name).sort();
expect(names).toEqual(['get_conversation', 'search_conversations']);
- expect(result.suffix).toContain('search_conversations');
- expect(result.suffix).toContain('get_conversation');
+ // The tools are wired (asserted above); their prompt suffix was
+ // intentionally trimmed, so no guidance text is appended.
});
- it('suffix mentions past conversation history', () => {
+ it('does not emit a suffix (conversation-history guidance trimmed)', () => {
const store = { searchConversations: vi.fn() } as any;
const result = buildSearchConversationsAddon(store);
- expect(result.suffix).toContain('conversation-history');
+ expect(result.suffix).toBe('');
});
});
diff --git a/packages/coc/test/server/executors/chat-tool-builder.test.ts b/packages/coc/test/server/executors/chat-tool-builder.test.ts
index 369a40ab5..0f96b5ecc 100644
--- a/packages/coc/test/server/executors/chat-tool-builder.test.ts
+++ b/packages/coc/test/server/executors/chat-tool-builder.test.ts
@@ -56,7 +56,8 @@ describe('buildChatToolBundle', () => {
expect(result.tools.map(t => t.name)).not.toContain('update_work_item');
expect(result.tools.map(t => t.name)).not.toContain('create_bug');
expect(result.toolGuidance).toContain('tavily_web_search');
- expect(result.toolGuidance).toContain('search_conversations');
+ // search_conversations / get_conversation tools are still wired (asserted
+ // above); their prompt suffix was intentionally trimmed, so no guidance text.
expect(result.toolGuidance).toContain('3 suggestions');
expect(result.askUser).toBeDefined();
});
@@ -124,7 +125,7 @@ describe('buildChatToolBundle', () => {
expect(toolNames).toContain('createLoop');
expect(toolNames).toContain('cancelLoop');
expect(toolNames).toContain('listLoops');
- expect(result.toolGuidance).toContain('Loop management tools');
+ // Loop tools are wired; their descriptive suffix was intentionally removed.
});
it('does not include loop tools when loopTools deps are not provided', () => {
diff --git a/packages/coc/test/server/executors/loop-tools-addon.test.ts b/packages/coc/test/server/executors/loop-tools-addon.test.ts
index fed5197a1..0fd0f4c0b 100644
--- a/packages/coc/test/server/executors/loop-tools-addon.test.ts
+++ b/packages/coc/test/server/executors/loop-tools-addon.test.ts
@@ -41,23 +41,12 @@ describe('buildLoopToolsAddon', () => {
expect(names).toContain('listLoops');
});
- it('includes descriptive suffix about loop tools', () => {
+ it('does not emit a descriptive suffix (prompt guidance trimmed)', () => {
const deps = makeMockLoopToolDeps();
const result = buildLoopToolsAddon(deps);
- expect(result.suffix).toContain('Loop management tools');
- expect(result.suffix).toContain('createLoop');
- expect(result.suffix).toContain('cancelLoop');
- expect(result.suffix).toContain('listLoops');
- expect(result.suffix).toContain('/loop skill');
- });
-
- it('instructs leading interval loop requests to prefer createLoop over scheduleWakeup', () => {
- const deps = makeMockLoopToolDeps();
- const result = buildLoopToolsAddon(deps);
-
- expect(result.suffix).toContain('fixed-interval');
- expect(result.suffix).toContain('call `createLoop`');
- expect(result.suffix).toContain('Do not use `scheduleWakeup` for this pattern');
+ // The loop-tool prompt guidance was intentionally removed; the tools are
+ // still wired (asserted above), but no suffix text is appended.
+ expect(result.suffix).toBe('');
});
});
diff --git a/packages/coc/test/spa/react/RepoDetail.test.ts b/packages/coc/test/spa/react/RepoDetail.test.ts
index 379fed846..a861895ec 100644
--- a/packages/coc/test/spa/react/RepoDetail.test.ts
+++ b/packages/coc/test/spa/react/RepoDetail.test.ts
@@ -12,6 +12,14 @@ const REPO_DETAIL_SOURCE = fs.readFileSync(
'utf-8',
);
+// The sub-tab taxonomy and visibility logic were extracted into repoSubTabs.ts
+// (shared with the remote-first shell). Source-level assertions about that logic
+// read from this file.
+const REPO_SUB_TABS_SOURCE = fs.readFileSync(
+ path.join(__dirname, '..', '..', '..', 'src', 'server', 'spa', 'client', 'react', 'features', 'repo-detail', 'repoSubTabs.ts'),
+ 'utf-8',
+);
+
describe('RepoDetail SUB_TABS', () => {
it('includes a "chats" entry', () => {
const chatsTab = SUB_TABS.find(t => t.key === 'chats');
@@ -88,11 +96,11 @@ describe('RepoDetail CLI Sessions placement (between Activity and Git)', () => {
it('groups cli-sessions with the activity/git/terminal divider group (group 1)', () => {
// TAB_GROUP_INDEX is not exported; assert via source so cli-sessions does
// not render as a divider-flanked island between Activity and Git.
- expect(REPO_DETAIL_SOURCE).toContain(
+ expect(REPO_SUB_TABS_SOURCE).toContain(
"'chats': 1, 'activity': 1, 'cli-sessions': 1, 'copilot-sessions': 1, 'git': 1, 'terminal': 1,",
);
// cli-sessions / copilot-sessions must no longer be in the work-items group.
- const workItemsGroupLine = REPO_DETAIL_SOURCE
+ const workItemsGroupLine = REPO_SUB_TABS_SOURCE
.split('\n')
.find(l => l.includes("'work-items': 2"));
expect(workItemsGroupLine).toBeDefined();
@@ -101,7 +109,7 @@ describe('RepoDetail CLI Sessions placement (between Activity and Git)', () => {
});
it('dev-workflow order places cli-sessions immediately after chats', () => {
- const devOrderMatch = REPO_DETAIL_SOURCE.match(/devWorkflowOrder.*?=\s*\[([\s\S]*?)\]/);
+ const devOrderMatch = REPO_SUB_TABS_SOURCE.match(/devWorkflowOrder.*?=\s*\[([\s\S]*?)\]/);
expect(devOrderMatch).toBeTruthy();
const keys = devOrderMatch![1].match(/'([^']+)'/g)!.map(k => k.replace(/'/g, ''));
expect(keys[0]).toBe('chats');
@@ -142,7 +150,7 @@ describe('RepoDetail Dreams tab feature gating', () => {
});
it('filters dreams tab from visibleSubTabs when disabled', () => {
- expect(REPO_DETAIL_SOURCE).toContain("t.key !== 'dreams'");
+ expect(REPO_SUB_TABS_SOURCE).toContain("t.key !== 'dreams'");
});
it('visibleSubTabs depends on dreamsEnabled', () => {
@@ -771,38 +779,38 @@ describe('RepoDetail PullRequestsTab always-mounted', () => {
describe('RepoDetail dev-workflow tab relabeling and reorder', () => {
it('dev-workflow branch relabels schedules to "Jobs"', () => {
- expect(REPO_DETAIL_SOURCE).toContain("'schedules': 'Jobs'");
+ expect(REPO_SUB_TABS_SOURCE).toContain("'schedules': 'Jobs'");
});
it('dev-workflow branch relabels pull-requests to "Full Requests"', () => {
- expect(REPO_DETAIL_SOURCE).toContain("'pull-requests': 'Full Requests'");
+ expect(REPO_SUB_TABS_SOURCE).toContain("'pull-requests': 'Full Requests'");
});
it('dev-workflow branch defines the correct tab order', () => {
- expect(REPO_DETAIL_SOURCE).toContain(
+ expect(REPO_SUB_TABS_SOURCE).toContain(
"'chats', 'cli-sessions', 'work-items', 'dreams', 'schedules', 'explorer',",
);
- expect(REPO_DETAIL_SOURCE).toContain(
+ expect(REPO_SUB_TABS_SOURCE).toContain(
"'workflows', 'git', 'terminal', 'pull-requests', 'tasks', 'settings',",
);
});
it('classic branch does NOT apply dev-workflow relabels', () => {
// Classic branch relabels Tasks as Plans, not Jobs/Full Requests
- const classicBlock = REPO_DETAIL_SOURCE.split("if (uiLayoutMode === 'classic')")[1]?.split('} else {')[0] ?? '';
+ const classicBlock = REPO_SUB_TABS_SOURCE.split("if (uiLayoutMode === 'classic')")[1]?.split('} else {')[0] ?? '';
expect(classicBlock).not.toContain("'Jobs'");
expect(classicBlock).not.toContain("'Full Requests'");
});
it('dev-workflow appends dynamic tabs after the fixed order', () => {
// The else branch must iterate tabMap leftovers (notes, wiki) after the ordered array
- expect(REPO_DETAIL_SOURCE).toContain("// Append dynamic tabs");
- expect(REPO_DETAIL_SOURCE).toContain("for (const [, tab] of tabMap)");
+ expect(REPO_SUB_TABS_SOURCE).toContain("// Append dynamic tabs");
+ expect(REPO_SUB_TABS_SOURCE).toContain("for (const [, tab] of tabMap)");
});
it('tab keys are unchanged — only labels differ', () => {
// devWorkflowOrder uses the same keys as SUB_TABS
- const devOrderMatch = REPO_DETAIL_SOURCE.match(/devWorkflowOrder.*?=\s*\[([\s\S]*?)\]/);
+ const devOrderMatch = REPO_SUB_TABS_SOURCE.match(/devWorkflowOrder.*?=\s*\[([\s\S]*?)\]/);
expect(devOrderMatch).toBeTruthy();
const keys = devOrderMatch![1].match(/'([^']+)'/g)!.map(k => k.replace(/'/g, ''));
for (const key of keys) {
diff --git a/packages/coc/test/spa/react/remote-shell/RemoteSubBar.test.tsx b/packages/coc/test/spa/react/remote-shell/RemoteSubBar.test.tsx
new file mode 100644
index 000000000..496e92781
--- /dev/null
+++ b/packages/coc/test/spa/react/remote-shell/RemoteSubBar.test.tsx
@@ -0,0 +1,112 @@
+/**
+ * RemoteSubBar — component tests.
+ *
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+
+const mockSelectClone = vi.fn();
+const mockSwitchSubTab = vi.fn();
+const mockQueueDispatch = vi.fn();
+const mockWorkItemDispatch = vi.fn();
+let mockAppState: any = { activeRepoSubTab: 'chats' };
+let mockQueueState: any = { repoQueueMap: {} };
+let mockWorkItemState: any = { unseenByRepo: {} };
+let mockQueueStats: any = { running: 0, queued: 0 };
+let mockGitInfo: any = { ahead: 0, behind: 0 };
+
+vi.mock('../../../../src/server/spa/client/react/contexts/AppContext', () => ({ useApp: () => ({ state: mockAppState, dispatch: vi.fn() }) }));
+vi.mock('../../../../src/server/spa/client/react/contexts/QueueContext', () => ({ useQueue: () => ({ state: mockQueueState, dispatch: mockQueueDispatch }) }));
+vi.mock('../../../../src/server/spa/client/react/contexts/WorkItemContext', () => ({ useWorkItems: () => ({ state: mockWorkItemState, dispatch: mockWorkItemDispatch }) }));
+vi.mock('../../../../src/server/spa/client/react/hooks/feature-flags/useTerminalEnabled', () => ({ useTerminalEnabled: () => true }));
+vi.mock('../../../../src/server/spa/client/react/features/notes/hooks/useNotesEnabled', () => ({ useNotesEnabled: () => true }));
+vi.mock('../../../../src/server/spa/client/react/hooks/feature-flags/useWorkflowsEnabled', () => ({ useWorkflowsEnabled: () => true }));
+vi.mock('../../../../src/server/spa/client/react/hooks/feature-flags/usePullRequestsEnabled', () => ({ usePullRequestsEnabled: () => true }));
+vi.mock('../../../../src/server/spa/client/react/hooks/feature-flags/useDreamsEnabled', () => ({ useDreamsEnabled: () => true }));
+vi.mock('../../../../src/server/spa/client/react/hooks/feature-flags/useNativeCliSessionsEnabled', () => ({ useNativeCliSessionsEnabled: () => true }));
+vi.mock('../../../../src/server/spa/client/react/hooks/preferences/useUiLayoutMode', () => ({ useUiLayoutMode: () => ['dev-workflow', vi.fn()] }));
+vi.mock('../../../../src/server/spa/client/react/queue/hooks/useRepoQueueStats', () => ({ useRepoQueueStats: () => mockQueueStats, isHidden: () => false }));
+vi.mock('../../../../src/server/spa/client/react/features/git/hooks/useGitInfo', () => ({ useGitInfo: () => mockGitInfo }));
+vi.mock('../../../../src/server/spa/client/react/features/remote-shell/useShellNavigation', () => ({
+ useShellNavigation: () => ({ selectClone: mockSelectClone, switchSubTab: mockSwitchSubTab }),
+}));
+
+import { RemoteSubBar } from '../../../../src/server/spa/client/react/features/remote-shell/RemoteSubBar';
+
+const SHORTCUTS = 'https://github.com/acme/shortcuts.git';
+const repo = (id: string, name: string, branch = 'main') => ({
+ workspace: { id, name, color: '#0078d4', remoteUrl: SHORTCUTS, rootPath: `/r/${id}` },
+ gitInfo: { isGitRepo: true, branch, dirty: false, remoteUrl: SHORTCUTS },
+});
+
+const renderBar = () => {
+ const repos = [repo('a', 'shortcuts'), repo('b', 'shortcuts-2', 'feat/x')];
+ return render(
);
+};
+
+beforeEach(() => {
+ cleanup();
+ mockSelectClone.mockReset();
+ mockSwitchSubTab.mockReset();
+ mockQueueDispatch.mockReset();
+ mockWorkItemDispatch.mockReset();
+ mockAppState = { activeRepoSubTab: 'chats' };
+ mockQueueState = { repoQueueMap: {} };
+ mockWorkItemState = { unseenByRepo: {} };
+ mockQueueStats = { running: 0, queued: 0 };
+ mockGitInfo = { ahead: 0, behind: 0 };
+});
+
+describe('RemoteSubBar', () => {
+ it('keeps Work Items + Pull Requests in the remote scope', () => {
+ renderBar();
+ const remoteTabs = screen.getAllByTestId('remote-scope-tab').map(el => el.getAttribute('data-subtab'));
+ expect(remoteTabs).toEqual(['work-items', 'pull-requests']);
+ });
+
+ it('shows every non-remote tab in the clone scope, inline, when width is unconstrained', () => {
+ renderBar();
+ const cloneTabs = screen.getAllByTestId('clone-scope-tab').map(el => el.getAttribute('data-subtab'));
+ // jsdom reports no layout width → nothing overflows → all clone tabs render inline.
+ expect(cloneTabs).toEqual(['chats', 'cli-sessions', 'dreams', 'schedules', 'explorer', 'workflows', 'git', 'terminal', 'tasks', 'settings', 'notes']);
+ // No remote-scope tabs leak into the clone scope.
+ expect(cloneTabs).not.toContain('work-items');
+ expect(cloneTabs).not.toContain('pull-requests');
+ // Nothing is forced into an overflow when everything fits.
+ expect(screen.queryByTestId('subbar-overflow-toggle')).toBeNull();
+ });
+
+ it('switches sub-tab when a clone tab is clicked', () => {
+ renderBar();
+ const git = screen.getAllByTestId('clone-scope-tab').find(el => el.getAttribute('data-subtab') === 'git')!;
+ fireEvent.click(git);
+ expect(mockSwitchSubTab).toHaveBeenCalledWith('git');
+ });
+
+ it('opens the clone popover and selects another clone', () => {
+ renderBar();
+ const sw = screen.getByTestId('clone-switch');
+ expect(sw.textContent).toContain('shortcuts');
+ expect(sw.textContent).toContain('· 2'); // two clones
+ fireEvent.click(sw);
+ const items = screen.getAllByTestId('clone-popover-item');
+ expect(items).toHaveLength(2);
+ fireEvent.click(items[1]);
+ expect(mockSelectClone).toHaveBeenCalledWith('b');
+ });
+
+ it('queues and asks against the active clone', () => {
+ renderBar();
+ fireEvent.click(screen.getByTestId('subbar-ask'));
+ expect(mockQueueDispatch).toHaveBeenCalledWith({ type: 'OPEN_DIALOG', workspaceId: 'a', mode: 'ask' });
+ fireEvent.click(screen.getByTestId('subbar-queue'));
+ expect(mockQueueDispatch).toHaveBeenCalledWith({ type: 'OPEN_DIALOG', workspaceId: 'a' });
+ });
+
+ it('shows a running badge on the activity/chats tab from queue stats', () => {
+ mockQueueStats = { running: 2, queued: 0 };
+ renderBar();
+ expect(screen.getByTestId('subbar-running-badge').textContent).toBe('2');
+ });
+});
diff --git a/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx b/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx
new file mode 100644
index 000000000..75627c42a
--- /dev/null
+++ b/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx
@@ -0,0 +1,128 @@
+/**
+ * RemoteTopBar — component tests.
+ *
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+
+const mockSelectClone = vi.fn();
+let mockAppState: any = { selectedRepoId: null };
+let mockQueueState: any = { repoQueueMap: {} };
+let mockRepos: any[] = [];
+let mockUnseen: Record
= {};
+
+vi.mock('../../../../src/server/spa/client/react/contexts/AppContext', () => ({
+ useApp: () => ({ state: mockAppState, dispatch: vi.fn() }),
+}));
+vi.mock('../../../../src/server/spa/client/react/contexts/QueueContext', () => ({
+ useQueue: () => ({ state: mockQueueState, dispatch: vi.fn() }),
+}));
+vi.mock('../../../../src/server/spa/client/react/contexts/ReposContext', () => ({
+ useRepos: () => ({ repos: mockRepos, unseenCounts: mockUnseen, fetchRepos: vi.fn() }),
+}));
+vi.mock('../../../../src/server/spa/client/react/features/remote-shell/useShellNavigation', () => ({
+ useShellNavigation: () => ({ selectClone: mockSelectClone, switchSubTab: vi.fn() }),
+}));
+vi.mock('../../../../src/server/spa/client/react/repos/CloneRepoDialog', () => ({
+ CloneRepoDialog: ({ open }: { open: boolean }) => (open ? : null),
+}));
+vi.mock('../../../../src/server/spa/client/react/repos/AddFolderDialog', () => ({
+ AddFolderDialog: ({ open }: { open: boolean }) => (open ? : null),
+}));
+vi.mock('../../../../src/server/spa/client/react/repos/AddRepoDialog', () => ({
+ AddRepoDialog: ({ open }: { open: boolean }) => (open ? : null),
+}));
+
+import { RemoteTopBar } from '../../../../src/server/spa/client/react/features/remote-shell/RemoteTopBar';
+
+const repo = (id: string, name: string, remoteUrl: string, color = '#123456') => ({
+ workspace: { id, name, color, remoteUrl, rootPath: `/r/${id}` },
+ gitInfo: { isGitRepo: true, branch: 'main', dirty: false, remoteUrl },
+});
+
+const SHORTCUTS = 'https://github.com/acme/shortcuts.git';
+const FORGE = 'https://github.com/acme/forge.git';
+
+beforeEach(() => {
+ cleanup();
+ mockSelectClone.mockReset();
+ mockAppState = { selectedRepoId: null };
+ mockQueueState = { repoQueueMap: {} };
+ mockUnseen = {};
+});
+
+describe('RemoteTopBar', () => {
+ it('renders one tab per remote — clones of the same origin collapse', () => {
+ mockRepos = [
+ repo('a', 'shortcuts', SHORTCUTS),
+ repo('b', 'shortcuts-2', SHORTCUTS),
+ repo('c', 'forge', FORGE),
+ ];
+ render();
+ expect(screen.getAllByTestId('remote-tab')).toHaveLength(2);
+ });
+
+ it('shows a clone-count chip only when a remote has more than one clone', () => {
+ mockRepos = [
+ repo('a', 'shortcuts', SHORTCUTS),
+ repo('b', 'shortcuts-2', SHORTCUTS),
+ repo('c', 'forge', FORGE),
+ ];
+ render();
+ const counts = screen.getAllByTestId('remote-clone-count');
+ expect(counts).toHaveLength(1);
+ expect(counts[0].textContent).toContain('2');
+ });
+
+ it('aggregates unseen counts across clones and shows a running pulse', () => {
+ mockRepos = [repo('a', 'shortcuts', SHORTCUTS), repo('b', 'shortcuts-2', SHORTCUTS)];
+ mockUnseen = { a: 3, b: 5 };
+ mockQueueState = { repoQueueMap: { a: { running: [{}], queued: [] } } };
+ render();
+ expect(screen.getByTestId('remote-unseen-badge').textContent).toBe('8');
+ expect(screen.getByTestId('remote-running-pulse')).toBeTruthy();
+ });
+
+ it('selects the first clone of a remote on click', () => {
+ mockRepos = [repo('a', 'shortcuts', SHORTCUTS), repo('b', 'shortcuts-2', SHORTCUTS)];
+ render();
+ fireEvent.click(screen.getAllByTestId('remote-tab')[0]);
+ expect(mockSelectClone).toHaveBeenCalledWith('a');
+ });
+
+ it('marks the remote containing the selected clone as active', () => {
+ mockRepos = [repo('a', 'shortcuts', SHORTCUTS), repo('c', 'forge', FORGE)];
+ mockAppState = { selectedRepoId: 'c' };
+ render();
+ const active = screen.getAllByTestId('remote-tab').find(el => el.getAttribute('data-active') === 'true');
+ expect(active).toBeTruthy();
+ expect(active!.getAttribute('data-remote-key')).toContain('forge');
+ });
+
+ it('exposes a top-level add menu with folder / repo / clone options', () => {
+ mockRepos = [repo('a', 'shortcuts', SHORTCUTS)];
+ render();
+ expect(screen.queryByTestId('remote-add-menu')).toBeNull();
+ fireEvent.click(screen.getByTestId('remote-add-btn'));
+ expect(screen.getByTestId('remote-add-folder-option')).toBeTruthy();
+ expect(screen.getByTestId('remote-add-repo-option')).toBeTruthy();
+ expect(screen.getByTestId('remote-clone-repo-option')).toBeTruthy();
+ });
+
+ it('adds an existing folder from the top-level menu', () => {
+ mockRepos = [repo('a', 'shortcuts', SHORTCUTS)];
+ render();
+ fireEvent.click(screen.getByTestId('remote-add-btn'));
+ fireEvent.click(screen.getByTestId('remote-add-folder-option'));
+ expect(screen.getByTestId('add-folder-dialog')).toBeTruthy();
+ });
+
+ it('clones a repository from the top-level menu', () => {
+ mockRepos = [repo('a', 'shortcuts', SHORTCUTS)];
+ render();
+ fireEvent.click(screen.getByTestId('remote-add-btn'));
+ fireEvent.click(screen.getByTestId('remote-clone-repo-option'));
+ expect(screen.getByTestId('clone-repo-dialog')).toBeTruthy();
+ });
+});
diff --git a/packages/coc/test/spa/react/remote-shell/repoSubTabs.test.ts b/packages/coc/test/spa/react/remote-shell/repoSubTabs.test.ts
new file mode 100644
index 000000000..548f96676
--- /dev/null
+++ b/packages/coc/test/spa/react/remote-shell/repoSubTabs.test.ts
@@ -0,0 +1,70 @@
+/**
+ * repoSubTabs — unit tests for the extracted sub-tab visibility logic.
+ * Guards that the shared helper behaves exactly like the logic previously
+ * inlined in RepoDetail (feature-flag gating, git gating, layout relabel/reorder).
+ */
+import { describe, it, expect } from 'vitest';
+import {
+ computeVisibleSubTabs,
+ SUB_TABS,
+ VISIBLE_SUB_TABS,
+ type VisibleSubTabOptions,
+} from '../../../../src/server/spa/client/react/features/repo-detail/repoSubTabs';
+
+const allOn: VisibleSubTabOptions = {
+ isGitRepo: true,
+ terminalEnabled: true,
+ notesEnabled: true,
+ workflowsEnabled: true,
+ pullRequestsEnabled: true,
+ dreamsEnabled: true,
+ nativeCliSessionsEnabled: true,
+ uiLayoutMode: 'dev-workflow',
+};
+
+describe('VISIBLE_SUB_TABS', () => {
+ it('hides wiki by default but keeps it in the full SUB_TABS list', () => {
+ expect(VISIBLE_SUB_TABS.find(t => t.key === 'wiki')).toBeUndefined();
+ expect(SUB_TABS.find(t => t.key === 'wiki')).toBeDefined();
+ });
+});
+
+describe('computeVisibleSubTabs', () => {
+ it('classic mode replaces chats with activity and relabels tasks', () => {
+ const tabs = computeVisibleSubTabs({ ...allOn, uiLayoutMode: 'classic' });
+ expect(tabs.find(t => t.key === 'chats')).toBeUndefined();
+ expect(tabs.find(t => t.key === 'activity')?.label).toBe('Activity');
+ expect(tabs.find(t => t.key === 'tasks')?.label).toBe('Plans (Dep.)');
+ });
+
+ it('dev-workflow mode reorders chats first and relabels PRs / schedules', () => {
+ const tabs = computeVisibleSubTabs(allOn);
+ expect(tabs[0].key).toBe('chats');
+ expect(tabs.find(t => t.key === 'pull-requests')?.label).toBe('Full Requests');
+ expect(tabs.find(t => t.key === 'schedules')?.label).toBe('Jobs');
+ });
+
+ it('hides git and pull-requests for a non-git repo', () => {
+ const tabs = computeVisibleSubTabs({ ...allOn, isGitRepo: false });
+ expect(tabs.find(t => t.key === 'git')).toBeUndefined();
+ expect(tabs.find(t => t.key === 'pull-requests')).toBeUndefined();
+ });
+
+ it('gates tabs behind their feature flags', () => {
+ const tabs = computeVisibleSubTabs({
+ ...allOn,
+ terminalEnabled: false,
+ notesEnabled: false,
+ workflowsEnabled: false,
+ pullRequestsEnabled: false,
+ dreamsEnabled: false,
+ nativeCliSessionsEnabled: false,
+ });
+ for (const key of ['terminal', 'notes', 'workflows', 'pull-requests', 'dreams', 'cli-sessions', 'copilot-sessions']) {
+ expect(tabs.find(t => t.key === key)).toBeUndefined();
+ }
+ // Non-gated tabs survive.
+ expect(tabs.find(t => t.key === 'work-items')).toBeDefined();
+ expect(tabs.find(t => t.key === 'git')).toBeDefined();
+ });
+});
diff --git a/packages/coc/test/spa/react/remote-shell/shellModel.test.ts b/packages/coc/test/spa/react/remote-shell/shellModel.test.ts
new file mode 100644
index 000000000..14a84b79f
--- /dev/null
+++ b/packages/coc/test/spa/react/remote-shell/shellModel.test.ts
@@ -0,0 +1,133 @@
+/**
+ * shellModel — unit tests for the pure remote-first shell helpers.
+ */
+import { describe, it, expect } from 'vitest';
+import {
+ partitionShellTabs,
+ computeVisibleTabKeys,
+ computeCloneStatusMap,
+ cloneStatusColor,
+ summarizeRemote,
+ REMOTE_SCOPE_KEYS,
+} from '../../../../src/server/spa/client/react/features/remote-shell/shellModel';
+import type { SubTabDef } from '../../../../src/server/spa/client/react/features/repo-detail/repoSubTabs';
+import type { RepoData, RepoGroup } from '../../../../src/server/spa/client/react/repos/repoGrouping';
+import type { RepoSubTab } from '../../../../src/server/spa/client/react/types/dashboard';
+
+const tab = (key: RepoSubTab, label = key): SubTabDef => ({ key, label });
+const repo = (id: string, color?: string): RepoData =>
+ ({ workspace: { id, name: id, color, rootPath: `/r/${id}` } } as RepoData);
+
+describe('scope key sets', () => {
+ it('declares Work Items + Pull Requests as remote-scoped', () => {
+ expect([...REMOTE_SCOPE_KEYS]).toEqual(['work-items', 'pull-requests']);
+ });
+});
+
+describe('partitionShellTabs', () => {
+ it('splits remote-scope (stable order) from all other clone tabs (source order)', () => {
+ const tabs = [
+ tab('activity'), tab('cli-sessions'), tab('git'), tab('terminal'),
+ tab('work-items'), tab('pull-requests'),
+ tab('explorer'), tab('schedules'), tab('settings'),
+ ];
+ const { remote, clone } = partitionShellTabs(tabs);
+ expect(remote.map(t => t.key)).toEqual(['work-items', 'pull-requests']);
+ // Every non-remote tab is clone-scoped, in source order (no fixed overflow).
+ expect(clone.map(t => t.key)).toEqual(['activity', 'cli-sessions', 'git', 'terminal', 'explorer', 'schedules', 'settings']);
+ });
+
+ it('omits remote tabs that are not present (e.g. non-git repo)', () => {
+ const { remote, clone } = partitionShellTabs([tab('activity'), tab('work-items'), tab('explorer')]);
+ expect(remote.map(t => t.key)).toEqual(['work-items']);
+ expect(clone.map(t => t.key)).toEqual(['activity', 'explorer']);
+ });
+
+ it('preserves relabeled tab definitions in the remote bucket', () => {
+ const { remote } = partitionShellTabs([tab('work-items', 'Work Items'), tab('pull-requests', 'Full Requests')]);
+ expect(remote.map(t => t.label)).toEqual(['Work Items', 'Full Requests']);
+ });
+});
+
+describe('computeVisibleTabKeys', () => {
+ const m = (key: string, width: number) => ({ key, width });
+
+ it('returns null (show all) when there is no layout width', () => {
+ expect(computeVisibleTabKeys([m('a', 50)], 0, 'a')).toBeNull();
+ });
+
+ it('returns null (show all) when everything fits', () => {
+ expect(computeVisibleTabKeys([m('a', 40), m('b', 40)], 500, null, 0)).toBeNull();
+ });
+
+ it('keeps only the tabs that fit, in order', () => {
+ const v = computeVisibleTabKeys([m('a', 40), m('b', 40), m('c', 40)], 90, null, 0);
+ expect(v && [...v]).toEqual(['a', 'b']); // 40 + 40 = 80 ≤ 90; third would be 120
+ });
+
+ it('always keeps the active tab visible, swapping out the last fitting tab', () => {
+ const v = computeVisibleTabKeys([m('a', 40), m('b', 40), m('c', 40)], 90, 'c', 0);
+ expect(v && [...v].sort()).toEqual(['a', 'c']);
+ });
+
+ it('accounts for the inter-tab gap', () => {
+ // 40+gap(10) twice = 100 > 95 → only the first fits
+ const v = computeVisibleTabKeys([m('a', 40), m('b', 40)], 95, null, 10);
+ expect(v && [...v]).toEqual(['a']);
+ });
+});
+
+describe('computeCloneStatusMap', () => {
+ const noneHidden = () => false;
+
+ it('classifies running / queued / paused / idle (running wins)', () => {
+ const repos = [repo('a'), repo('b'), repo('c'), repo('d')];
+ const map = computeCloneStatusMap(repos, {
+ a: { running: [{}], queued: [{}] },
+ b: { running: [], queued: [{}] },
+ c: { stats: { isPaused: true }, running: [{}], queued: [] },
+ // d: absent entirely
+ }, noneHidden);
+ expect(map).toEqual({ a: 'running', b: 'queued', c: 'paused', d: 'idle' });
+ });
+
+ it('respects the isHidden filter when counting running tasks', () => {
+ const map = computeCloneStatusMap([repo('a')], { a: { running: [{ hidden: true }], queued: [] } }, (t: any) => t.hidden);
+ expect(map.a).toBe('idle');
+ });
+});
+
+describe('cloneStatusColor', () => {
+ it('maps statuses and falls back for idle/unknown', () => {
+ expect(cloneStatusColor('running', '#000')).toBe('#16a34a');
+ expect(cloneStatusColor('queued', '#000')).toBe('#c98410');
+ expect(cloneStatusColor('paused', '#000')).toBe('#f14c4c');
+ expect(cloneStatusColor('idle', '#abc')).toBe('#abc');
+ expect(cloneStatusColor(undefined, '#abc')).toBe('#abc');
+ });
+});
+
+describe('summarizeRemote', () => {
+ const group = (repos: RepoData[]): RepoGroup =>
+ ({ normalizedUrl: 'github.com/acme/shortcuts', label: 'acme/shortcuts', repos, expanded: true });
+
+ it('aggregates status, unseen, clone count, color and short name', () => {
+ const g = group([repo('a', '#111'), repo('b', '#222'), repo('c')]);
+ const s = summarizeRemote(g, { a: 'idle', b: 'queued', c: 'running' }, { a: 2, b: 0, c: 5 });
+ expect(s.status).toBe('running');
+ expect(s.unseen).toBe(7);
+ expect(s.cloneCount).toBe(3);
+ expect(s.color).toBe('#111');
+ expect(s.name).toBe('shortcuts');
+ });
+
+ it('reports queued when no clone is running', () => {
+ const g = group([repo('a'), repo('b')]);
+ expect(summarizeRemote(g, { a: 'idle', b: 'queued' }, {}).status).toBe('queued');
+ });
+
+ it('uses the whole label as the name when there is no owner/ prefix', () => {
+ const g: RepoGroup = { normalizedUrl: null, label: 'my-repo', repos: [repo('a')], expanded: true };
+ expect(summarizeRemote(g, {}, {}).name).toBe('my-repo');
+ });
+});
diff --git a/packages/coc/test/spa/react/remote-shell/useRemoteShellEnabled.test.ts b/packages/coc/test/spa/react/remote-shell/useRemoteShellEnabled.test.ts
new file mode 100644
index 000000000..88889543a
--- /dev/null
+++ b/packages/coc/test/spa/react/remote-shell/useRemoteShellEnabled.test.ts
@@ -0,0 +1,36 @@
+/**
+ * useRemoteShellEnabled / isRemoteShellEnabled — tests for the global admin
+ * `features.remoteShell` flag read path.
+ *
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { applyRuntimeConfigPatch, isRemoteShellEnabled } from '../../../../src/server/spa/client/react/utils/config';
+import { useRemoteShellEnabled } from '../../../../src/server/spa/client/react/hooks/feature-flags/useRemoteShellEnabled';
+
+describe('remote-first shell feature flag', () => {
+ beforeEach(() => {
+ applyRuntimeConfigPatch({ remoteShellEnabled: false });
+ });
+
+ it('defaults to disabled', () => {
+ expect(isRemoteShellEnabled()).toBe(false);
+ });
+
+ it('isRemoteShellEnabled reflects the runtime flag', () => {
+ applyRuntimeConfigPatch({ remoteShellEnabled: true });
+ expect(isRemoteShellEnabled()).toBe(true);
+ applyRuntimeConfigPatch({ remoteShellEnabled: false });
+ expect(isRemoteShellEnabled()).toBe(false);
+ });
+
+ it('useRemoteShellEnabled reads the flag and reacts to runtime config updates', () => {
+ const { result } = renderHook(() => useRemoteShellEnabled());
+ expect(result.current).toBe(false);
+ act(() => { applyRuntimeConfigPatch({ remoteShellEnabled: true }); });
+ expect(result.current).toBe(true);
+ act(() => { applyRuntimeConfigPatch({ remoteShellEnabled: false }); });
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/packages/coc/test/spa/react/repos/RepoDetail-git-visibility.test.ts b/packages/coc/test/spa/react/repos/RepoDetail-git-visibility.test.ts
index 9bd932353..56ac3f441 100644
--- a/packages/coc/test/spa/react/repos/RepoDetail-git-visibility.test.ts
+++ b/packages/coc/test/spa/react/repos/RepoDetail-git-visibility.test.ts
@@ -20,6 +20,13 @@ const REPO_DETAIL_SOURCE = fs.readFileSync(
'utf-8',
);
+// Sub-tab visibility filtering lives in repoSubTabs.ts (shared with the
+// remote-first shell); source assertions about the filters read from here.
+const REPO_SUB_TABS_SOURCE = fs.readFileSync(
+ path.join(__dirname, '..', '..', '..', '..', 'src', 'server', 'spa', 'client', 'react', 'features', 'repo-detail', 'repoSubTabs.ts'),
+ 'utf-8',
+);
+
// ── 1. Git tab present in static BASE_VISIBLE_SUB_TABS (for git repos) ──────
describe('Git tab visible for git repos', () => {
@@ -40,13 +47,13 @@ describe('Git tab hidden for non-git repos', () => {
});
it('computes visibleSubTabs by filtering git when !isGitRepo', () => {
- // The useMemo should filter VISIBLE_SUB_TABS based on isGitRepo
- expect(REPO_DETAIL_SOURCE).toContain('VISIBLE_SUB_TABS');
- expect(REPO_DETAIL_SOURCE).toContain("t.key !== 'git'");
+ // The shared helper filters VISIBLE_SUB_TABS based on isGitRepo
+ expect(REPO_SUB_TABS_SOURCE).toContain('VISIBLE_SUB_TABS');
+ expect(REPO_SUB_TABS_SOURCE).toContain("t.key !== 'git'");
});
it('filters out pull-requests tab for non-git repos', () => {
- expect(REPO_DETAIL_SOURCE).toContain("t.key !== 'pull-requests'");
+ expect(REPO_SUB_TABS_SOURCE).toContain("t.key !== 'pull-requests'");
});
it('uses visibleSubTabs (not VISIBLE_SUB_TABS) in the tab strip map', () => {
diff --git a/packages/coc/test/spa/react/repos/RepoDetail-mobile.test.ts b/packages/coc/test/spa/react/repos/RepoDetail-mobile.test.ts
index d05bb76e4..6ddc6f872 100644
--- a/packages/coc/test/spa/react/repos/RepoDetail-mobile.test.ts
+++ b/packages/coc/test/spa/react/repos/RepoDetail-mobile.test.ts
@@ -30,8 +30,9 @@ describe('RepoDetail mobile: imports', () => {
describe('RepoDetail mobile: header layout', () => {
it('desktop header is guarded with !isMobile and uses flex-row layout', () => {
- // Header is desktop-only — not rendered on mobile
- expect(REPO_DETAIL_SOURCE).toContain('!isMobile && (');
+ // Header is desktop-only — not rendered on mobile (and suppressed when the
+ // remote-first shell renders its own RemoteSubBar, hence !chromeless).
+ expect(REPO_DETAIL_SOURCE).toContain('!isMobile && !chromeless && (');
expect(REPO_DETAIL_SOURCE).toContain('repo-detail-header');
// Desktop header always uses flex-row (no mobile variant needed)
expect(REPO_DETAIL_SOURCE).toContain('flex flex-row items-center');
diff --git a/packages/coc/test/spa/react/terminal-tab-integration.test.ts b/packages/coc/test/spa/react/terminal-tab-integration.test.ts
index 854b9d5aa..b3849a13e 100644
--- a/packages/coc/test/spa/react/terminal-tab-integration.test.ts
+++ b/packages/coc/test/spa/react/terminal-tab-integration.test.ts
@@ -25,6 +25,13 @@ const REPO_DETAIL_SOURCE = fs.readFileSync(
'utf-8',
);
+// Sub-tab visibility filtering lives in repoSubTabs.ts (shared with the
+// remote-first shell); source assertions about the filters read from here.
+const REPO_SUB_TABS_SOURCE = fs.readFileSync(
+ path.join(__dirname, '..', '..', '..', 'src', 'server', 'spa', 'client', 'react', 'features', 'repo-detail', 'repoSubTabs.ts'),
+ 'utf-8',
+);
+
// ── Router: VALID_REPO_SUB_TABS ─────────────────────────────────────────────
describe('Router terminal integration', () => {
@@ -84,7 +91,7 @@ describe('RepoDetail terminal visibility gating', () => {
});
it('filters terminal tab from visibleSubTabs when disabled', () => {
- expect(REPO_DETAIL_SOURCE).toContain("t.key !== 'terminal'");
+ expect(REPO_SUB_TABS_SOURCE).toContain("t.key !== 'terminal'");
});
it('visibleSubTabs depends on terminalEnabled', () => {