diff --git a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx
index d80c67d4c..42f217d96 100644
--- a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx
+++ b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx
@@ -7,7 +7,7 @@
*/
import type { AdminAutoProviderRoutingConfig, AdminDefaultProvider, ProviderInstallStatus } from '@plusplusoneplusplus/coc-client';
-import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
+import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
import { getSpaCocClient, getSpaCocClientErrorMessage } from '../api/cocClient';
import { useApp } from '../contexts/AppContext';
import { SHOW_WELCOME_TUTORIAL } from '../featureFlags';
@@ -26,9 +26,11 @@ import { DbBrowserSection } from './DbBrowserSection';
import { PromptsPanel } from './PromptsPanel';
import { ProviderTokensSection } from './ProviderTokensSection';
import { SettingsCard } from './SettingsCard';
+import { AdminInputSuffix, AdminRow, AdminSeg, AdminToggle, SourceBadge } from './adminControls';
import { applyRuntimeConfigPatch, isContainerMode, isServersEnabled } from '../utils/config';
import { AIProviderPage, normalizeAutoProviderRoutingConfig, type NormalizedAutoProviderRoutingConfig } from './AIProviderPage';
+import type { DreamsConfigForm } from '../features/dreams/DreamsView';
import {
ADMIN_SETTING_DEFINITIONS,
FEATURE_CARD_GROUPS,
@@ -50,6 +52,7 @@ const UsageStatsView = lazy(() => import('../features/stats/UsageStatsView').the
const ServersView = lazy(() => import('../features/servers/ServersView').then(m => ({ default: m.ServersView })));
const MemoryV2Panel = lazy(() => import('../features/memory/MemoryV2Panel').then(m => ({ default: m.MemoryV2Panel })));
const ProviderModelsSection = lazy(() => import('../features/models/ProviderModelsSection').then(m => ({ default: m.ProviderModelsSection })));
+const DreamsView = lazy(() => import('../features/dreams/DreamsView').then(m => ({ default: m.DreamsView })));
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
@@ -198,6 +201,7 @@ interface ToolNavItem {
export const ALL_TOOL_NAV_ITEMS: ToolNavItem[] = [
{ id: 'memory-toggle', tab: 'memory', label: 'Memory', icon: '◈', description: 'View and manage global and workspace memory facts, reviews, and episodes.' },
{ id: 'skills-toggle', tab: 'skills', label: 'Skills', icon: '⚡', description: 'Install, configure, and inspect agent skills surfaced to the assistant.' },
+ { id: 'dreams-admin-toggle', tab: 'dreams-admin', label: 'Dreams', icon: '☾', description: 'Enable Dreams, tune the idle-reflection schedule and defaults, and watch provider activity.' },
{ id: 'logs-toggle', tab: 'logs', label: 'Logs', icon: '📋', description: 'Live and historical server logs streamed via SSE.' },
{ id: 'stats-toggle', tab: 'stats', label: 'Usage & Costs', icon: '📊', description: 'Aggregated usage statistics for chats, tokens, costs, and processes.' },
{ id: 'servers-toggle', tab: 'servers', label: 'Servers', icon: '🖥', description: 'Browse running CoC server instances and their health.' },
@@ -205,6 +209,7 @@ export const ALL_TOOL_NAV_ITEMS: ToolNavItem[] = [
export const TOOL_TAB_GROUP_LABELS: Partial
> = {
memory: 'Knowledge',
skills: 'Knowledge',
+ 'dreams-admin': 'Knowledge',
servers: 'Configure',
stats: 'Operations',
logs: 'Operations',
@@ -408,6 +413,12 @@ export function AdminPanel() {
const [dreamProviderActivity, setDreamProviderActivity] = useState([]);
const [dreamProviderActivityError, setDreamProviderActivityError] = useState(null);
+ // Dreams tab config (global). Owned here so it loads with the rest of the
+ // admin config; edited + saved from the Dreams tab (Knowledge nav group).
+ const [dreamsForm, setDreamsForm] = useState({ enabled: false, provider: '', model: '', timeoutMinutes: '60', intervalMinutes: '5' });
+ const [dreamsSnapshot, setDreamsSnapshot] = useState({ enabled: false, provider: '', model: '', timeoutMinutes: '60', intervalMinutes: '5' });
+ const [dreamsSaving, setDreamsSaving] = useState(false);
+
// Snapshots for per-card dirty tracking (set when config/prefs loads)
const [aiExecSnapshot, setAiExecSnapshot] = useState({ model: '', parallel: '1', timeout: '', output: 'table' });
const [defaultProviderSnapshot, setDefaultProviderSnapshot] = useState({
@@ -510,6 +521,17 @@ export function AdminPanel() {
const loadedFeatures = readFeatureValues(resolved);
setFeatureValues(loadedFeatures);
setFeaturesSnapshot(loadedFeatures);
+ const loadedDreams: DreamsConfigForm = {
+ enabled: resolved.dreams?.enabled ?? false,
+ provider: resolved.dreams?.provider === 'codex' || resolved.dreams?.provider === 'claude' || resolved.dreams?.provider === 'copilot'
+ ? resolved.dreams.provider
+ : '',
+ model: resolved.dreams?.model ?? '',
+ timeoutMinutes: String(Math.round((resolved.dreams?.timeoutMs ?? 3_600_000) / 60_000)),
+ intervalMinutes: String(Math.round((resolved.dreams?.idleCheckIntervalMs ?? 5 * 60 * 1000) / 60_000)),
+ };
+ setDreamsForm(loadedDreams);
+ setDreamsSnapshot(loadedDreams);
const aapre = resolved.features?.autoAgentProviderRouting ?? false;
setAutoAgentProviderRoutingEnabled(aapre);
const cxe = resolved.codex?.enabled ?? false;
@@ -621,6 +643,12 @@ export function AdminPanel() {
const featuresDirty = FEATURES_CARD_SETTINGS.some(def => featureValues[def.key] !== featuresSnapshot[def.key]);
+ const dreamsDirty = dreamsForm.enabled !== dreamsSnapshot.enabled ||
+ dreamsForm.provider !== dreamsSnapshot.provider ||
+ dreamsForm.model !== dreamsSnapshot.model ||
+ dreamsForm.timeoutMinutes !== dreamsSnapshot.timeoutMinutes ||
+ dreamsForm.intervalMinutes !== dreamsSnapshot.intervalMinutes;
+
// ── AI & Execution card ──
const handleSaveAiExec = useCallback(async () => {
const errors: string[] = [];
@@ -757,8 +785,16 @@ export function AdminPanel() {
return;
}
void handleRefreshQuota();
+ }, [activeTab, handleRefreshQuota]);
+
+ // Dreams provider activity now lives in the admin Dreams tab; auto-load it
+ // whenever that tab becomes the active dashboard route.
+ useEffect(() => {
+ if (activeDashboardTab !== 'dreams-admin' || isContainerMode()) {
+ return;
+ }
void refreshDreamProviderActivity();
- }, [activeTab, handleRefreshQuota, refreshDreamProviderActivity]);
+ }, [activeDashboardTab, refreshDreamProviderActivity]);
// ── Chat Experience card ──
const handleSaveChat = useCallback(async () => {
@@ -875,6 +911,42 @@ export function AdminPanel() {
setFeatureValues({ ...featuresSnapshot });
}, [featuresSnapshot]);
+ // ── Dreams tab config card ──
+ const handleSaveDreams = useCallback(async () => {
+ const intervalMinutes = Number(dreamsForm.intervalMinutes);
+ if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1) {
+ addToast('Dreams idle check interval must be a positive whole number of minutes', 'error');
+ return;
+ }
+ const timeoutMinutes = Number(dreamsForm.timeoutMinutes);
+ if (!Number.isInteger(timeoutMinutes) || timeoutMinutes < 1) {
+ addToast('Dreams run timeout must be a positive whole number of minutes', 'error');
+ return;
+ }
+ setDreamsSaving(true);
+ try {
+ await getSpaCocClient().admin.updateConfig({
+ 'dreams.enabled': dreamsForm.enabled,
+ 'dreams.provider': dreamsForm.provider || null,
+ 'dreams.model': dreamsForm.model.trim() || null,
+ 'dreams.idleCheckIntervalMs': intervalMinutes * 60_000,
+ 'dreams.timeoutMs': timeoutMinutes * 60_000,
+ });
+ addToast('Settings saved', 'success');
+ invalidateDisplaySettings();
+ applyRuntimeConfigPatch({ dreamsEnabled: dreamsForm.enabled });
+ setDreamsSnapshot({ ...dreamsForm });
+ } catch (err: unknown) {
+ addToast(getSpaCocClientErrorMessage(err, 'Save failed'), 'error');
+ } finally {
+ setDreamsSaving(false);
+ }
+ }, [dreamsForm, addToast]);
+
+ const handleCancelDreams = useCallback(() => {
+ setDreamsForm({ ...dreamsSnapshot });
+ }, [dreamsSnapshot]);
+
const handleSaveServerName = useCallback(async () => {
const trimmed = serverName.trim();
try {
@@ -1113,6 +1185,7 @@ export function AdminPanel() {
items: [
toolNavItem('memory'),
toolNavItem('skills'),
+ toolNavItem('dreams-admin'),
],
},
{
@@ -1284,6 +1357,17 @@ export function AdminPanel() {
}}
/>}
{activeToolItem.tab === 'skills' && }
+ {activeToolItem.tab === 'dreams-admin' && setDreamsForm(prev => ({ ...prev, ...patch }))}
+ configDirty={dreamsDirty}
+ configSaving={dreamsSaving}
+ onSaveConfig={handleSaveDreams}
+ onCancelConfig={handleCancelDreams}
+ providerActivity={dreamProviderActivity}
+ providerActivityError={dreamProviderActivityError}
+ onRefreshProviderActivity={refreshDreamProviderActivity}
+ />}
{activeToolItem.tab === 'logs' && }
{activeToolItem.tab === 'stats' && }
{activeToolItem.tab === 'servers' && }
@@ -1983,9 +2067,6 @@ export function AdminPanel() {
quotaLoading={quotaLoading}
quotaError={quotaError}
onRefreshQuota={handleRefreshQuota}
- providerActivity={dreamProviderActivity}
- providerActivityError={dreamProviderActivityError}
- onRefreshProviderActivity={refreshDreamProviderActivity}
sources={sources}
/>
)}
@@ -2015,105 +2096,3 @@ function resolveNestedValue(obj: Record, key: string): unknown
}
return current;
}
-
-function SourceBadge({ source, isDefault }: { source?: string; isDefault?: boolean }) {
- const s = source || 'default';
- const variant =
- s === 'cli' ? 'ar-src-cli' :
- s === 'env' ? 'ar-src-env' :
- s === 'file' || s === 'config' ? 'ar-src-config' :
- '';
- const modifiedClass = isDefault === false ? ' ar-src-modified' : '';
- const label = isDefault === false ? 'modified' : s;
- const title = isDefault === false
- ? `Value differs from the built-in default (source: ${s})`
- : `Source: ${s}`;
- return {label} ;
-}
-
-/* ── Row primitives that produce the new visual without changing behaviour ── */
-
-interface AdminRowProps {
- name: ReactNode;
- hint?: ReactNode;
- children: ReactNode;
- 'data-testid'?: string;
-}
-function AdminRow({ name, hint, children, 'data-testid': dataTestId }: AdminRowProps) {
- return (
-
-
-
{name}
- {hint &&
{hint}
}
-
-
{children}
-
- );
-}
-
-interface AdminToggleProps {
- checked: boolean;
- onChange: (checked: boolean) => void;
- disabled?: boolean;
- 'data-testid'?: string;
- 'aria-label'?: string;
-}
-function AdminToggle({ checked, onChange, disabled, 'data-testid': dataTestId, 'aria-label': ariaLabel }: AdminToggleProps) {
- return (
-
- onChange(e.target.checked)}
- data-testid={dataTestId}
- aria-label={ariaLabel}
- />
-
-
-
- );
-}
-
-interface AdminSegOption {
- value: T;
- label: string;
- testId?: string;
-}
-interface AdminSegProps {
- value: T;
- onChange: (value: T) => void;
- options: ReadonlyArray>;
- 'aria-label'?: string;
-}
-function AdminSeg({ value, onChange, options, 'aria-label': ariaLabel }: AdminSegProps) {
- return (
-
- {options.map(opt => (
- onChange(opt.value)}
- data-testid={opt.testId}
- >
- {opt.label}
-
- ))}
-
- );
-}
-
-interface AdminInputSuffixProps {
- suffix: string;
- children: ReactNode;
-}
-function AdminInputSuffix({ suffix, children }: AdminInputSuffixProps) {
- return (
-
- {children}
- {suffix}
-
- );
-}
diff --git a/packages/coc/src/server/spa/client/react/admin/admin-redesign.css b/packages/coc/src/server/spa/client/react/admin/admin-redesign.css
index 348b4aabc..7f7f2b18a 100644
--- a/packages/coc/src/server/spa/client/react/admin/admin-redesign.css
+++ b/packages/coc/src/server/spa/client/react/admin/admin-redesign.css
@@ -344,6 +344,16 @@
flex: 1;
min-height: 0;
}
+/* Dreams admin reuses the AI-provider `.aip-page` shell but, unlike that page
+ (which scrolls via `.ar-main`), it renders inside `.ar-tool-embed` where
+ `.ar-main--embed` suppresses the outer scroller. The Skills/Memory embeds
+ own an inner scroll region; the `.aip-page` grid has none, so give the
+ embedded page its own scroll region plus page padding (mirroring `.ar-page`)
+ so long content — the provider-activity queue — stays reachable. */
+.admin-redesign .ar-tool-embed > .aip-page {
+ overflow-y: auto;
+ padding: 28px 32px 120px;
+}
.admin-redesign .ar-topbar {
display: flex;
align-items: center;
@@ -1151,6 +1161,9 @@
.admin-redesign .ar-page {
padding: 20px 16px 80px;
}
+ .admin-redesign .ar-tool-embed > .aip-page {
+ padding: 20px 16px 80px;
+ }
.admin-redesign .ar-row {
flex-direction: column;
align-items: stretch;
diff --git a/packages/coc/src/server/spa/client/react/admin/adminControls.tsx b/packages/coc/src/server/spa/client/react/admin/adminControls.tsx
new file mode 100644
index 000000000..3741bae61
--- /dev/null
+++ b/packages/coc/src/server/spa/client/react/admin/adminControls.tsx
@@ -0,0 +1,110 @@
+/**
+ * Admin row primitives — shared presentational controls for settings surfaces.
+ *
+ * Extracted from `AdminPanel.tsx` so other admin-shell views (e.g. the Dreams
+ * tab) can render the same Linear-inspired rows/toggles/segments without
+ * duplicating markup. Visuals come from `admin-redesign.css`; these components
+ * are pure and carry no behaviour of their own.
+ */
+
+import type { ReactNode } from 'react';
+
+export function SourceBadge({ source, isDefault }: { source?: string; isDefault?: boolean }) {
+ const s = source || 'default';
+ const variant =
+ s === 'cli' ? 'ar-src-cli' :
+ s === 'env' ? 'ar-src-env' :
+ s === 'file' || s === 'config' ? 'ar-src-config' :
+ '';
+ const modifiedClass = isDefault === false ? ' ar-src-modified' : '';
+ const label = isDefault === false ? 'modified' : s;
+ const title = isDefault === false
+ ? `Value differs from the built-in default (source: ${s})`
+ : `Source: ${s}`;
+ return {label} ;
+}
+
+export interface AdminRowProps {
+ name: ReactNode;
+ hint?: ReactNode;
+ children: ReactNode;
+ 'data-testid'?: string;
+}
+export function AdminRow({ name, hint, children, 'data-testid': dataTestId }: AdminRowProps) {
+ return (
+
+
+
{name}
+ {hint &&
{hint}
}
+
+
{children}
+
+ );
+}
+
+export interface AdminToggleProps {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ disabled?: boolean;
+ 'data-testid'?: string;
+ 'aria-label'?: string;
+}
+export function AdminToggle({ checked, onChange, disabled, 'data-testid': dataTestId, 'aria-label': ariaLabel }: AdminToggleProps) {
+ return (
+
+ onChange(e.target.checked)}
+ data-testid={dataTestId}
+ aria-label={ariaLabel}
+ />
+
+
+
+ );
+}
+
+interface AdminSegOption {
+ value: T;
+ label: string;
+ testId?: string;
+}
+export interface AdminSegProps {
+ value: T;
+ onChange: (value: T) => void;
+ options: ReadonlyArray>;
+ 'aria-label'?: string;
+}
+export function AdminSeg({ value, onChange, options, 'aria-label': ariaLabel }: AdminSegProps) {
+ return (
+
+ {options.map(opt => (
+ onChange(opt.value)}
+ data-testid={opt.testId}
+ >
+ {opt.label}
+
+ ))}
+
+ );
+}
+
+interface AdminInputSuffixProps {
+ suffix: string;
+ children: ReactNode;
+}
+export function AdminInputSuffix({ suffix, children }: AdminInputSuffixProps) {
+ return (
+
+ {children}
+ {suffix}
+
+ );
+}
diff --git a/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx
new file mode 100644
index 000000000..7fc83a093
--- /dev/null
+++ b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx
@@ -0,0 +1,168 @@
+// Admin "Dreams" tab — the single home for Dreams configuration and activity.
+//
+// Lives in the dashboard's "Knowledge" nav group (alongside Memory and
+// Skills) and is embedded inside the admin shell. This tab owns:
+// • the global `dreams.enabled` toggle,
+// • the running-interval (`dreams.idleCheckIntervalMs`, edited in minutes),
+// • default provider / model / timeout for idle-triggered Dream runs, and
+// • the "Dreams provider activity" queue + history.
+//
+// The per-workspace dream-cards review panel (`DreamsPanel`) is a separate,
+// untouched surface under each repo's detail view.
+//
+// Config (form + dirty/save) and the provider-activity feed are owned by
+// `AdminPanel` and passed in as props, so they load with the rest of the admin
+// config and reuse the shared toast + runtime-flag plumbing.
+
+import { SettingsCard } from '../../admin/SettingsCard';
+import { AdminInputSuffix, AdminRow, AdminSeg, AdminToggle } from '../../admin/adminControls';
+import type { AgentProviderWorkActivity } from '../../shared/providerActivity';
+import { ProviderActivitySection } from './ProviderActivitySection';
+
+/** Editable global Dreams settings surfaced on the Dreams tab. */
+export interface DreamsConfigForm {
+ /** Global `dreams.enabled` flag — gates idle-time reflection everywhere. */
+ enabled: boolean;
+ /** Default provider for idle-triggered Dream runs; blank uses the global default provider. */
+ provider: '' | 'copilot' | 'codex' | 'claude';
+ /** Optional default model for idle-triggered Dream runs. */
+ model: string;
+ /** Default Dream AI request timeout, edited in minutes and persisted as milliseconds. */
+ timeoutMinutes: string;
+ /** Automatic idle-check cadence, edited in minutes and persisted as milliseconds. */
+ intervalMinutes: string;
+}
+
+export interface DreamsViewProps {
+ config?: DreamsConfigForm;
+ onConfigChange?: (patch: Partial) => void;
+ configDirty?: boolean;
+ configSaving?: boolean;
+ onSaveConfig?: () => void;
+ onCancelConfig?: () => void;
+ providerActivity?: AgentProviderWorkActivity[];
+ providerActivityError?: string | null;
+ onRefreshProviderActivity?: () => void;
+}
+
+const DEFAULT_CONFIG: DreamsConfigForm = { enabled: false, provider: '', model: '', timeoutMinutes: '60', intervalMinutes: '5' };
+
+export function DreamsView({
+ config = DEFAULT_CONFIG,
+ onConfigChange,
+ configDirty,
+ configSaving,
+ onSaveConfig,
+ onCancelConfig,
+ providerActivity = [],
+ providerActivityError,
+ onRefreshProviderActivity,
+}: DreamsViewProps = {}) {
+ return (
+
+
+
+
Dreams
+
+ Enable Dreams, tune the idle-reflection schedule and defaults, and watch the
+ provider activity queue — all from one place.
+
+
+
Restart-aware
+
+
+
+ Enable Dreams Experimental >}
+ hint="Enables workspace opt-in review cards from idle-time reflection. Disabled by default; workspaces must also opt in individually."
+ >
+ onConfigChange?.({ enabled })}
+ data-testid="toggle-dreams-enabled"
+ />
+
+ Idle check interval Restart >}
+ hint="How often the server checks for idle workspaces that are ready for automatic Dream runs. Saved immediately; restart the server for the scheduler cadence to use the new value."
+ >
+
+ onConfigChange?.({ intervalMinutes: event.target.value })}
+ data-testid="dreams-idle-check-interval-minutes"
+ />
+
+
+
+ onConfigChange?.({ provider })}
+ aria-label="Dreams default provider"
+ options={[
+ { value: '', label: 'Global', testId: 'dreams-provider-global' },
+ { value: 'copilot', label: 'Copilot', testId: 'dreams-provider-copilot' },
+ { value: 'codex', label: 'Codex', testId: 'dreams-provider-codex' },
+ { value: 'claude', label: 'Claude', testId: 'dreams-provider-claude' },
+ ]}
+ />
+
+
+ onConfigChange?.({ model: event.target.value })}
+ placeholder="Provider default"
+ data-testid="dreams-default-model"
+ />
+
+
+
+ onConfigChange?.({ timeoutMinutes: event.target.value })}
+ data-testid="dreams-timeout-minutes"
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/packages/coc/src/server/spa/client/react/features/dreams/ProviderActivitySection.tsx b/packages/coc/src/server/spa/client/react/features/dreams/ProviderActivitySection.tsx
new file mode 100644
index 000000000..5c4f4801b
--- /dev/null
+++ b/packages/coc/src/server/spa/client/react/features/dreams/ProviderActivitySection.tsx
@@ -0,0 +1,76 @@
+/**
+ * ProviderActivitySection — the "Dreams provider activity" queue + history card.
+ *
+ * Renders active and recent Dream runs attributed to the provider, model, and
+ * timeout selected for each run, with an optional Refresh control. Lives in the
+ * admin **Dreams** tab (`DreamsView`); the state, fetch, and refresh handler are
+ * owned by `AdminPanel` and passed in as props.
+ */
+import { formatProviderActivityTimeout, type AgentProviderWorkActivity } from '../../shared/providerActivity';
+import { PROVIDER_LABELS, ProviderAvatar } from '../../shared/providerVisuals';
+
+export interface ProviderActivitySectionProps {
+ activity: AgentProviderWorkActivity[];
+ error?: string | null;
+ onRefresh?: () => void;
+}
+
+export function ProviderActivitySection({ activity, error, onRefresh }: ProviderActivitySectionProps) {
+ return (
+
+
+
+
+ Dreams provider activity
+ queue + history
+
+
+ Active and recent Dream jobs are attributed to the provider, model, and timeout selected for each run.
+
+
+ {onRefresh && (
+
+ Refresh
+
+ )}
+
+ {error ? (
+ ⚠ {error}
+ ) : activity.length === 0 ? (
+
+ No active or recent Dreams work.
+
+ ) : (
+
+ {activity.map(item => {
+ const providerLabel = PROVIDER_LABELS[item.provider] ?? item.provider;
+ const trigger = item.trigger === 'idle' ? 'Idle' : item.trigger === 'manual' ? 'Manual' : 'Dreams';
+ const status = item.status ? item.status.replace(/-/g, ' ') : 'unknown';
+ return (
+
+
+
+
+
+ {item.label}
+ {providerLabel}
+
+
+ {trigger} · {status} · {item.model ?? 'provider default'} · {formatProviderActivityTimeout(item.timeoutMs)}
+
+ {item.error &&
✕ {item.error}
}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx
new file mode 100644
index 000000000..d13cae007
--- /dev/null
+++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx
@@ -0,0 +1,570 @@
+/**
+ * NativeCliSessionsPanel — read-only dashboard view for native Copilot,
+ * Codex, and Claude Code CLI sessions scoped to the active workspace.
+ *
+ * Native sessions are external data read from the server user's
+ * external CLI stores. This surface intentionally renders no CoC
+ * chat actions (no follow-up, archive, pin, delete, resume, retry, or turn
+ * actions) and labels every session as an external read-only record. All
+ * stored text renders as plain pre-wrapped text so stored HTML/scripts never
+ * execute.
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+import type {
+ ListNativeCliSessionsResponse,
+ NativeCliSessionDetail,
+ NativeCliSessionListItem,
+ NativeCliSessionProviderId,
+ NativeCliSessionsUnavailableReason,
+} from '@plusplusoneplusplus/coc-client';
+import { getSpaCocClient } from '../../api/cocClient';
+import { Button, Spinner, cn } from '../../ui';
+import { useNativeCliSessionsEnabled } from '../../hooks/feature-flags/useNativeCliSessionsEnabled';
+import { buildNativeCliSessionHash, parseNativeCliSessionDeepLink } from '../../layout/Router';
+import { ConversationTurnBubble } from '../chat/conversation/ConversationTurnBubble';
+import { ProviderBadge } from '../chat/ProviderBadge';
+import { toClientConversationTurns } from './nativeConversationTurns';
+
+const PROVIDERS: NativeCliSessionProviderId[] = ['copilot', 'codex', 'claude'];
+
+const PROVIDER_META: Record = {
+ codex: {
+ label: 'Codex',
+ externalLabel: 'Native Codex CLI session',
+ store: '~/.codex/sessions',
+ },
+ claude: {
+ label: 'Claude',
+ externalLabel: 'Native Claude Code session',
+ store: '~/.claude/projects',
+ },
+ copilot: {
+ label: 'Copilot',
+ externalLabel: 'Native Copilot CLI session',
+ store: '~/.copilot/session-store.db',
+ },
+};
+
+function readOnlyTooltip(provider: NativeCliSessionProviderId, storePath?: string | null): string {
+ const path = storePath || PROVIDER_META[provider].store;
+ return `This data is read from the local ${PROVIDER_META[provider].label} CLI session store (${path}) and cannot be modified from CoC.`;
+}
+
+interface NativeCliSessionsPanelProps {
+ workspaceId: string;
+}
+
+interface ListFilters {
+ q: string;
+ sessionId: string;
+ branch: string;
+ from: string;
+ to: string;
+}
+
+const EMPTY_FILTERS: ListFilters = { q: '', sessionId: '', branch: '', from: '', to: '' };
+
+function formatTimestamp(value: string | null): string {
+ if (!value) return '—';
+ const parsed = Date.parse(value);
+ if (Number.isNaN(parsed)) return value;
+ return new Date(parsed).toLocaleString();
+}
+
+function unavailableCopy(provider: NativeCliSessionProviderId, reason: NativeCliSessionsUnavailableReason | undefined): { title: string; body: string } {
+ if (reason === 'store-missing') {
+ return {
+ title: 'Native session store not found',
+ body: `No native ${PROVIDER_META[provider].label} CLI session store exists at ${PROVIDER_META[provider].store} on the CoC server. Run the CLI at least once to create it.`,
+ };
+ }
+ return {
+ title: 'Native session store unavailable',
+ body: `The native ${PROVIDER_META[provider].label} CLI session store could not be read. It may be corrupt or use an unsupported schema.`,
+ };
+}
+
+function ReadOnlyBadge({ provider, storePath }: { provider: NativeCliSessionProviderId; storePath?: string | null }) {
+ return (
+
+ Read-only
+
+ );
+}
+
+function ExternalLabel({ provider, storePath }: { provider: NativeCliSessionProviderId; storePath?: string | null }) {
+ return (
+
+ {PROVIDER_META[provider].externalLabel}
+
+ );
+}
+
+export function NativeCliSessionsPanel({ workspaceId }: NativeCliSessionsPanelProps) {
+ const enabled = useNativeCliSessionsEnabled();
+ const [provider, setProvider] = useState('copilot');
+
+ const [filterDraft, setFilterDraft] = useState(EMPTY_FILTERS);
+ const [filters, setFilters] = useState(EMPTY_FILTERS);
+ const [offset, setOffset] = useState(0);
+ const [listLoading, setListLoading] = useState(false);
+ const [listError, setListError] = useState(null);
+ const [listResponse, setListResponse] = useState(null);
+
+ const [selectedSessionId, setSelectedSessionId] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [detailError, setDetailError] = useState(null);
+ const [detail, setDetail] = useState(null);
+
+ const loadList = useCallback(async () => {
+ if (!enabled) return;
+ setListLoading(true);
+ setListError(null);
+ try {
+ const response = await getSpaCocClient().nativeCliSessions.list(workspaceId, {
+ provider,
+ q: filters.q || undefined,
+ sessionId: filters.sessionId || undefined,
+ branch: filters.branch || undefined,
+ from: filters.from ? `${filters.from}T00:00:00.000Z` : undefined,
+ to: filters.to ? `${filters.to}T23:59:59.999Z` : undefined,
+ offset,
+ });
+ setListResponse(response);
+ } catch (error) {
+ setListError(error instanceof Error ? error.message : String(error));
+ setListResponse(null);
+ } finally {
+ setListLoading(false);
+ }
+ }, [enabled, workspaceId, provider, filters, offset]);
+
+ useEffect(() => { void loadList(); }, [loadList]);
+
+ // Reset paging/filters when the workspace changes. Selection is driven by
+ // the URL hash (see the deep-link sync effect below).
+ useEffect(() => {
+ setDetail(null);
+ setOffset(0);
+ setFilterDraft(EMPTY_FILTERS);
+ setFilters(EMPTY_FILTERS);
+ }, [workspaceId, provider]);
+
+ // Deep-link: keep the selected session in sync with the URL hash
+ // (`#repos/{wsId}/copilot-sessions/{sessionId}`) so selections survive
+ // refresh/back/forward and can be shared as links.
+ useEffect(() => {
+ const apply = () => {
+ const parsed = parseNativeCliSessionDeepLink(window.location.hash);
+ if (parsed && parsed.workspaceId === workspaceId) {
+ setProvider(prev => (prev === parsed.provider ? prev : parsed.provider));
+ setSelectedSessionId(prev => (prev === parsed.sessionId ? prev : parsed.sessionId));
+ return;
+ }
+ setSelectedSessionId(prev => (prev === null ? prev : null));
+ };
+ apply();
+ window.addEventListener('hashchange', apply);
+ return () => window.removeEventListener('hashchange', apply);
+ }, [workspaceId]);
+
+ // Selecting (or clearing) a session writes the deep-link hash; the
+ // hashchange listener above then reconciles `selectedSessionId`.
+ const selectSession = useCallback((sessionId: string | null) => {
+ setSelectedSessionId(sessionId);
+ const next = buildNativeCliSessionHash(workspaceId, provider, sessionId);
+ if (window.location.hash !== next) {
+ window.location.hash = next;
+ }
+ }, [workspaceId, provider]);
+
+ const switchProvider = useCallback((nextProvider: NativeCliSessionProviderId) => {
+ setProvider(nextProvider);
+ setSelectedSessionId(null);
+ setOffset(0);
+ const next = buildNativeCliSessionHash(workspaceId, nextProvider, null);
+ if (window.location.hash !== next) {
+ window.location.hash = next;
+ }
+ }, [workspaceId]);
+
+ useEffect(() => {
+ if (!enabled || !selectedSessionId) {
+ setDetail(null);
+ return;
+ }
+ let cancelled = false;
+ setDetailLoading(true);
+ setDetailError(null);
+ getSpaCocClient().nativeCliSessions.get(workspaceId, selectedSessionId, provider)
+ .then(response => {
+ if (cancelled) return;
+ if (!response.enabled || response.available === false || !response.session) {
+ setDetail(null);
+ setDetailError('This native session is unavailable.');
+ return;
+ }
+ setDetail(response.session);
+ })
+ .catch(error => {
+ if (cancelled) return;
+ setDetail(null);
+ const message = error instanceof Error ? error.message : String(error);
+ setDetailError(/not found/i.test(message)
+ ? 'Session not found in this workspace.'
+ : message);
+ })
+ .finally(() => { if (!cancelled) setDetailLoading(false); });
+ return () => { cancelled = true; };
+ }, [enabled, workspaceId, provider, selectedSessionId]);
+
+ if (!enabled) {
+ return (
+
+
+
CLI Sessions is disabled
+
+ Enable the features.nativeCliSessions flag in Admin to browse native
+ Codex, Claude Code, and Copilot CLI sessions for this workspace in read-only mode.
+
+
+
+ );
+ }
+
+ const unavailable = listResponse && (listResponse.enabled === false || listResponse.available === false);
+ const items = listResponse?.items ?? [];
+ const total = listResponse?.total ?? 0;
+ const limit = listResponse?.limit ?? 50;
+ const hasFilters = Boolean(filters.q || filters.sessionId || filters.branch || filters.from || filters.to);
+
+ const applyFilters = (e: React.FormEvent) => {
+ e.preventDefault();
+ setOffset(0);
+ setFilters(filterDraft);
+ };
+
+ const listPane = (
+
+
+
+
CLI Sessions
+
+
+
+
+ {total.toLocaleString()} session{total === 1 ? '' : 's'}
+
+
+
+ {PROVIDERS.map(candidate => (
+ switchProvider(candidate)}
+ className={cn(
+ 'rounded-md border px-2 py-1 text-[11px] font-medium',
+ candidate === provider
+ ? 'border-[#0969da] bg-[#ddf4ff] text-[#0969da]'
+ : 'border-[#d0d7de] bg-white text-[#57606a] hover:bg-[#f6f8fa]',
+ )}
+ data-testid={`native-sessions-provider-${candidate}`}
+ >
+ {PROVIDER_META[candidate].label}
+
+ ))}
+
+
+ {listResponse?.available === true && listResponse.searchIndexAvailable === false && filters.q && (
+
+ This provider has no native search index. CoC is using on-demand substring search for the current filter.
+
+ )}
+ {listResponse?.available === true && (listResponse.deduplicatedCount ?? 0) > 0 && (
+
+ {listResponse.deduplicatedCount} session{listResponse.deduplicatedCount === 1 ? '' : 's'} hidden — already tracked in CoC Activity.
+
+ )}
+ {listResponse?.available === true && (listResponse.backgroundJobCount ?? 0) > 0 && (
+
+ {listResponse.backgroundJobCount} background job{listResponse.backgroundJobCount === 1 ? '' : 's'} hidden (e.g. title generation).
+
+ )}
+
+
+ {listLoading && (
+
+ )}
+ {!listLoading && listError && (
+
+ Failed to load native sessions: {listError}
+
void loadList()}>Retry
+
+ )}
+ {!listLoading && !listError && unavailable && (
+
+
{unavailableCopy(provider, listResponse?.reason).title}
+
{unavailableCopy(provider, listResponse?.reason).body}
+
+ )}
+ {!listLoading && !listError && !unavailable && items.length === 0 && (
+
+ {hasFilters
+ ? `No native ${PROVIDER_META[provider].label} CLI sessions match the current filters.`
+ : `No native ${PROVIDER_META[provider].label} CLI sessions were found for this workspace.`}
+
+ )}
+ {!listLoading && !listError && !unavailable && items.length > 0 && (
+
+
+ {items.map(item => (
+ selectSession(item.id)}
+ />
+ ))}
+
+
+ )}
+
+ {!unavailable && total > limit && (
+
+ setOffset(Math.max(0, offset - limit))}>Previous
+ {offset + 1}–{Math.min(offset + limit, total)} of {total}
+ = total || listLoading} onClick={() => setOffset(offset + limit)}>Next
+
+ )}
+
+ );
+
+ const detailPane = (
+
+ {!selectedSessionId && (
+
+
+
Select a native session to view its summary and turns.
+
+ )}
+ {selectedSessionId && detailLoading && (
+
+ )}
+ {selectedSessionId && !detailLoading && detailError && (
+
+ {detailError}
+
+ )}
+ {selectedSessionId && !detailLoading && !detailError && detail && (
+
selectSession(null)} />
+ )}
+
+ );
+
+ // Wide screens render the searchable table beside the detail; narrow screens
+ // stack panes and show one at a time based on selection.
+ return (
+
+
+
+ {listPane}
+
+
+ {detailPane}
+
+
+
+ );
+}
+
+/** @deprecated Use NativeCliSessionsPanel. */
+export const NativeCopilotSessionsPanel = NativeCliSessionsPanel;
+
+function SessionRow({ item, selected, onSelect }: {
+ item: NativeCliSessionListItem;
+ selected: boolean;
+ onSelect: () => void;
+}) {
+ const location = item.repository || item.cwd || '';
+ return (
+
+
+
+
+
+ {item.id.slice(0, 8)}
+ {formatTimestamp(item.updatedAt)}
+
+
{item.summaryPreview || No summary stored }
+ {item.matchSnippets.length > 0 && (
+
+ {item.matchSnippets.map((snippet, index) => (
+
{snippet}
+ ))}
+
+ )}
+ {location && (
+
+ )}
+
+
+
+ {item.turnCount} turn{item.turnCount === 1 ? '' : 's'}
+
+
+
+
+
+ {item.branch || 'Unknown branch'}
+
+
+
+
+
+ );
+}
+
+function SessionDetailView({ detail, workspaceId, onBack }: { detail: NativeCliSessionDetail; workspaceId: string; onBack: () => void }) {
+ // Reconstructed transcript (rich `events.jsonl` parse, else flat DB
+ // fallback) mapped into the SPA chat shape so we can reuse the existing
+ // read-only chat bubble — no input box, streaming, or resume actions.
+ const conversation = toClientConversationTurns(detail.conversation);
+ return (
+
+
+
+
{detail.id}
+
+ {readOnlyTooltip(detail.provider, detail.storePath)}
+
+
+
Repository {detail.repository || '—'}
+
Branch {detail.branch || 'Unknown branch'}
+
Working dir {detail.cwd || '—'}
+
Host {detail.hostType || '—'}
+
Created {formatTimestamp(detail.createdAt)}
+
Updated {formatTimestamp(detail.updatedAt)}
+
+ {detail.summary && (
+
+
Stored summary
+
{detail.summary}
+
+ )}
+
+
+
+
Conversation ({conversation.length})
+
+ Read-only reconstruction of the native {PROVIDER_META[detail.provider].label} CLI transcript. No follow-up, streaming, or resume.
+
+ {conversation.length === 0 ? (
+
This native session has no stored turns.
+ ) : (
+
+ {conversation.map((turn, index) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/nativeConversationTurns.ts b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/nativeConversationTurns.ts
new file mode 100644
index 000000000..76d99dd36
--- /dev/null
+++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/nativeConversationTurns.ts
@@ -0,0 +1,101 @@
+/**
+ * Map the backend-reconstructed native Copilot conversation
+ * ({@link ReconstructedConversationTurn}, from `session-state//events.jsonl`
+ * or the `session-store.db` fallback) into the SPA chat shape
+ * ({@link ClientConversationTurn}) so the read-only detail view can reuse the
+ * existing `ConversationArea` / `ConversationTurnBubble` components without a
+ * fork.
+ *
+ * The two shapes are deliberately near-identical; the only structural gap is
+ * `thinking`: `ClientConversationTurn` has no reasoning field, so the model's
+ * readable reasoning is folded into the assistant turn's content stream as a
+ * markdown blockquote at map time (see {@link thinkingToMarkdown}).
+ */
+
+import type {
+ ReconstructedConversationTurn,
+ ReconstructedTimelineItem,
+ ReconstructedToolCall,
+} from '@plusplusoneplusplus/coc-client';
+import type {
+ ClientConversationTurn,
+ ClientTimelineItem,
+ ClientToolCall,
+} from '../../types/dashboard';
+
+/**
+ * Render a turn's readable reasoning as a markdown blockquote so it folds into
+ * the assistant bubble's content stream (the chat components have no dedicated
+ * reasoning slot). A trailing blank line keeps the blockquote a separate
+ * markdown block from the assistant text that follows it — timeline content
+ * items are concatenated without separators before markdown parsing.
+ */
+export function thinkingToMarkdown(thinking: string): string {
+ const quoted = thinking
+ .split('\n')
+ .map(line => (line.length > 0 ? `> ${line}` : '>'))
+ .join('\n');
+ return `> 🧠 **Reasoning**\n>\n${quoted}\n\n`;
+}
+
+function mapToolCall(toolCall: ReconstructedToolCall): ClientToolCall {
+ return {
+ id: toolCall.id,
+ toolName: toolCall.toolName,
+ args: toolCall.args,
+ status: toolCall.status,
+ ...(toolCall.result !== undefined ? { result: toolCall.result } : {}),
+ ...(toolCall.error !== undefined ? { error: toolCall.error } : {}),
+ ...(toolCall.startTime !== undefined ? { startTime: toolCall.startTime } : {}),
+ ...(toolCall.endTime !== undefined ? { endTime: toolCall.endTime } : {}),
+ };
+}
+
+function mapTimelineItem(item: ReconstructedTimelineItem): ClientTimelineItem {
+ return {
+ type: item.type,
+ timestamp: item.timestamp,
+ ...(item.content !== undefined ? { content: item.content } : {}),
+ ...(item.toolCall ? { toolCall: mapToolCall(item.toolCall) } : {}),
+ };
+}
+
+/** Map a single reconstructed turn into the SPA chat turn shape. */
+export function toClientConversationTurn(turn: ReconstructedConversationTurn): ClientConversationTurn {
+ const timeline: ClientTimelineItem[] = Array.isArray(turn.timeline)
+ ? turn.timeline.map(mapTimelineItem)
+ : [];
+ let content = turn.content ?? '';
+
+ // Fold assistant reasoning into the content stream. Prepending a content
+ // timeline item makes it render above the assistant text (assistant turns
+ // render from the timeline); also prepending to `content` covers the
+ // tool-only / empty-timeline fallback path and keeps copy/raw faithful.
+ if (turn.role === 'assistant' && turn.thinking) {
+ const reasoning = thinkingToMarkdown(turn.thinking);
+ timeline.unshift({ type: 'content', timestamp: turn.timestamp ?? '', content: reasoning });
+ content = content ? `${reasoning}${content}` : reasoning;
+ }
+
+ const mapped: ClientConversationTurn = {
+ role: turn.role,
+ content,
+ timeline,
+ };
+ if (turn.timestamp !== undefined) mapped.timestamp = turn.timestamp;
+ if (turn.turnIndex !== undefined) mapped.turnIndex = turn.turnIndex;
+ if (turn.toolCalls && turn.toolCalls.length > 0) mapped.toolCalls = turn.toolCalls.map(mapToolCall);
+ if (turn.images && turn.images.length > 0) mapped.images = turn.images;
+ if (turn.skillNames && turn.skillNames.length > 0) mapped.skillNames = turn.skillNames;
+ if (turn.model) mapped.model = turn.model;
+ if (turn.isError) mapped.isError = true;
+ return mapped;
+}
+
+/** Map a reconstructed conversation into SPA chat turns (empty when absent). */
+export function toClientConversationTurns(
+ conversation: ReconstructedConversationTurn[] | undefined | null,
+): ClientConversationTurn[] {
+ if (!Array.isArray(conversation)) return [];
+ return conversation.map(toClientConversationTurn);
+}
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 a044b54e2..e730fdde0 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
@@ -24,6 +24,7 @@ import { WorkflowDetailView } from '../../processes/dag';
import { TerminalView } from '../terminal/TerminalView';
import { NotesView } from '../notes/NotesView';
import { DreamsPanel } from '../dreams/DreamsPanel';
+import { NativeCliSessionsPanel } from '../native-copilot-sessions/NativeCopilotSessionsPanel';
import { AddRepoDialog } from '../../repos/AddRepoDialog';
import { ErrorBoundary } from '../../ui/ErrorBoundary';
@@ -38,6 +39,7 @@ 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 { MobileTabBar } from '../../layout/MobileTabBar';
import { buildRepoSubTabSuffix } from '../../layout/Router';
import { SHOW_WIKI_TAB } from '../../layout/TopBar';
@@ -59,6 +61,7 @@ interface RepoDetailProps {
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' },
@@ -84,7 +87,7 @@ export const VISIBLE_SUB_TABS = SHOW_WIKI_TAB
* Group identity is purely visual and does not affect functionality.
*/
const TAB_GROUP_INDEX: Record = {
- 'chats': 1, 'activity': 1, 'git': 1, 'terminal': 1,
+ '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,
@@ -134,6 +137,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) {
const workflowsEnabled = useWorkflowsEnabled();
const pullRequestsEnabled = usePullRequestsEnabled();
const dreamsEnabled = useDreamsEnabled();
+ const nativeCliSessionsEnabled = useNativeCliSessionsEnabled();
const sessionContextAttachmentsEnabled = isSessionContextAttachmentsEnabled();
const canRetrieveConversations = useConversationRetrievalCapability(ws.id, sessionContextAttachmentsEnabled);
const [headerContextDropTarget, setHeaderContextDropTarget] = useState<'task' | 'ask' | null>(null);
@@ -162,6 +166,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) {
const prevWorkflowsEnabled = useRef(workflowsEnabled);
const prevPullRequestsEnabled = useRef(pullRequestsEnabled);
const prevDreamsEnabled = useRef(dreamsEnabled);
+ const prevNativeCliSessionsEnabled = useRef(nativeCliSessionsEnabled);
const visibleSubTabs = useMemo(() => {
let tabs = VISIBLE_SUB_TABS;
@@ -171,6 +176,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) {
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
@@ -184,7 +190,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) {
'pull-requests': 'Full Requests',
};
const devWorkflowOrder: RepoSubTab[] = [
- 'chats', 'work-items', 'dreams', 'schedules', 'explorer',
+ '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]));
@@ -204,7 +210,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) {
tabs = ordered;
}
return tabs;
- }, [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, uiLayoutMode]);
+ }, [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, nativeCliSessionsEnabled, uiLayoutMode]);
// Redirect away from git/pull-requests tab when switching to a non-git repo
useEffect(() => {
@@ -253,6 +259,14 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) {
prevDreamsEnabled.current = dreamsEnabled;
}, [activeSubTab, dreamsEnabled, dispatch]);
+ // Redirect away from CLI sessions tab only when the feature transitions to disabled
+ useEffect(() => {
+ if ((activeSubTab === 'cli-sessions' || activeSubTab === 'copilot-sessions') && !nativeCliSessionsEnabled && prevNativeCliSessionsEnabled.current) {
+ dispatch({ type: 'SET_REPO_SUB_TAB', tab: 'chats' });
+ }
+ prevNativeCliSessionsEnabled.current = nativeCliSessionsEnabled;
+ }, [activeSubTab, nativeCliSessionsEnabled, dispatch]);
+
// Redirect when switching layout modes
useEffect(() => {
if (uiLayoutMode === 'classic' && activeSubTab === 'chats') {
@@ -777,7 +791,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) {
)}
) : (
-
+
{activeSubTab === 'settings' && }
{activeSubTab === 'workflows' && }
{/*
@@ -834,6 +848,11 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) {
{wasVisited('dreams') && }
)}
+ {nativeCliSessionsEnabled && (
+
+ {(wasVisited('cli-sessions') || wasVisited('copilot-sessions')) && }
+
+ )}
{activeSubTab === 'workflow' && state.selectedWorkflowProcessId &&
}
)}
diff --git a/packages/coc/src/server/spa/client/react/features/repo-settings/LlmToolsPanel.tsx b/packages/coc/src/server/spa/client/react/features/repo-settings/LlmToolsPanel.tsx
index 4711ff008..d1b65273b 100644
--- a/packages/coc/src/server/spa/client/react/features/repo-settings/LlmToolsPanel.tsx
+++ b/packages/coc/src/server/spa/client/react/features/repo-settings/LlmToolsPanel.tsx
@@ -4,7 +4,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
-import type { LlmToolMeta, LlmToolsConfig } from '@plusplusoneplusplus/coc-client';
+import type { LlmToolMeta, LlmToolParam, LlmToolsConfig } from '@plusplusoneplusplus/coc-client';
import { getSpaCocClient } from '../../api/cocClient';
import { useGlobalToast } from '../../contexts/ToastContext';
@@ -12,6 +12,91 @@ interface LlmToolsPanelProps {
workspaceId: string;
}
+/**
+ * Render one compact parameter token: `name: type*` for required params and
+ * `name?: type` for optional ones. The `type` is already a compact label such
+ * as a primitive, `{...}` (nested object) or `[...]` (array), so nested shapes
+ * stay collapsed.
+ */
+function formatParam(param: LlmToolParam): string {
+ return `${param.name}${param.required ? '' : '?'}: ${param.type}${param.required ? '*' : ''}`;
+}
+
+/**
+ * Compact, inline-expandable parameter summary for a single tool. Lives outside
+ * the toggle