diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index de614bb07..33b71acd6 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -580,6 +580,44 @@ 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 `useRemoteShellEnabled()` +(`hooks/feature-flags/useRemoteShellEnabled.ts`), which reads the live +`features.remoteShell` admin flag (runtime flag `remoteShellEnabled`, +`isRemoteShellEnabled()` in `utils/config.ts`). It is a **global admin setting** +toggled in **Admin → Configure → Features → Remote-first shell** +(`toggle-remote-shell-enabled`), defined once in `ADMIN_SETTING_DEFINITIONS`. +Disabled by default; desktop-only; takes effect on reload. + +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`. A trailing `+` button + (`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 + (everything else). A clone-switcher popover (lists the remote's clones only) sits + between them, then the clone tabs, then compact Ask/Queue targeting the active + clone. Clone tabs use **responsive overflow**: a hidden measurement mirror plus a + `ResizeObserver` feed `computeVisibleTabKeys`, which shows every tab that fits and + collapses the tail into a `…` menu (always keeping the active tab visible). + +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` +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 - `WelcomeTour`: 5-step full-screen modal (Welcome/Modes/Queue/Multi-repo/Servers) diff --git a/AGENTS.md b/AGENTS.md index 03343a1ed..7f0e9adba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,21 +17,6 @@ npm workspaces monorepo with one frozen VS Code extension and published Node pac For anything touching CoC, forge, deep-wiki, coc-client, the dashboard SPA, REST API, workflow engine, memory system, LLM tools, process store, admin config, MCP settings, Ralph, loops, EnDev, the Windows service, monorepo layout, build/test/release flow, or repo conventions — **load `.github/skills/coc-knowledge/SKILL.md` and read the relevant `references/*.md` files** before responding or editing. -Quick-start pointers (full detail lives in the skill): - -- **Monorepo, build, test, changesets release** → [monorepo.md](.github/skills/coc-knowledge/references/monorepo.md) -- **CoC server module layout / executors / startup** → [server-architecture.md](.github/skills/coc-knowledge/references/server-architecture.md) -- **Admin config field registry + admin UI styling** → [admin-config.md](.github/skills/coc-knowledge/references/admin-config.md) -- **Workspace MCP merge & allow-list** → [mcp-settings.md](.github/skills/coc-knowledge/references/mcp-settings.md) -- **EnDev xDPU eligibility & skill surfacing** → [endev.md](.github/skills/coc-knowledge/references/endev.md) -- **Windows service (`Manage-CoCService.ps1`)** → [coc-service.md](.github/skills/coc-knowledge/references/coc-service.md) -- **Ralph iterative sessions + promote endpoint** → [ralph.md](.github/skills/coc-knowledge/references/ralph.md) -- **Recurring loops + wakeups + circuit breakers** → [loops.md](.github/skills/coc-knowledge/references/loops.md) -- **Deep Wiki six-phase pipeline** → [deep-wiki.md](.github/skills/coc-knowledge/references/deep-wiki.md) -- **REST API catalog** → [rest-api.md](.github/skills/coc-knowledge/references/rest-api.md) -- **Notes Git sync engine** → [sync.md](.github/skills/coc-knowledge/references/sync.md) -- **SDK wrapper, Codex support, provider registry** → [sdk-wrapper.md](.github/skills/coc-knowledge/references/sdk-wrapper.md) - ## Hard Invariants (apply even before reading the skill) - **Multi-repo:** every feature must support multiple workspaces. @@ -41,3 +26,4 @@ Quick-start pointers (full detail lives in the skill): - **No SDK session caching:** `coc-agent-sdk` and above must never add `sendFollowUp` or keep-alive/session caches. - **Model resolution order:** `task.config.model` > `PerRepoPreferences.defaultModels[mode]` > `defaultModel` > CLI default. - **Node.js ≥ 24** for every package (`engines.node`). +- **Never switch branches:** AI must NEVER run `git checkout`, `git switch`, or any command that changes the current branch. Always work on the current branch as-is. diff --git a/packages/coc-client/src/contracts/admin.ts b/packages/coc-client/src/contracts/admin.ts index 04361a4bd..1b2698e3d 100644 --- a/packages/coc-client/src/contracts/admin.ts +++ b/packages/coc-client/src/contracts/admin.ts @@ -248,6 +248,7 @@ export interface RuntimeDashboardConfig { commitChatLensDormantMode: 'ghost' | 'pill'; effortLevelsEnabled: boolean; nativeCliSessionsEnabled: boolean; + remoteShellEnabled: boolean; }; hostname?: string; bindAddress?: string; diff --git a/packages/coc/src/config.ts b/packages/coc/src/config.ts index e775a0fc1..5d87c356a 100644 --- a/packages/coc/src/config.ts +++ b/packages/coc/src/config.ts @@ -255,6 +255,8 @@ export interface CLIConfig { ralphMultiAgentGrill?: boolean; /** Read-only native Copilot/Codex/Claude CLI sessions tab. Disabled by default. */ nativeCliSessions?: boolean; + /** Remote-first two-row dashboard shell (one tab per remote). Disabled by default. */ + remoteShell?: boolean; }; /** Memory promotion configuration */ memoryPromotion?: { @@ -542,6 +544,8 @@ export interface ResolvedCLIConfig { ralphMultiAgentGrill: boolean; /** Read-only native Copilot/Codex/Claude CLI sessions tab. Disabled by default. */ nativeCliSessions: boolean; + /** Remote-first two-row dashboard shell (one tab per remote). Disabled by default. */ + remoteShell: boolean; }; /** Memory promotion configuration */ memoryPromotion: { @@ -776,6 +780,7 @@ export const DEFAULT_CONFIG: ResolvedCLIConfig = { autoAgentProviderRouting: false, ralphMultiAgentGrill: false, nativeCliSessions: false, + remoteShell: false, }, memoryPromotion: { batchSize: 50, diff --git a/packages/coc/src/config/admin-setting-definitions.ts b/packages/coc/src/config/admin-setting-definitions.ts index 81b3c37ae..45492b5b3 100644 --- a/packages/coc/src/config/admin-setting-definitions.ts +++ b/packages/coc/src/config/admin-setting-definitions.ts @@ -725,6 +725,14 @@ export const ADMIN_SETTING_DEFINITIONS: readonly AdminSettingDefinition[] = [ testId: 'toggle-native-cli-sessions-enabled', }, }), + bool({ + key: 'features.remoteShell', default: false, runtime: 'live', runtimeFlag: 'remoteShellEnabled', + ui: { + group: 'dashboard', order: 65, label: 'Remote-first shell', badge: 'experimental', + hint: 'Replace per-clone repo tabs with a remote-first two-row top bar: one tab per git remote, a clone switcher, and remote/clone-scoped sub-tabs. Desktop only. Disabled by default.', + testId: 'toggle-remote-shell-enabled', + }, + }), bool({ key: 'features.ralphMultiAgentGrill', default: false, runtime: 'live', runtimeFlag: 'ralphMultiAgentGrillEnabled', ui: { 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: '' }; } // ============================================================================ 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..06db1c0e3 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteSubBar.tsx @@ -0,0 +1,367 @@ +/** + * 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 + * clone tabs (Activity, CLI Sessions, Git, Terminal, Explorer, Schedules, …). + * As many clone tabs as fit are shown inline; the rest collapse into a ⋯ + * overflow that is measured responsively against the available width. + * + * 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 { useCallback, 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 { + partitionShellTabs, computeCloneStatusMap, cloneStatusColor, summarizeRemote, computeVisibleTabKeys, +} from './shellModel'; +import { useShellNavigation } from './useShellNavigation'; +import type { RepoData } from '../../repos/repoGrouping'; +import type { RepoSubTab } from '../../types/dashboard'; + +interface RemoteSubBarProps { + repo: RepoData; + repos: RepoData[]; +} + +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 }: 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 } = useMemo(() => partitionShellTabs(tabs), [tabs]); + + 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); + // Which clone-tab keys fit inline; null means "show all" (no overflow / no layout yet). + const [visibleKeys, setVisibleKeys] = useState | null>(null); + const cloneRef = useRef(null); + const ovRef = useRef(null); + const cloneRegionRef = useRef(null); + const measureRef = 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]); + + // ── Responsive overflow: show as many clone tabs as fit; collapse the rest ── + const recomputeOverflow = useCallback(() => { + const region = cloneRegionRef.current; + const measure = measureRef.current; + if (!region || !measure) return; + const containerWidth = region.clientWidth; + const els = Array.from(measure.querySelectorAll('[data-measure-key]')); + const measured = els.map(el => ({ key: el.getAttribute('data-measure-key') || '', width: el.offsetWidth })); + const next = computeVisibleTabKeys(measured, containerWidth, activeTab); + setVisibleKeys(prev => { + if (next === null) return prev === null ? prev : null; + if (prev !== null && prev.size === next.size && [...next].every(k => prev.has(k))) return prev; + return next; + }); + }, [activeTab]); + + useEffect(() => { + const region = cloneRegionRef.current; + if (!region) return; + recomputeOverflow(); + if (typeof ResizeObserver === 'undefined') return; + const ro = new ResizeObserver(recomputeOverflow); + ro.observe(region); + return () => ro.disconnect(); + }, [recomputeOverflow, cloneTabs]); + + const visibleCloneTabs = cloneTabs.filter(t => !visibleKeys || visibleKeys.has(t.key)); + const hiddenCloneTabs = visibleKeys ? cloneTabs.filter(t => !visibleKeys.has(t.key)) : []; + const hasOverflow = hiddenCloneTabs.length > 0; + const overflowActive = hiddenCloneTabs.some(t => t.key === activeTab); + + const onTab = (key: RepoSubTab) => { + if (key === 'work-items') workItemDispatch({ type: 'MARK_WORK_ITEMS_SEEN', repoId: cloneId }); + switchSubTab(key); + setOvOpen(false); + }; + + const badge = (key: RepoSubTab, forMeasure = false) => { + const tid = (id: string) => (forMeasure ? {} : { 'data-testid': id }); + 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 ( +
+ {/* Hidden width-measurement mirror of every clone tab (drives overflow). */} +
+ {cloneTabs.map(t => ( + + {t.label} + {badge(t.key, true)} + + ))} +
+ + {/* ── 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 ( + + ); + })} +
+ )} +
+ + {/* Clone tabs — fill the available space; the tail overflows into ⋯. */} +
+ {visibleCloneTabs.map(t => renderTab(t, 'clone-scope-tab'))} +
+ + {/* overflow ⋯ (only when some clone tabs don't fit) */} + {hasOverflow && ( +
+ + {ovOpen && ( +
+
More · this clone
+ {hiddenCloneTabs.map(t => { + const isActive = activeTab === t.key; + return ( + + ); + })} +
+ )} +
+ )} + + {/* ── Clone-scoped actions ── */} + + +
+ ); +} 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..32a03e99b --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/remote-shell/RemoteTopBar.tsx @@ -0,0 +1,220 @@ +/** + * 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, 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 { AddRepoDialog } from '../../repos/AddRepoDialog'; +import { AddFolderDialog } from '../../repos/AddFolderDialog'; +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, fetchRepos } = useRepos(); + const { selectClone } = useShellNavigation(); + 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. + 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]); + + 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]; + 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 ( + + ); + })} +
+ {/* Top-level add menu — outside the scroll container so the dropdown isn't clipped. */} +
+ + {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/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..f66ebaa49 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/remote-shell/shellModel.ts @@ -0,0 +1,155 @@ +/** + * 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']; + +// Fixed display order for the remote scope (only present tabs are emitted). +const REMOTE_ORDER: RepoSubTab[] = ['work-items', 'pull-requests']; + +export interface PartitionedShellTabs { + /** Remote-scoped tabs (Work Items, Pull Requests) — left of the divider, always shown. */ + remote: SubTabDef[]; + /** Clone-scoped tabs in display order. Shown inline until they run out of + * horizontal room, after which the tail collapses into the ⋯ overflow. */ + clone: SubTabDef[]; +} + +/** + * Split the visible sub-tabs into the two scopes. Remote-scoped tabs come first + * in a stable order; every other tab is clone-scoped and kept in source order. + */ +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 taken = new Set(remote.map(t => t.key)); + const clone = tabs.filter(t => !taken.has(t.key)); + return { remote, clone }; +} + +/** + * Given each clone tab's natural pixel width (in display order) and the + * available container width, return the set of tab keys that fit. The active tab + * is always kept visible (swapped in for the last fitting tab if needed). + * + * Returns `null` to mean "show everything" — either there is no layout + * information yet (containerWidth <= 0, e.g. jsdom) or every tab fits. + */ +export function computeVisibleTabKeys( + measured: { key: string; width: number }[], + containerWidth: number, + activeKey: string | null, + gap = 2, +): Set | null { + if (containerWidth <= 0) return null; + const visible = new Set(); + let used = 0; + let lastVisible: string | null = null; + for (const m of measured) { + const w = m.width + gap; + if (used + w <= containerWidth) { + visible.add(m.key); + used += w; + lastVisible = m.key; + } else { + break; + } + } + if (visible.size >= measured.length) return null; + if (activeKey && !visible.has(activeKey)) { + if (lastVisible && visible.size > 0) visible.delete(lastVisible); + visible.add(activeKey); + } + return visible; +} + +// ── 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 && (