From aa695ef86a24d38aec8b807f95a87b56da766bb4 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 23:52:39 -0700 Subject: [PATCH 1/9] refactor(coc): streamline imports and clean up unused code in prompt-builder.ts - Removed duplicate import of Tool from coc-agent-sdk. - Consolidated and organized imports for better readability. - Updated return values in several functions to remove unnecessary suffix strings. - Enhanced code clarity by removing commented-out code and unused variables. --- .../src/server/executors/prompt-builder.ts | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/packages/coc/src/server/executors/prompt-builder.ts b/packages/coc/src/server/executors/prompt-builder.ts index a6d9e49e0..3da3be012 100644 --- a/packages/coc/src/server/executors/prompt-builder.ts +++ b/packages/coc/src/server/executors/prompt-builder.ts @@ -8,6 +8,7 @@ * Cross-platform compatible (Linux/Mac/Windows). */ +import type { Tool } from '@plusplusoneplusplus/coc-agent-sdk'; import type { AutoFolderContext, ConversationTurn, @@ -16,7 +17,6 @@ import type { SendMessageOptions, SystemMessageConfig, } from '@plusplusoneplusplus/forge'; -import type { Tool } from '@plusplusoneplusplus/coc-agent-sdk'; import { READ_ONLY_SYSTEM_MESSAGE, buildAutoFolderLocationBlock, @@ -27,14 +27,21 @@ import { } from '@plusplusoneplusplus/forge'; import * as fs from 'fs'; import * as path from 'path'; -import { createSearchConversationsTool } from '../llm-tools/search-conversations-tool'; -import { createGetConversationTool } from '../llm-tools/get-conversation-tool'; -import { createSuggestFollowUpsTool } from '../llm-tools/suggest-follow-ups-tool'; -import { createAskUserTool } from '../llm-tools/ask-user-tool'; +import { CONFIG_FILE_NAME, resolveConfig } from '../../config'; import type { AskUserAnswerInput, AskUserAnswerValue, AskUserToolDeps } from '../llm-tools/ask-user-tool'; +import { createAskUserTool } from '../llm-tools/ask-user-tool'; +import { createCanvasTools } from '../llm-tools/canvas-tools'; import { createCreateUpdateWorkItemTool, type BroadcastWorkItemFn, type CreateUpdateWorkItemToolDeps } from '../llm-tools/create-update-work-item-tool'; +import { createExcalidrawTools } from '../llm-tools/excalidraw-tools'; +import { createGetConversationTool } from '../llm-tools/get-conversation-tool'; import { createGetWorkItemTool } from '../llm-tools/get-work-item-tool'; -import type { ChatMode, ChatPayload, DreamRunPayload, PrClassificationPayload, RunScriptPayload } from '../tasks/task-types'; +import { filterDisabledLlmTools } from '../llm-tools/llm-tool-registry'; +import type { LoopToolDeps } from '../llm-tools/loop-tools'; +import { createCancelLoopTool, createCreateLoopTool, createListLoopsTool, createScheduleWakeupTool } from '../llm-tools/loop-tools'; +import { createSearchConversationsTool } from '../llm-tools/search-conversations-tool'; +import { createSuggestFollowUpsTool } from '../llm-tools/suggest-follow-ups-tool'; +import { createTavilyWebSearchTool } from '../llm-tools/tavily-web-search-tool'; +import type { ChatMode, ChatPayload, DreamRunPayload, ForEachGenerationContext, MapReduceGenerationContext, PrClassificationPayload, RunScriptPayload } from '../tasks/task-types'; import { hasCommitChatContext, hasPullRequestChatContext, @@ -48,14 +55,6 @@ import { normalizeChatMode, resolveInstructionMode, } from '../tasks/task-types'; -import type { ForEachGenerationContext, MapReduceGenerationContext } from '../tasks/task-types'; -import { createTavilyWebSearchTool } from '../llm-tools/tavily-web-search-tool'; -import { createExcalidrawTools } from '../llm-tools/excalidraw-tools'; -import { createCanvasTools } from '../llm-tools/canvas-tools'; -import { resolveConfig, CONFIG_FILE_NAME } from '../../config'; -import { filterDisabledLlmTools } from '../llm-tools/llm-tool-registry'; -import { createScheduleWakeupTool, createCreateLoopTool, createCancelLoopTool, createListLoopsTool } from '../llm-tools/loop-tools'; -import type { LoopToolDeps } from '../llm-tools/loop-tools'; // ============================================================================ @@ -476,11 +475,8 @@ export function buildSearchConversationsAddon( currentProcessId, }); const { tool: getTool } = createGetConversationTool({ store, workspaceId }); - const suffix = - '\n\nconversation-history tools: `search_conversations` (keyword search, or no query + since/until to list recent sessions) ' + - 'and `get_conversation` (full transcript by processId). Use when the user references past discussions.'; - return { tools: [searchTool, getTool], suffix }; + return { tools: [searchTool, getTool], suffix: '' }; } // ============================================================================ @@ -516,7 +512,7 @@ export function buildAskUserAddon( answerQuestion: () => false, skipQuestion: () => false, answerQuestions: () => false, - cancelAll: () => {}, + cancelAll: () => { }, hasPending: () => false, }; } @@ -632,12 +628,7 @@ export function buildScheduleWakeupAddon( } const { tool } = createScheduleWakeupTool(deps); - const suffix = - '\n\nYou have access to the `scheduleWakeup` tool. ' + - 'Use it to schedule a one-shot delayed follow-up message into the current conversation. ' + - 'Specify a delay like "30s", "5m", or "1h". Minimum delay is 1 second.'; - - return { tools: [tool], suffix }; + return { tools: [tool], suffix: '' }; } // ============================================================================ @@ -655,12 +646,7 @@ export function buildLoopToolsAddon( const { tool: cancelTool } = createCancelLoopTool(deps); const { tool: listTool } = createListLoopsTool(deps); - const suffix = - '\n\nLoop management tools (`createLoop`, `cancelLoop`, `listLoops`) are active via the /loop skill. ' + - 'When a message starts with an interval + task (e.g. "1m check status"), treat it as a fixed-interval ' + - 'loop: perform the task now, then call `createLoop`. Do not use `scheduleWakeup` for this pattern.'; - - return { tools: [createTool, cancelTool, listTool], suffix }; + return { tools: [createTool, cancelTool, listTool], suffix: '' }; } // ============================================================================ From f210cdf82d726d950da65368dcbed3cfd8c2b23f Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sun, 14 Jun 2026 13:22:47 -0700 Subject: [PATCH 2/9] feat(coc): remote-first 2-row dashboard shell (flag-gated) Implements the "CoC Shell Redesign" top navigation as an opt-in, remote-first shell. Default off; toggled via repo Settings -> Remote Shell (localStorage `coc-remote-shell-enabled`). Desktop only; classic nav is unchanged when the flag is off. - RemoteTopBar (row 1): one tab per git remote via groupReposByRemote, with a clone-count chip, aggregate running pulse, and summed unseen badge. - RemoteSubBar (row 2): remote-scope (Work Items, Pull Requests) | clone switcher popover | clone-scope tabs (Activity/Chats, CLI Sessions, Git, Terminal) | ... overflow (Explorer, Schedules, ...) | compact Ask/Queue. - Reuses existing AppContext/Queue/Repos selection and Router hash routing; renders above a chromeless RepoDetail body. - Extracts RepoDetail's sub-tab visibility logic into repoSubTabs.ts (computeVisibleSubTabs) shared by both surfaces; moves SHOW_WIKI_TAB / SHOW_MEMORY_TAB into featureFlags.ts (re-exported from TopBar). Tests: 33 new unit/component tests (shell model, tab partition, flag hook, RemoteTopBar, RemoteSubBar); updated 3 source-scraping tests for the relocated tab logic. tsc, eslint, and client build all clean. Co-Authored-By: Claude Opus 4.8 --- .../coc-knowledge/references/dashboard-spa.md | 30 ++ .../server/spa/client/react/featureFlags.ts | 6 + .../features/remote-shell/RemoteSubBar.tsx | 333 ++++++++++++++++++ .../features/remote-shell/RemoteTopBar.tsx | 134 +++++++ .../react/features/remote-shell/shellModel.ts | 125 +++++++ .../remote-shell/useShellNavigation.ts | 47 +++ .../react/features/repo-detail/RepoDetail.tsx | 95 +---- .../react/features/repo-detail/repoSubTabs.ts | 117 ++++++ .../repo-settings/RepoPreferencesSection.tsx | 14 + .../react/hooks/preferences/useRemoteShell.ts | 76 ++++ .../server/spa/client/react/layout/TopBar.tsx | 17 +- .../spa/client/react/repos/ReposView.tsx | 15 +- .../coc/test/spa/react/RepoDetail.test.ts | 32 +- .../react/remote-shell/RemoteSubBar.test.tsx | 122 +++++++ .../react/remote-shell/RemoteTopBar.test.tsx | 93 +++++ .../react/remote-shell/repoSubTabs.test.ts | 70 ++++ .../spa/react/remote-shell/shellModel.test.ts | 114 ++++++ .../react/remote-shell/useRemoteShell.test.ts | 52 +++ .../repos/RepoDetail-git-visibility.test.ts | 15 +- .../react/terminal-tab-integration.test.ts | 9 +- 20 files changed, 1412 insertions(+), 104 deletions(-) create mode 100644 packages/coc/src/server/spa/client/react/features/remote-shell/RemoteSubBar.tsx create mode 100644 packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx create mode 100644 packages/coc/src/server/spa/client/react/features/remote-shell/shellModel.ts create mode 100644 packages/coc/src/server/spa/client/react/features/remote-shell/useShellNavigation.ts create mode 100644 packages/coc/src/server/spa/client/react/features/repo-detail/repoSubTabs.ts create mode 100644 packages/coc/src/server/spa/client/react/hooks/preferences/useRemoteShell.ts create mode 100644 packages/coc/test/spa/react/remote-shell/RemoteSubBar.test.tsx create mode 100644 packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx create mode 100644 packages/coc/test/spa/react/remote-shell/repoSubTabs.test.ts create mode 100644 packages/coc/test/spa/react/remote-shell/shellModel.test.ts create mode 100644 packages/coc/test/spa/react/remote-shell/useRemoteShell.test.ts diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index de614bb07..7968f37f7 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -580,6 +580,36 @@ unmounts the embed, and renders the standard admin card content. Each tool's internal sub-tab/hash scheme (e.g. `#skills/installed`, `#logs?sessionId=…`) is unchanged. +### Remote-first shell (experimental) + +An optional two-row navigation mode gated by `useRemoteShell()` +(`hooks/preferences/useRemoteShell.ts`), a localStorage-backed +(`coc-remote-shell-enabled`) module-store flag toggled from the **Remote Shell** +select in `RepoPreferencesSection` (`pref-remote-shell`). Disabled by default; +desktop-only. + +When on, the desktop top nav switches from per-clone repo tabs to a remote-first +model built on `features/remote-shell/`: +- **Row 1 `RemoteTopBar`** replaces `RepoTabStrip` inside `TopBar`. It renders one + tab per remote (origin) via `groupReposByRemote`, with a color dot, clone-count + chip, aggregate running pulse, and summed unseen badge. Selecting a remote picks + its last-used clone (else the first). Aggregation comes from `summarizeRemote` / + `computeCloneStatusMap` in `shellModel.ts`. +- **Row 2 `RemoteSubBar`** renders above a `chromeless` `RepoDetail` in `ReposView` + and replaces RepoDetail's own header. It splits tabs by scope using + `partitionShellTabs`: remote-scoped (Work Items, Pull Requests) on the left, then + a clone-switcher popover (lists the remote's clones; footer opens + `CloneRepoDialog`), the primary clone tabs (Activity/Chats, CLI Sessions, Git, + Terminal), a `…` overflow for the rest (Explorer, Schedules, …), and compact + Ask/Queue buttons targeting the active clone. + +The sub-tab taxonomy and feature-flag/git/layout gating live in +`features/repo-detail/repoSubTabs.ts` (`SUB_TABS`, `VISIBLE_SUB_TABS`, +`TAB_GROUP_INDEX`, `computeVisibleSubTabs`), shared by both `RepoDetail` and the +shell so the two stay behaviorally identical. Selection/routing reuse +`buildRepoSubTabSuffix` via `useShellNavigation`. `SHOW_WIKI_TAB` / `SHOW_MEMORY_TAB` +are defined in `featureFlags.ts` and re-exported from `TopBar` for back-compat. + ## Onboarding - `WelcomeTour`: 5-step full-screen modal (Welcome/Modes/Queue/Multi-repo/Servers) diff --git a/packages/coc/src/server/spa/client/react/featureFlags.ts b/packages/coc/src/server/spa/client/react/featureFlags.ts index 0b98c3d34..604cb0a67 100644 --- a/packages/coc/src/server/spa/client/react/featureFlags.ts +++ b/packages/coc/src/server/spa/client/react/featureFlags.ts @@ -6,6 +6,12 @@ /** Enable the welcome modal, first-steps card, and feature tips. */ export const SHOW_WELCOME_TUTORIAL = true; +/** Set to `true` to re-enable the top-level Wiki tab in navigation. */ +export const SHOW_WIKI_TAB = false; + +/** Set to `true` to re-enable the topbar Memory icon. */ +export const SHOW_MEMORY_TAB = false; + /** Enable the focused-diff classification UI on the PR Files Changed tab. */ export const SHOW_FOCUSED_DIFF = true; diff --git a/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteSubBar.tsx b/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteSubBar.tsx new file mode 100644 index 000000000..d696a58f9 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteSubBar.tsx @@ -0,0 +1,333 @@ +/** + * RemoteSubBar — row 2 of the remote-first shell. + * + * Splits into two scopes: + * • Remote scope (shared across clones): Work Items, Pull Requests — shown once + * and unchanged when you switch clones. + * • Clone scope (follows the active checkout): a clone-switcher popover + the + * primary clone tabs (Activity, CLI Sessions, Git, Terminal). Less-used tabs + * (Explorer, Schedules, …) collapse under a ⋯ overflow. + * + * Compact Ask / Queue actions sit at the right edge, targeting the active clone. + * Rendered above a chromeless RepoDetail in ReposView when the shell is on. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useApp } from '../../contexts/AppContext'; +import { useQueue } from '../../contexts/QueueContext'; +import { useWorkItems } from '../../contexts/WorkItemContext'; +import { useTerminalEnabled } from '../../hooks/feature-flags/useTerminalEnabled'; +import { useNotesEnabled } from '../notes/hooks/useNotesEnabled'; +import { useWorkflowsEnabled } from '../../hooks/feature-flags/useWorkflowsEnabled'; +import { usePullRequestsEnabled } from '../../hooks/feature-flags/usePullRequestsEnabled'; +import { useDreamsEnabled } from '../../hooks/feature-flags/useDreamsEnabled'; +import { useNativeCliSessionsEnabled } from '../../hooks/feature-flags/useNativeCliSessionsEnabled'; +import { useUiLayoutMode } from '../../hooks/preferences/useUiLayoutMode'; +import { useRepoQueueStats, isHidden as isHiddenTask } from '../../queue/hooks/useRepoQueueStats'; +import { useGitInfo } from '../git/hooks/useGitInfo'; +import { computeVisibleSubTabs, type SubTabDef } from '../repo-detail/repoSubTabs'; +import { groupReposByRemote, truncatePath } from '../../repos/repoGrouping'; +import { CloneRepoDialog } from '../../repos/CloneRepoDialog'; +import { + partitionShellTabs, computeCloneStatusMap, cloneStatusColor, summarizeRemote, +} from './shellModel'; +import { useShellNavigation } from './useShellNavigation'; +import type { RepoData } from '../../repos/repoGrouping'; +import type { RepoSubTab } from '../../types/dashboard'; + +interface RemoteSubBarProps { + repo: RepoData; + repos: RepoData[]; + onRefresh: () => void; +} + +function Chevron() { + return ( + + ); +} + +const scopeLabelClass = 'hidden lg:inline-flex items-center text-[9.5px] font-bold uppercase tracking-[0.08em] text-[#848484] dark:text-[#777] px-1 select-none flex-shrink-0'; + +export function RemoteSubBar({ repo, repos, onRefresh }: RemoteSubBarProps) { + const ws = repo.workspace; + const cloneId = String(ws.id); + const { state } = useApp(); + const { state: queueState, dispatch: queueDispatch } = useQueue(); + const { state: workItemState, dispatch: workItemDispatch } = useWorkItems(); + const { selectClone, switchSubTab } = useShellNavigation(); + + const terminalEnabled = useTerminalEnabled(); + const notesEnabled = useNotesEnabled(); + const workflowsEnabled = useWorkflowsEnabled(); + const pullRequestsEnabled = usePullRequestsEnabled(); + const dreamsEnabled = useDreamsEnabled(); + const nativeCliSessionsEnabled = useNativeCliSessionsEnabled(); + const [uiLayoutMode] = useUiLayoutMode(); + const isGitRepo = !!repo.gitInfo?.isGitRepo; + + const { running: runningCount, queued: queuedCount } = useRepoQueueStats(cloneId); + const { ahead: gitAhead, behind: gitBehind } = useGitInfo(cloneId); + const unseenWorkItemCount = (workItemState.unseenByRepo[cloneId] || []).length; + const activeTab = state.activeRepoSubTab; + + const tabs = useMemo(() => computeVisibleSubTabs({ + isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, + pullRequestsEnabled, dreamsEnabled, nativeCliSessionsEnabled, uiLayoutMode, + }), [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, nativeCliSessionsEnabled, uiLayoutMode]); + const { remote: remoteTabs, clone: cloneTabs, overflow: overflowTabs } = useMemo(() => partitionShellTabs(tabs), [tabs]); + const overflowActive = overflowTabs.some(t => t.key === activeTab); + + const group = useMemo(() => { + const groups = groupReposByRemote(repos, {}); + return groups.find(g => g.repos.some(r => String(r.workspace.id) === cloneId)) ?? null; + }, [repos, cloneId]); + const clones = group?.repos ?? [repo]; + const cloneStatus = useMemo( + () => computeCloneStatusMap(repos, queueState.repoQueueMap, isHiddenTask), + [repos, queueState.repoQueueMap], + ); + const remoteColor = (clones[0]?.workspace.color as string) || '#848484'; + const remoteLabel = group ? summarizeRemote(group, cloneStatus, {}).name : ws.name; + const branch = repo.gitInfo?.branch || null; + + const [cloneOpen, setCloneOpen] = useState(false); + const [ovOpen, setOvOpen] = useState(false); + const [cloneDialogOpen, setCloneDialogOpen] = useState(false); + const cloneRef = useRef(null); + const ovRef = useRef(null); + + useEffect(() => { + if (!cloneOpen && !ovOpen) return; + const onDown = (e: MouseEvent) => { + if (cloneOpen && cloneRef.current && !cloneRef.current.contains(e.target as Node)) setCloneOpen(false); + if (ovOpen && ovRef.current && !ovRef.current.contains(e.target as Node)) setOvOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { setCloneOpen(false); setOvOpen(false); } + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [cloneOpen, ovOpen]); + + const onTab = (key: RepoSubTab) => { + if (key === 'work-items') workItemDispatch({ type: 'MARK_WORK_ITEMS_SEEN', repoId: cloneId }); + switchSubTab(key); + setOvOpen(false); + }; + + const badge = (key: RepoSubTab) => { + if ((key === 'activity' || key === 'chats') && runningCount > 0) { + return {runningCount}; + } + if ((key === 'activity' || key === 'chats') && queuedCount > 0) { + return {queuedCount}; + } + if (key === 'work-items' && unseenWorkItemCount > 0) { + return {unseenWorkItemCount}; + } + if (key === 'git' && (gitAhead > 0 || gitBehind > 0)) { + return ( + + {gitAhead > 0 && ↑{gitAhead}} + {gitBehind > 0 && ↓{gitBehind}} + + ); + } + return null; + }; + + const renderTab = (t: SubTabDef, testid: string) => { + const isActive = activeTab === t.key; + return ( + + ); + }; + + return ( +
+ {/* ── Remote scope ── */} + {remoteTabs.length > 0 && Remote} + {remoteTabs.map(t => renderTab(t, 'remote-scope-tab'))} + + {/* divider */} + + + {/* ── Clone scope ── */} + Clone +
+ + {cloneOpen && ( +
+
+ {remoteLabel} · clones + {clones.length} +
+ {clones.map((c, i) => { + const cid = String(c.workspace.id); + const isSel = cid === cloneId; + const st = cloneStatus[cid]; + return ( + + ); + })} +
+ +
+
+ )} +
+ {cloneTabs.map(t => renderTab(t, 'clone-scope-tab'))} + + {/* overflow ⋯ */} + {overflowTabs.length > 0 && ( +
+ + {ovOpen && ( +
+
More · this clone
+ {overflowTabs.map(t => { + const isActive = activeTab === t.key; + return ( + + ); + })} +
+ )} +
+ )} + + {/* ── Clone-scoped actions ── */} + + + + + setCloneDialogOpen(false)} + onSuccess={() => { setCloneDialogOpen(false); onRefresh(); }} + /> +
+ ); +} diff --git a/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx b/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx new file mode 100644 index 000000000..052efe482 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx @@ -0,0 +1,134 @@ +/** + * RemoteTopBar — row 1 of the remote-first shell. + * + * Renders one tab per git remote (origin) instead of one per local clone, so + * multiple clones of the same origin no longer pile up. Each tab shows a status + * dot, the remote name, a clone-count chip, a running pulse and an unseen badge. + * Clicking a remote selects one of its clones (the last-used one, else the first). + * + * Rendered inside TopBar in place of RepoTabStrip when the remote shell is on. + */ + +import { useEffect, useMemo, useRef } from 'react'; +import { useApp } from '../../contexts/AppContext'; +import { useQueue } from '../../contexts/QueueContext'; +import { useRepos } from '../../contexts/ReposContext'; +import { groupReposByRemote, groupKey } from '../../repos/repoGrouping'; +import type { RepoGroup } from '../../repos/repoGrouping'; +import { isHidden as isHiddenTask } from '../../queue/hooks/useRepoQueueStats'; +import { computeCloneStatusMap, summarizeRemote } from './shellModel'; +import { useShellNavigation } from './useShellNavigation'; + +function CloneGlyph() { + return ( + + ); +} + +export function RemoteTopBar() { + const { state } = useApp(); + const { state: queueState } = useQueue(); + const { repos, unseenCounts } = useRepos(); + const { selectClone } = useShellNavigation(); + + // Remember the last-selected clone per remote so re-selecting a remote + // returns to the checkout you were last on rather than always the first. + const lastCloneByRemote = useRef>({}); + + const groups = useMemo(() => groupReposByRemote(repos, {}), [repos]); + const cloneStatus = useMemo( + () => computeCloneStatusMap(repos, queueState.repoQueueMap, isHiddenTask), + [repos, queueState.repoQueueMap], + ); + + const selectedId = state.selectedRepoId; + const activeGroupKey = useMemo(() => { + for (const g of groups) { + if (g.repos.some(r => String(r.workspace.id) === selectedId)) return groupKey(g); + } + return null; + }, [groups, selectedId]); + + useEffect(() => { + if (!selectedId) return; + const g = groups.find(grp => grp.repos.some(r => String(r.workspace.id) === selectedId)); + if (g) lastCloneByRemote.current[groupKey(g)] = selectedId; + }, [groups, selectedId]); + + const pickRemote = (g: RepoGroup) => { + const key = groupKey(g); + const remembered = lastCloneByRemote.current[key]; + const target = remembered && g.repos.some(r => String(r.workspace.id) === remembered) + ? remembered + : (g.repos[0] ? String(g.repos[0].workspace.id) : undefined); + if (target) selectClone(target); + }; + + return ( +
+ {groups.map(g => { + const key = groupKey(g); + const s = summarizeRemote(g, cloneStatus, unseenCounts); + const isActive = key === activeGroupKey; + return ( + + ); + })} +
+ ); +} diff --git a/packages/coc/src/server/spa/client/react/features/remote-shell/shellModel.ts b/packages/coc/src/server/spa/client/react/features/remote-shell/shellModel.ts new file mode 100644 index 000000000..d735112e6 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/remote-shell/shellModel.ts @@ -0,0 +1,125 @@ +/** + * shellModel — pure helpers for the remote-first shell. + * + * Maps the existing per-clone repo data model onto the redesign's remote-first + * concepts: + * • a "remote" is a RepoGroup (clones sharing a normalized origin URL) + * • a "clone" is a RepoData (a local checkout / workspace) + * + * Everything here is pure and dependency-light so it can be unit-tested without + * React or the dashboard contexts. + */ + +import type { RepoData, RepoGroup } from '../../repos/repoGrouping'; +import type { RepoSubTab } from '../../types/dashboard'; +import type { SubTabDef } from '../repo-detail/repoSubTabs'; + +// ── Tab scoping ────────────────────────────────────────────────────────────── + +/** Sub-tabs that belong to the REMOTE (shared across all clones of an origin). */ +export const REMOTE_SCOPE_KEYS: ReadonlyArray = ['work-items', 'pull-requests']; + +/** Primary clone-scoped tabs shown inline; everything else collapses to the ⋯ overflow. */ +export const PRIMARY_CLONE_KEYS: ReadonlyArray = ['chats', 'activity', 'cli-sessions', 'git', 'terminal']; + +// Fixed display order within each scope (only present tabs are emitted). +const REMOTE_ORDER: RepoSubTab[] = ['work-items', 'pull-requests']; +const CLONE_ORDER: RepoSubTab[] = ['chats', 'activity', 'cli-sessions', 'git', 'terminal']; + +export interface PartitionedShellTabs { + /** Remote-scoped tabs (Work Items, Pull Requests) — left of the divider. */ + remote: SubTabDef[]; + /** Primary clone-scoped tabs shown inline. */ + clone: SubTabDef[]; + /** Less-used clone-scoped tabs tucked under the ⋯ overflow. */ + overflow: SubTabDef[]; +} + +/** + * Split the visible sub-tabs into remote / clone / overflow buckets, preserving a + * stable display order for the named buckets and source order for the overflow. + */ +export function partitionShellTabs(tabs: SubTabDef[]): PartitionedShellTabs { + const byKey = new Map(tabs.map(t => [t.key, t])); + const remote = REMOTE_ORDER.map(k => byKey.get(k)).filter((t): t is SubTabDef => !!t); + const clone = CLONE_ORDER.map(k => byKey.get(k)).filter((t): t is SubTabDef => !!t); + const taken = new Set([...remote, ...clone].map(t => t.key)); + const overflow = tabs.filter(t => !taken.has(t.key)); + return { remote, clone, overflow }; +} + +// ── Clone status ───────────────────────────────────────────────────────────── + +export type CloneStatus = 'idle' | 'running' | 'queued' | 'paused'; + +/** + * Derive per-clone queue status from the QueueContext repoQueueMap, mirroring + * RepoTabStrip's logic. `isHiddenTask` is injected to keep this module pure. + */ +export function computeCloneStatusMap( + repos: RepoData[], + repoQueueMap: Record | undefined, + isHiddenTask: (t: any) => boolean, +): Record { + const map: Record = {}; + for (const repo of repos) { + const id = String(repo.workspace.id); + const entry = repoQueueMap?.[id]; + if (!entry) { map[id] = 'idle'; continue; } + if (entry.stats?.isPaused) { map[id] = 'paused'; continue; } + const running = (entry.running ?? []).filter((t: any) => !isHiddenTask(t)).length; + if (running > 0) { map[id] = 'running'; continue; } + const queued = (entry.queued ?? []).filter((t: any) => !isHiddenTask(t)).length; + map[id] = queued > 0 ? 'queued' : 'idle'; + } + return map; +} + +/** Resolve the dot color for a clone given its status, falling back to the remote color. */ +export function cloneStatusColor(status: CloneStatus | undefined, fallback: string): string { + if (status === 'running') return '#16a34a'; + if (status === 'queued') return '#c98410'; + if (status === 'paused') return '#f14c4c'; + return fallback; +} + +// ── Remote summary ─────────────────────────────────────────────────────────── + +export type RemoteStatus = 'idle' | 'running' | 'queued'; + +export interface RemoteSummary { + /** Aggregate queue status across all clones (running wins over queued). */ + status: RemoteStatus; + /** Sum of unseen counts across clones. */ + unseen: number; + /** Number of clones (local checkouts) for this remote. */ + cloneCount: number; + /** Representative color (first clone's color). */ + color: string; + /** Short remote name (last path segment of the group label). */ + name: string; +} + +/** Aggregate a remote group's clones into the per-tab display summary. */ +export function summarizeRemote( + group: RepoGroup, + cloneStatus: Record, + unseenCounts: Record, +): RemoteSummary { + let running = false; + let queued = false; + let unseen = 0; + for (const repo of group.repos) { + const id = String(repo.workspace.id); + const st = cloneStatus[id] ?? 'idle'; + if (st === 'running') running = true; + else if (st === 'queued') queued = true; + unseen += unseenCounts[id] ?? 0; + } + const status: RemoteStatus = running ? 'running' : queued ? 'queued' : 'idle'; + const first = group.repos[0]?.workspace; + const color = (first?.color as string) || '#848484'; + const label = group.label || (first?.name as string) || 'repo'; + const name = label.includes('/') ? label.split('/').slice(-1)[0] : label; + return { status, unseen, cloneCount: group.repos.length, color, name }; +} diff --git a/packages/coc/src/server/spa/client/react/features/remote-shell/useShellNavigation.ts b/packages/coc/src/server/spa/client/react/features/remote-shell/useShellNavigation.ts new file mode 100644 index 000000000..5bd25bcb3 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/remote-shell/useShellNavigation.ts @@ -0,0 +1,47 @@ +/** + * useShellNavigation — selection + sub-tab routing for the remote-first shell. + * + * Reuses the exact hash-routing the classic nav uses (buildRepoSubTabSuffix), + * so the new shell stays interoperable with deep links and the Router. + */ + +import { useCallback } from 'react'; +import { useApp } from '../../contexts/AppContext'; +import { useQueue } from '../../contexts/QueueContext'; +import { buildRepoSubTabSuffix } from '../../layout/Router'; +import type { RepoSubTab } from '../../types/dashboard'; + +export interface ShellNavigation { + /** Select a clone (workspace). Preserves the active sub-tab unless overridden. */ + selectClone: (id: string, subTabOverride?: RepoSubTab) => void; + /** Switch the active sub-tab for the currently selected clone. */ + switchSubTab: (tab: RepoSubTab) => void; +} + +export function useShellNavigation(): ShellNavigation { + const { state, dispatch } = useApp(); + const { state: queueState } = useQueue(); + + const navigate = useCallback((id: string, subTab: RepoSubTab) => { + const selectedTaskId = queueState.selectedTaskIdByRepo?.[id] ?? null; + const suffix = buildRepoSubTabSuffix( + subTab, + { ...state, selectedNotePath: state.notePathState?.[id] ?? null }, + selectedTaskId, + ); + location.hash = '#repos/' + encodeURIComponent(id) + suffix; + }, [queueState.selectedTaskIdByRepo, state]); + + const selectClone = useCallback((id: string, subTabOverride?: RepoSubTab) => { + dispatch({ type: 'SET_SELECTED_REPO', id }); + navigate(id, subTabOverride ?? state.activeRepoSubTab ?? 'chats'); + }, [dispatch, navigate, state.activeRepoSubTab]); + + const switchSubTab = useCallback((tab: RepoSubTab) => { + dispatch({ type: 'SET_REPO_SUB_TAB', tab }); + const id = state.selectedRepoId; + if (id) navigate(id, tab); + }, [dispatch, navigate, state.selectedRepoId]); + + return { selectClone, switchSubTab }; +} diff --git a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx index e730fdde0..fc5bc2b3a 100644 --- a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx +++ b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx @@ -42,7 +42,7 @@ import { useDreamsEnabled } from '../../hooks/feature-flags/useDreamsEnabled'; import { useNativeCliSessionsEnabled } from '../../hooks/feature-flags/useNativeCliSessionsEnabled'; import { MobileTabBar } from '../../layout/MobileTabBar'; import { buildRepoSubTabSuffix } from '../../layout/Router'; -import { SHOW_WIKI_TAB } from '../../layout/TopBar'; +import { TAB_GROUP_INDEX, computeVisibleSubTabs } from './repoSubTabs'; import type { RepoData } from '../../repos/repoGrouping'; import type { RepoSubTab, TasksPanelNavState } from '../../types/dashboard'; import { isSessionContextAttachmentsEnabled } from '../../utils/config'; @@ -57,43 +57,16 @@ interface RepoDetailProps { repo: RepoData; repos: RepoData[]; onRefresh: () => void; + /** When true, suppress the desktop header (title + sub-tab strip + actions). + * Used by the remote-first shell, which renders its own RemoteSubBar instead. */ + chromeless?: boolean; } -export const SUB_TABS: { key: RepoSubTab; label: string; shortcut?: string }[] = [ - { key: 'chats', label: 'Chats', shortcut: 'Alt+A' }, - { key: 'cli-sessions', label: 'CLI Sessions' }, - { key: 'git', label: 'Git', shortcut: 'Alt+G' }, - { key: 'terminal', label: 'Terminal' }, - { key: 'work-items', label: 'Work Items', shortcut: 'Alt+I' }, - { key: 'dreams', label: 'Dreams', shortcut: 'Alt+D' }, - { key: 'pull-requests', label: 'Pull Requests', shortcut: 'Alt+R' }, - { key: 'explorer', label: 'Explorer', shortcut: 'Alt+E' }, - { key: 'workflows', label: 'Workflows', shortcut: 'Alt+W' }, - { key: 'schedules', label: 'Schedules', shortcut: 'Alt+S' }, - { key: 'tasks', label: 'Tasks (Dep.)', shortcut: 'Alt+T' }, - { key: 'notes', label: 'Notes', shortcut: 'Alt+N' }, - { key: 'settings', label: 'Settings', shortcut: 'Alt+C' }, - { key: 'wiki', label: 'Wiki' }, -]; - -/** Tabs actually rendered in the UI — wiki is hidden behind a feature flag. */ -export const VISIBLE_SUB_TABS = SHOW_WIKI_TAB - ? SUB_TABS - : SUB_TABS.filter(t => t.key !== 'wiki'); +// The sub-tab taxonomy and visibility logic live in ./repoSubTabs so they can be +// shared with the remote-first shell. Re-exported here for backward compatibility. +export { SUB_TABS, VISIBLE_SUB_TABS } from './repoSubTabs'; -/** - * Logical group buckets for the desktop tab strip — used to render thin - * vertical dividers between adjacent tabs that belong to different groups. - * Group identity is purely visual and does not affect functionality. - */ -const TAB_GROUP_INDEX: Record = { - 'chats': 1, 'activity': 1, 'cli-sessions': 1, 'copilot-sessions': 1, 'git': 1, 'terminal': 1, - 'work-items': 2, 'dreams': 2, 'pull-requests': 2, 'tasks': 2, - 'explorer': 3, 'workflows': 3, 'schedules': 3, - 'notes': 4, 'settings': 4, 'wiki': 4, -}; - -export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { +export function RepoDetail({ repo, repos, onRefresh, chromeless = false }: RepoDetailProps) { const { state, dispatch } = useApp(); const { state: queueState, dispatch: queueDispatch } = useQueue(); const { isMobile } = useBreakpoint(); @@ -168,49 +141,10 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { const prevDreamsEnabled = useRef(dreamsEnabled); const prevNativeCliSessionsEnabled = useRef(nativeCliSessionsEnabled); - const visibleSubTabs = useMemo(() => { - let tabs = VISIBLE_SUB_TABS; - if (!isGitRepo) tabs = tabs.filter(t => t.key !== 'git' && t.key !== 'pull-requests'); - if (!terminalEnabled) tabs = tabs.filter(t => t.key !== 'terminal'); - if (!notesEnabled) tabs = tabs.filter(t => t.key !== 'notes'); - if (!workflowsEnabled) tabs = tabs.filter(t => t.key !== 'workflows'); - if (!pullRequestsEnabled) tabs = tabs.filter(t => t.key !== 'pull-requests'); - if (!dreamsEnabled) tabs = tabs.filter(t => t.key !== 'dreams'); - if (!nativeCliSessionsEnabled) tabs = tabs.filter(t => t.key !== 'cli-sessions' && t.key !== 'copilot-sessions'); - // Layout mode filtering - if (uiLayoutMode === 'classic') { - // Classic: replace Chats with Activity, relabel Tasks as Plans - tabs = tabs - .map(t => t.key === 'chats' ? { ...t, key: 'activity' as RepoSubTab, label: 'Activity' } : t) - .map(t => t.key === 'tasks' ? { ...t, label: 'Plans (Dep.)' } : t); - } else { - // Dev-workflow: relabel and reorder tabs - const devWorkflowRelabels: Record = { - 'schedules': 'Jobs', - 'pull-requests': 'Full Requests', - }; - const devWorkflowOrder: RepoSubTab[] = [ - 'chats', 'cli-sessions', 'work-items', 'dreams', 'schedules', 'explorer', - 'workflows', 'git', 'terminal', 'pull-requests', 'tasks', 'settings', - ]; - const tabMap = new Map(tabs.map(t => [t.key, t])); - const ordered: typeof tabs = []; - for (const key of devWorkflowOrder) { - const tab = tabMap.get(key); - if (tab) { - const newLabel = devWorkflowRelabels[key]; - ordered.push(newLabel ? { ...tab, label: newLabel } : tab); - tabMap.delete(key); - } - } - // Append dynamic tabs (notes, wiki) that aren't in the fixed order - for (const [, tab] of tabMap) { - ordered.push(tab); - } - tabs = ordered; - } - return tabs; - }, [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, nativeCliSessionsEnabled, uiLayoutMode]); + const visibleSubTabs = useMemo(() => computeVisibleSubTabs({ + isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, + pullRequestsEnabled, dreamsEnabled, nativeCliSessionsEnabled, uiLayoutMode, + }), [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, nativeCliSessionsEnabled, uiLayoutMode]); // Redirect away from git/pull-requests tab when switching to a non-git repo useEffect(() => { @@ -503,8 +437,9 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { return (
- {/* Header — desktop only; on mobile the repo name lives in MobileTabBar leadingSlot */} - {!isMobile && ( + {/* Header — desktop only; on mobile the repo name lives in MobileTabBar leadingSlot. + Suppressed when chromeless (remote-first shell renders its own RemoteSubBar). */} + {!isMobile && !chromeless && ( +
+ + +
void>(); + +function notifyAll(): void { + for (const fn of listeners) fn(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +function getSnapshot(): boolean { + return enabled; +} + +/** Read the current value synchronously without subscribing (non-component code). */ +export function getRemoteShellEnabled(): boolean { + return enabled; +} + +export function setRemoteShellEnabled(next: boolean): void { + if (next === enabled) return; + enabled = next; + try { + localStorage.setItem(STORAGE_KEY, next ? '1' : '0'); + } catch { + // Storage unavailable (private mode / SSR) — keep the in-memory value. + } + notifyAll(); +} + +/** @internal Reset module-level state for testing. */ +export function __resetRemoteShellForTesting(): void { + enabled = false; + listeners.clear(); +} + +/** @internal Force a value for testing without touching localStorage. */ +export function __setRemoteShellForTesting(next: boolean): void { + enabled = next; + notifyAll(); +} + +export function useRemoteShell(): [boolean, (next: boolean) => void] { + const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return [value, setRemoteShellEnabled]; +} diff --git a/packages/coc/src/server/spa/client/react/layout/TopBar.tsx b/packages/coc/src/server/spa/client/react/layout/TopBar.tsx index 3573f9530..8723cb8e9 100644 --- a/packages/coc/src/server/spa/client/react/layout/TopBar.tsx +++ b/packages/coc/src/server/spa/client/react/layout/TopBar.tsx @@ -18,6 +18,8 @@ import { buildNoteHash, buildRepoSubTabSuffix } from './Router'; import { NotificationBell } from '../shared/NotificationBell'; import { agentProviderQuotaIndicator as AgentProviderQuotaIndicator } from '../shared/AgentProviderQuotaIndicator'; import { RepoTabStrip } from '../features/repo-detail/RepoTabStrip'; +import { RemoteTopBar } from '../features/remote-shell/RemoteTopBar'; +import { useRemoteShell } from '../hooks/preferences/useRemoteShell'; import { MY_WORK_WORKSPACE_ID } from '../repos/MyWorkView'; import { MY_LIFE_WORKSPACE_ID } from '../repos/MyLifeView'; import { useMyWorkEnabled } from '../hooks/feature-flags/useMyWorkEnabled'; @@ -25,13 +27,13 @@ import { useMyLifeEnabled } from '../hooks/feature-flags/useMyLifeEnabled'; import { RepoManagementPopover } from '../repos/RepoManagementPopover'; import { useBreakpoint } from '../hooks/ui/useBreakpoint'; import { getHostname } from '../utils/config'; +import { SHOW_WIKI_TAB, SHOW_MEMORY_TAB } from '../featureFlags'; import type { DashboardTab } from '../types/dashboard'; import type { WsStatus } from '../hooks/useWebSocket'; -/** Set to `true` to re-enable the top-level Wiki tab in navigation. */ -export const SHOW_WIKI_TAB = false; -/** Set to `true` to re-enable the topbar Memory icon. */ -export const SHOW_MEMORY_TAB = false; +// Nav flags live in featureFlags.ts; re-exported here for modules that import +// them from TopBar (BottomNav, Router). +export { SHOW_WIKI_TAB, SHOW_MEMORY_TAB }; export const ALL_TABS: { label: string; tab: DashboardTab }[] = [ { label: 'Wiki', tab: 'wiki' }, @@ -66,6 +68,7 @@ export function TopBar({ onAdminOpen }: TopBarProps = {}) { const { theme, toggleTheme } = useTheme(); const { breakpoint } = useBreakpoint(); const isMobile = breakpoint === 'mobile'; + const [remoteShell] = useRemoteShell(); const [popoverOpen, setPopoverOpen] = useState(false); const hostname = getHostname(); const brandLabel = hostname ? `CoC @ ${hostname}` : 'CoC'; @@ -188,7 +191,9 @@ export function TopBar({ onAdminOpen }: TopBarProps = {}) { 🏠 )} - {!isMobile && ( + {!isMobile && (remoteShell ? ( + + ) : ( - )} + ))} {TABS.length > 0 && (
@@ -375,12 +362,6 @@ export function RemoteSubBar({ repo, repos, onRefresh }: RemoteSubBarProps) { + Queue - - setCloneDialogOpen(false)} - onSuccess={() => { setCloneDialogOpen(false); onRefresh(); }} - /> ); } diff --git a/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx b/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx index 052efe482..7d5c610a3 100644 --- a/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx +++ b/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx @@ -9,13 +9,14 @@ * Rendered inside TopBar in place of RepoTabStrip when the remote shell is on. */ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useApp } from '../../contexts/AppContext'; import { useQueue } from '../../contexts/QueueContext'; import { useRepos } from '../../contexts/ReposContext'; import { groupReposByRemote, groupKey } from '../../repos/repoGrouping'; import type { RepoGroup } from '../../repos/repoGrouping'; import { isHidden as isHiddenTask } from '../../queue/hooks/useRepoQueueStats'; +import { CloneRepoDialog } from '../../repos/CloneRepoDialog'; import { computeCloneStatusMap, summarizeRemote } from './shellModel'; import { useShellNavigation } from './useShellNavigation'; @@ -31,8 +32,9 @@ function CloneGlyph() { export function RemoteTopBar() { const { state } = useApp(); const { state: queueState } = useQueue(); - const { repos, unseenCounts } = useRepos(); + const { repos, unseenCounts, fetchRepos } = useRepos(); const { selectClone } = useShellNavigation(); + const [cloneDialogOpen, setCloneDialogOpen] = useState(false); // Remember the last-selected clone per remote so re-selecting a remote // returns to the checkout you were last on rather than always the first. @@ -129,6 +131,20 @@ export function RemoteTopBar() { ); })} + + setCloneDialogOpen(false)} + onSuccess={() => { setCloneDialogOpen(false); fetchRepos(); }} + /> ); } diff --git a/packages/coc/src/server/spa/client/react/repos/ReposView.tsx b/packages/coc/src/server/spa/client/react/repos/ReposView.tsx index bf027807e..e6d7b790f 100644 --- a/packages/coc/src/server/spa/client/react/repos/ReposView.tsx +++ b/packages/coc/src/server/spa/client/react/repos/ReposView.tsx @@ -113,7 +113,7 @@ export function ReposView() { remoteShell ? ( // Remote-first shell: RemoteSubBar (row 2) above a chromeless RepoDetail body. <> - +
diff --git a/packages/coc/test/spa/react/remote-shell/RemoteSubBar.test.tsx b/packages/coc/test/spa/react/remote-shell/RemoteSubBar.test.tsx index 22f4d83f3..496e92781 100644 --- a/packages/coc/test/spa/react/remote-shell/RemoteSubBar.test.tsx +++ b/packages/coc/test/spa/react/remote-shell/RemoteSubBar.test.tsx @@ -28,9 +28,6 @@ vi.mock('../../../../src/server/spa/client/react/hooks/feature-flags/useNativeCl 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/repos/CloneRepoDialog', () => ({ - CloneRepoDialog: ({ open }: { open: boolean }) => (open ?
: null), -})); vi.mock('../../../../src/server/spa/client/react/features/remote-shell/useShellNavigation', () => ({ useShellNavigation: () => ({ selectClone: mockSelectClone, switchSubTab: mockSwitchSubTab }), })); @@ -45,7 +42,7 @@ const repo = (id: string, name: string, branch = 'main') => ({ const renderBar = () => { const repos = [repo('a', 'shortcuts'), repo('b', 'shortcuts-2', 'feat/x')]; - return render(); + return render(); }; beforeEach(() => { diff --git a/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx b/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx index 2f2a5da10..22dd83172 100644 --- a/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx +++ b/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx @@ -24,6 +24,9 @@ vi.mock('../../../../src/server/spa/client/react/contexts/ReposContext', () => ( 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), +})); import { RemoteTopBar } from '../../../../src/server/spa/client/react/features/remote-shell/RemoteTopBar'; @@ -90,4 +93,12 @@ describe('RemoteTopBar', () => { expect(active).toBeTruthy(); expect(active!.getAttribute('data-remote-key')).toContain('forge'); }); + + it('opens the clone dialog from the top-level add button (not per-origin)', () => { + mockRepos = [repo('a', 'shortcuts', SHORTCUTS)]; + render(); + expect(screen.queryByTestId('clone-repo-dialog')).toBeNull(); + fireEvent.click(screen.getByTestId('remote-add-clone')); + expect(screen.getByTestId('clone-repo-dialog')).toBeTruthy(); + }); }); From 193e38edead71715eb27e14c6bb96abae9c62639 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sun, 14 Jun 2026 14:32:58 -0700 Subject: [PATCH 7/9] fix(coc): restore add-workspace-folder option in remote shell add menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-level "+" only offered Clone, which dropped the "Add workspace folder" (register an existing local folder) and "Add specific repository" actions the classic repo strip had. Make the "+" an add menu with all three: Add workspace folder (AddFolderDialog), Add specific repository (AddRepoDialog), Clone repository (CloneRepoDialog) — placed outside the scroll container so the dropdown isn't clipped. Tests: cover the add menu and the folder / clone dialog paths. Co-Authored-By: Claude Opus 4.8 --- .../coc-knowledge/references/dashboard-spa.md | 5 +- .../features/remote-shell/RemoteTopBar.tsx | 92 ++++++++++++++++--- .../react/remote-shell/RemoteTopBar.test.tsx | 30 +++++- 3 files changed, 111 insertions(+), 16 deletions(-) diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 7fdbbd76a..142eabb07 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -597,8 +597,9 @@ model built on `features/remote-shell/`: chip, aggregate running pulse, and summed unseen badge. Selecting a remote picks its last-used clone (else the first). Aggregation comes from `summarizeRemote` / `computeCloneStatusMap` in `shellModel.ts`. A trailing `+` button - (`remote-add-clone`) opens `CloneRepoDialog` — the single top-level "add clone" - action (it is not duplicated per-origin). + (`remote-add-btn`) opens an add menu — Add workspace folder (`AddFolderDialog`), + Add specific repository (`AddRepoDialog`), Clone repository (`CloneRepoDialog`) — + the single top-level add action (not duplicated per-origin). - **Row 2 `RemoteSubBar`** renders above a `chromeless` `RepoDetail` in `ReposView` and replaces RepoDetail's own header. `partitionShellTabs` splits tabs into remote-scoped (Work Items, Pull Requests — always shown left) and clone-scoped diff --git a/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx b/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx index 7d5c610a3..32a03e99b 100644 --- a/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx +++ b/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx @@ -17,6 +17,8 @@ import { groupReposByRemote, groupKey } from '../../repos/repoGrouping'; import type { RepoGroup } from '../../repos/repoGrouping'; import { isHidden as isHiddenTask } from '../../queue/hooks/useRepoQueueStats'; import { CloneRepoDialog } from '../../repos/CloneRepoDialog'; +import { AddRepoDialog } from '../../repos/AddRepoDialog'; +import { AddFolderDialog } from '../../repos/AddFolderDialog'; import { computeCloneStatusMap, summarizeRemote } from './shellModel'; import { useShellNavigation } from './useShellNavigation'; @@ -34,7 +36,11 @@ export function RemoteTopBar() { const { state: queueState } = useQueue(); const { repos, unseenCounts, fetchRepos } = useRepos(); const { selectClone } = useShellNavigation(); - const [cloneDialogOpen, setCloneDialogOpen] = useState(false); + const [addMenuOpen, setAddMenuOpen] = useState(false); + const [addFolderOpen, setAddFolderOpen] = useState(false); + const [addRepoOpen, setAddRepoOpen] = useState(false); + const [cloneOpen, setCloneOpen] = useState(false); + const addMenuRef = useRef(null); // Remember the last-selected clone per remote so re-selecting a remote // returns to the checkout you were last on rather than always the first. @@ -60,6 +66,20 @@ export function RemoteTopBar() { if (g) lastCloneByRemote.current[groupKey(g)] = selectedId; }, [groups, selectedId]); + useEffect(() => { + if (!addMenuOpen) return; + const onDown = (e: MouseEvent) => { + if (addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) setAddMenuOpen(false); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setAddMenuOpen(false); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [addMenuOpen]); + const pickRemote = (g: RepoGroup) => { const key = groupKey(g); const remembered = lastCloneByRemote.current[key]; @@ -70,6 +90,7 @@ export function RemoteTopBar() { }; return ( +
); })} +
+ {/* Top-level add menu — outside the scroll container so the dropdown isn't clipped. */} +
- setCloneDialogOpen(false)} - onSuccess={() => { setCloneDialogOpen(false); fetchRepos(); }} - /> + {addMenuOpen && ( +
+ + + +
+ )} +
+ setAddFolderOpen(false)} + onAdded={() => { setAddFolderOpen(false); fetchRepos(); }} + /> + setAddRepoOpen(false)} + repos={repos} + onSuccess={() => { setAddRepoOpen(false); fetchRepos(); }} + /> + setCloneOpen(false)} + onSuccess={() => { setCloneOpen(false); fetchRepos(); }} + />
); } diff --git a/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx b/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx index 22dd83172..75627c42a 100644 --- a/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx +++ b/packages/coc/test/spa/react/remote-shell/RemoteTopBar.test.tsx @@ -27,6 +27,12 @@ vi.mock('../../../../src/server/spa/client/react/features/remote-shell/useShellN 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'; @@ -94,11 +100,29 @@ describe('RemoteTopBar', () => { expect(active!.getAttribute('data-remote-key')).toContain('forge'); }); - it('opens the clone dialog from the top-level add button (not per-origin)', () => { + 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(); - expect(screen.queryByTestId('clone-repo-dialog')).toBeNull(); - fireEvent.click(screen.getByTestId('remote-add-clone')); + fireEvent.click(screen.getByTestId('remote-add-btn')); + fireEvent.click(screen.getByTestId('remote-clone-repo-option')); expect(screen.getByTestId('clone-repo-dialog')).toBeTruthy(); }); }); From a2191aeeee1f824d2ac5ca1ae3fbbe7cd5a99317 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sun, 14 Jun 2026 18:16:05 -0700 Subject: [PATCH 8/9] test(coc): fix CI failures on PR #331 - RepoDetail-mobile: update the desktop-header guard assertion for the new `!isMobile && !chromeless` condition (chromeless remote shell). - config.test: add features.remoteShell to the all-overridden fixture and refresh the resolved-config inline snapshot for the new flag. - chat-tool-builder / loop-tools-addon: drop assertions for the prompt-builder tool-guidance suffixes intentionally emptied in aa695ef86 (the tools themselves remain wired and asserted). Co-Authored-By: Claude Opus 4.8 --- packages/coc/test/config.test.ts | 3 +++ .../executors/chat-tool-builder.test.ts | 5 +++-- .../server/executors/loop-tools-addon.test.ts | 19 ++++--------------- .../spa/react/repos/RepoDetail-mobile.test.ts | 5 +++-- 4 files changed, 13 insertions(+), 19 deletions(-) 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/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/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'); From c7790eae361c87144176e4811e45dfddca853bd4 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sun, 14 Jun 2026 18:36:02 -0700 Subject: [PATCH 9/9] fix(coc): keep nav flags out of the heavily-mocked featureFlags module PR #331 CI: 10 test files partially-mock featureFlags. Moving SHOW_WIKI_TAB into it (so repoSubTabs could read it) made those mocks throw on the missing export wherever repoSubTabs is transitively imported (e.g. AdminPanel, ReposGrid). Move SHOW_WIKI_TAB / SHOW_MEMORY_TAB to a dedicated lightweight navFlags.ts (re-exported from TopBar for BottomNav/Router); featureFlags.ts's public surface is restored so the existing partial mocks keep working. Also update executors-prompt-builder.test.ts for the search_conversations suffix removed in aa695ef86 (the tools stay wired and asserted). Co-Authored-By: Claude Opus 4.8 --- .../coc-knowledge/references/dashboard-spa.md | 4 +++- .../src/server/spa/client/react/featureFlags.ts | 6 ------ .../react/features/repo-detail/repoSubTabs.ts | 2 +- .../src/server/spa/client/react/layout/TopBar.tsx | 6 +++--- .../coc/src/server/spa/client/react/navFlags.ts | 14 ++++++++++++++ .../test/server/executors-prompt-builder.test.ts | 8 ++++---- 6 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 packages/coc/src/server/spa/client/react/navFlags.ts diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 142eabb07..33b71acd6 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -614,7 +614,9 @@ The sub-tab taxonomy and feature-flag/git/layout gating live in `TAB_GROUP_INDEX`, `computeVisibleSubTabs`), shared by both `RepoDetail` and the shell so the two stay behaviorally identical. Selection/routing reuse `buildRepoSubTabSuffix` via `useShellNavigation`. `SHOW_WIKI_TAB` / `SHOW_MEMORY_TAB` -are defined in `featureFlags.ts` and re-exported from `TopBar` for back-compat. +live in a dedicated lightweight `navFlags.ts` (read by `repoSubTabs.ts`; re-exported +from `TopBar` for `BottomNav`/`Router`) — kept out of the heavily-mocked +`featureFlags.ts` so partial test mocks of it don't break on the missing export. ## Onboarding diff --git a/packages/coc/src/server/spa/client/react/featureFlags.ts b/packages/coc/src/server/spa/client/react/featureFlags.ts index 604cb0a67..0b98c3d34 100644 --- a/packages/coc/src/server/spa/client/react/featureFlags.ts +++ b/packages/coc/src/server/spa/client/react/featureFlags.ts @@ -6,12 +6,6 @@ /** Enable the welcome modal, first-steps card, and feature tips. */ export const SHOW_WELCOME_TUTORIAL = true; -/** Set to `true` to re-enable the top-level Wiki tab in navigation. */ -export const SHOW_WIKI_TAB = false; - -/** Set to `true` to re-enable the topbar Memory icon. */ -export const SHOW_MEMORY_TAB = false; - /** Enable the focused-diff classification UI on the PR Files Changed tab. */ export const SHOW_FOCUSED_DIFF = true; diff --git a/packages/coc/src/server/spa/client/react/features/repo-detail/repoSubTabs.ts b/packages/coc/src/server/spa/client/react/features/repo-detail/repoSubTabs.ts index 3f68a1e77..b4e816909 100644 --- a/packages/coc/src/server/spa/client/react/features/repo-detail/repoSubTabs.ts +++ b/packages/coc/src/server/spa/client/react/features/repo-detail/repoSubTabs.ts @@ -7,7 +7,7 @@ */ import type { RepoSubTab } from '../../types/dashboard'; -import { SHOW_WIKI_TAB } from '../../featureFlags'; +import { SHOW_WIKI_TAB } from '../../navFlags'; export interface SubTabDef { key: RepoSubTab; diff --git a/packages/coc/src/server/spa/client/react/layout/TopBar.tsx b/packages/coc/src/server/spa/client/react/layout/TopBar.tsx index cbeaf9041..01758be9d 100644 --- a/packages/coc/src/server/spa/client/react/layout/TopBar.tsx +++ b/packages/coc/src/server/spa/client/react/layout/TopBar.tsx @@ -27,12 +27,12 @@ import { useMyLifeEnabled } from '../hooks/feature-flags/useMyLifeEnabled'; import { RepoManagementPopover } from '../repos/RepoManagementPopover'; import { useBreakpoint } from '../hooks/ui/useBreakpoint'; import { getHostname } from '../utils/config'; -import { SHOW_WIKI_TAB, SHOW_MEMORY_TAB } from '../featureFlags'; +import { SHOW_WIKI_TAB, SHOW_MEMORY_TAB } from '../navFlags'; import type { DashboardTab } from '../types/dashboard'; import type { WsStatus } from '../hooks/useWebSocket'; -// Nav flags live in featureFlags.ts; re-exported here for modules that import -// them from TopBar (BottomNav, Router). +// Nav flags live in navFlags.ts; re-exported here for modules that import them +// from TopBar (BottomNav, Router). export { SHOW_WIKI_TAB, SHOW_MEMORY_TAB }; export const ALL_TABS: { label: string; tab: DashboardTab }[] = [ diff --git a/packages/coc/src/server/spa/client/react/navFlags.ts b/packages/coc/src/server/spa/client/react/navFlags.ts new file mode 100644 index 000000000..48e4bee6a --- /dev/null +++ b/packages/coc/src/server/spa/client/react/navFlags.ts @@ -0,0 +1,14 @@ +/** + * Compile-time navigation tab flags. + * + * Kept in a dedicated module — separate from featureFlags.ts — so leaf modules + * like repoSubTabs.ts can read them without pulling in the heavier TopBar module + * and without tripping the many partial `featureFlags` mocks across the test + * suite (which would otherwise throw on a missing SHOW_WIKI_TAB export). + */ + +/** Set to `true` to re-enable the top-level Wiki tab in navigation. */ +export const SHOW_WIKI_TAB = false; + +/** Set to `true` to re-enable the topbar Memory icon. */ +export const SHOW_MEMORY_TAB = false; 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(''); }); });