@chatcode/cco-better-sidebar 0.18.0-alpha.0 → 0.18.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ /** Build the local ChatCode login gate URL while preserving the current page. */
2
+ export declare function chatcodeLoginRoute(location: Pick<Location, 'pathname' | 'search' | 'hash'>): string;
3
+ /** Enter the existing local login flow after account state cannot be read. */
4
+ export declare function redirectToChatcodeLogin(location?: Location): void;
@@ -0,0 +1,11 @@
1
+ import type { Context } from '../context-types.ts';
2
+ /**
3
+ * Read the session selected by the host's scoped-session adapter.
4
+ *
5
+ * Newer hosts deliberately removed `sessions.list.current`: selection is UI
6
+ * scope state, not catalog state. The adapter is authoritative on both old
7
+ * and new hosts and tracks main-view retain/release transitions.
8
+ */
9
+ export declare function currentSessionId(ctx: Context): string | undefined;
10
+ /** React subscription to the same authoritative current-session source. */
11
+ export declare function useCurrentSessionId(ctx: Context): string | undefined;
@@ -16,11 +16,21 @@ export declare function SidebarProducedFiles(props: {
16
16
  /** Reveal the produced files in the explorer ("Show in folder" twin). */
17
17
  onShowInFolder: (files: readonly string[]) => void;
18
18
  }): import("react").JSX.Element;
19
+ interface SidebarProducedFilesEntryProps {
20
+ /** Present only when an older chain slot selected the owner before render. */
21
+ matched?: readonly string[];
22
+ store: SidebarStore;
23
+ openInSidebar: (path: string) => void;
24
+ onShowInFolder: (files: readonly string[]) => void;
25
+ }
26
+ /** Adapt both the legacy chain match and the current list owner to the row. */
27
+ export declare function SidebarProducedFilesEntry(props: SidebarProducedFilesEntryProps): ReturnType<typeof SidebarProducedFiles> | null;
19
28
  /**
20
29
  * Register the turn-tail interception (returns the disposer).
21
30
  *
22
31
  * The slot is a CHILD slot the host's ui-conversation declares in its
23
- * `conversation.chat.node` children table (kind: chain, scope: session).
32
+ * `conversation.chat.node` children table (currently kind: list, scope:
33
+ * session; older compatible Hosts declared a chain).
24
34
  * Registering it directly races the declaration — the ui-slots core's
25
35
  * load-time validation throws "not declared (a parent entry's children
26
36
  * table must declare it)" when the parent entry is not on the ledger yet.
@@ -42,3 +52,4 @@ export declare function registerTurnTailInterception(ctx: Context, store: Sideba
42
52
  * disposer restoring the original (HMR-safe).
43
53
  */
44
54
  export declare function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void;
55
+ export {};
@@ -101,7 +101,7 @@ export interface SidebarSlotRegisterOptions {
101
101
  id?: string;
102
102
  order?: number;
103
103
  label?: string | (() => string);
104
- /** Chain routing selector (returns the matched value, or null to pass on). */
104
+ /** Legacy chain routing selector (returns the matched value, or null to pass on). */
105
105
  select?: (owner: unknown) => unknown;
106
106
  priority?: number;
107
107
  locale?: string;
@@ -289,7 +289,8 @@ export interface SidebarSessionPersistenceService {
289
289
  }
290
290
  /** The client session list snapshot the sidebar subscribes to. */
291
291
  export interface SidebarSessionList {
292
- current: string | undefined;
292
+ /** @deprecated Selection moved to `uiSession.adapter.current`; old hosts may still expose this field. */
293
+ current?: string;
293
294
  byId: Record<string, SidebarSessionSummary>;
294
295
  /** Direct durable catalogs keyed by their selected parent address. */
295
296
  subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>;
@@ -356,6 +357,17 @@ export interface SidebarSessionsService {
356
357
  */
357
358
  refreshSubagents?(parentSessionId: string): Promise<void>;
358
359
  }
360
+ /** The host's authoritative current Session scope binding. */
361
+ export interface SidebarUiSessionService {
362
+ adapter: {
363
+ current: {
364
+ getSnapshot(): {
365
+ readonly key: string | undefined;
366
+ };
367
+ subscribe(fn: () => void): () => void;
368
+ };
369
+ };
370
+ }
359
371
  /**
360
372
  * The client locale service face (mirror of @deepseek-ai/dsh-client-locale's
361
373
  * LocaleRuntime — only the slices the sidebar touches). The sidebar follows
@@ -475,6 +487,8 @@ export interface SidebarContextShape {
475
487
  webServer: SidebarWebServer;
476
488
  /** The session store (host `.get`) and the client list feed (`.list`) faces. */
477
489
  sessions: SidebarSessionStore & SidebarSessionsService;
490
+ /** Current UI Session scope; selection no longer belongs to the Session list catalog. */
491
+ uiSession: SidebarUiSessionService;
478
492
  /** The web runtime trust list (bind-derived). */
479
493
  webRuntime: SidebarWebRuntime;
480
494
  /** The client slot registry (register/inject). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatcode/cco-better-sidebar",
3
- "version": "0.18.0-alpha.0",
3
+ "version": "0.18.0-alpha.2",
4
4
  "description": "ChatCode CLI Web plugin: a VS Code-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -10,6 +10,7 @@
10
10
  import { useEffect, useRef, useState } from 'react'
11
11
  import { api } from './api.ts'
12
12
  import { useChatcodeAccount } from './useChatcodeAccount.ts'
13
+ import { redirectToChatcodeLogin } from './chatcode-login-route.ts'
13
14
  import { t } from './locales.ts'
14
15
  import css from './sidebar.module.css'
15
16
 
@@ -93,6 +94,10 @@ export function AccountMenu(): JSX.Element {
93
94
  return () => { document.removeEventListener('pointerdown', onPointerDown) }
94
95
  }, [])
95
96
 
97
+ useEffect(() => {
98
+ if (error === 'status') redirectToChatcodeLogin()
99
+ }, [error])
100
+
96
101
  // The host has no slot for replacing this one closed-shell control. Keep
97
102
  // the original fully recoverable: its inline style is restored on disposal
98
103
  // and it remains the target invoked by our Settings menu item.
@@ -127,7 +132,8 @@ export function AccountMenu(): JSX.Element {
127
132
  const signedIn = chatcode?.validation === 'valid' && chatcode.expired !== true
128
133
  // A saved credential remains usable for the next check when the company
129
134
  // network is unavailable. Do not turn that transient state into a login CTA.
130
- const canStartChatcodeLogin = chatcode?.available === true && !signedIn
135
+ const hasChatcodeAccount = chatcode?.available === true && chatcode.configured === true
136
+ const canStartChatcodeLogin = chatcode?.available === true && !hasChatcodeAccount
131
137
  && !(chatcode.configured === true && chatcode.validation === 'unavailable')
132
138
 
133
139
  return (
@@ -144,15 +150,15 @@ export function AccountMenu(): JSX.Element {
144
150
  <SettingsIcon />
145
151
  {t('accountMenuSettings')}
146
152
  </button>
147
- {error && <div role="alert" className={css.accountItem}>{t(error === 'status' ? 'accountMenuChatcodeStatusError' : 'accountMenuChatcodeError')}</div>}
153
+ {error === 'operation' && <div role="alert" className={css.accountItem}>{t('accountMenuChatcodeError')}</div>}
148
154
  {(chatcode?.login?.state === 'failed' || chatcode?.login?.state === 'timed-out') && <div role="alert" className={css.accountItem}>{t('accountMenuChatcodeRetry')}</div>}
149
155
  {loginUrl && <a data-dsh-external="true" className={css.accountItem} href={loginUrl} target="_blank" rel="noopener noreferrer" onClick={event => { event.stopPropagation() }}>{t('accountMenuChatcodeOpen')}</a>}
150
156
  {chatcode?.available === true && <>
151
157
  {canStartChatcodeLogin && <button type="button" role="menuitem" className={css.accountItem} disabled={busy} onClick={startLogin}>
152
158
  <LogoutIcon />{busy ? t('accountMenuChatcodeStarting') : t('accountMenuChatcodeLogin')}
153
159
  </button>}
154
- {chatcode.configured && <button type="button" role="menuitem" className={css.accountItem} disabled={busy} onClick={logoutChatcode}>
155
- <LogoutIcon />{t('accountMenuChatcodeLogout')}
160
+ {hasChatcodeAccount && <button type="button" role="menuitem" className={css.accountItem} disabled={busy} onClick={logoutChatcode}>
161
+ <LogoutIcon />{t('accountMenuLogout')}
156
162
  </button>}
157
163
  </>}
158
164
  {chatcode?.available === false && <button type="button" role="menuitem" className={css.accountItem} onClick={logout}>
@@ -2,6 +2,7 @@
2
2
  import { useEffect, useRef } from 'react'
3
3
  import type { ChatCodeAuthState } from './api.ts'
4
4
  import { useChatcodeAccount } from './useChatcodeAccount.ts'
5
+ import { redirectToChatcodeLogin } from './chatcode-login-route.ts'
5
6
 
6
7
  type GateDecision = 'allow' | 'login' | 'wait'
7
8
 
@@ -28,8 +29,7 @@ export function ChatcodeGate(): JSX.Element | null {
28
29
  // this visit has passed that check, a later company-network outage does
29
30
  // not interrupt ongoing work.
30
31
  if (chatcodeGateDecision(chatcode, verifiedThisVisit.current) !== 'login') return
31
- const back = `${window.location.pathname}${window.location.search}${window.location.hash}`
32
- window.location.replace(`/sidebar/login?return=${encodeURIComponent(back)}`)
32
+ redirectToChatcodeLogin()
33
33
  }, [chatcode])
34
34
  return null
35
35
  }
@@ -64,6 +64,7 @@ import { OrphanedTab } from './OrphanedTab.tsx'
64
64
  import { RenderBoundary } from './RenderBoundary.tsx'
65
65
  import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts'
66
66
  import { t } from './locales.ts'
67
+ import { useCurrentSessionId } from './current-session.ts'
67
68
  import { api, type SessionScope } from './api.ts'
68
69
  import { AccountMenu } from './AccountMenu.tsx'
69
70
  import css from './sidebar.module.css'
@@ -322,7 +323,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
322
323
  useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
323
324
  useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
324
325
  )
325
- const current = sessionList.current
326
+ const current = useCurrentSessionId(ctx)
326
327
 
327
328
  // Per-session sidebar state.
328
329
  const snapshot = useSyncExternalStore(
@@ -1385,6 +1386,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
1385
1386
  // focus is the only way the existing Tooltip can explain what is missing.
1386
1387
  return (
1387
1388
  <div data-dsh-panel-host {...osFileDragShield}>
1389
+ <AccountMenu />
1388
1390
  {nativeToolbarHost !== null ? createPortal(
1389
1391
  <div className={clsx(css.toggleCluster, css.toggleClusterNative)} data-dsh-toggle-cluster data-dsh-native-toolbar-cluster>
1390
1392
  {!narrow && (
@@ -0,0 +1,11 @@
1
+ /** Build the local ChatCode login gate URL while preserving the current page. */
2
+ export function chatcodeLoginRoute(location: Pick<Location, 'pathname' | 'search' | 'hash'>): string {
3
+ const back = `${location.pathname}${location.search}${location.hash}`
4
+ return `/sidebar/login?return=${encodeURIComponent(back)}`
5
+ }
6
+
7
+ /** Enter the existing local login flow after account state cannot be read. */
8
+ export function redirectToChatcodeLogin(location: Location = window.location): void {
9
+ if (location.pathname === '/sidebar/login') return
10
+ location.replace(chatcodeLoginRoute(location))
11
+ }
@@ -0,0 +1,22 @@
1
+ import { useCallback, useSyncExternalStore } from 'react'
2
+ import type { Context } from '../context-types.ts'
3
+
4
+ /**
5
+ * Read the session selected by the host's scoped-session adapter.
6
+ *
7
+ * Newer hosts deliberately removed `sessions.list.current`: selection is UI
8
+ * scope state, not catalog state. The adapter is authoritative on both old
9
+ * and new hosts and tracks main-view retain/release transitions.
10
+ */
11
+ export function currentSessionId(ctx: Context): string | undefined {
12
+ return ctx.uiSession.adapter.current.getSnapshot().key
13
+ }
14
+
15
+ /** React subscription to the same authoritative current-session source. */
16
+ export function useCurrentSessionId(ctx: Context): string | undefined {
17
+ const source = ctx.uiSession.adapter.current
18
+ return useSyncExternalStore(
19
+ useCallback(listener => source.subscribe(listener), [source]),
20
+ useCallback(() => source.getSnapshot().key, [source]),
21
+ )
22
+ }
@@ -43,7 +43,7 @@ import { apply as applyUiTweaksClient } from './ui-tweaks/index.tsx'
43
43
  * locale service backs the sidebar's copy — see locales.ts). `modules`
44
44
  * (rc.8+) is the client module system the chunk loader resolves its
45
45
  * externals through — Cordis guards service access without inject. */
46
- export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'modules', 'commandUi', 'conversation']
46
+ export const inject = ['slots', 'sessions', 'uiSession', 'workspaces', 'locale', 'modules', 'commandUi', 'conversation']
47
47
 
48
48
  /**
49
49
  * Error boundary over the sidebar tree (root scope): a render error in the
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Interception of the chat's produced-files row: the turn-tail chain entry
3
- * that replaces ui-deliverables' row when the closing turn produced files.
4
- * The takeover looks identical (same chip row); the chips open the file in
5
- * the sidebar instead of the host OS. Priority -1 runs before the default-0
6
- * deliverables entry; when nothing was produced the selector returns null
7
- * and the original row renders unchanged.
2
+ * Contribution of the chat's produced-files row. Current Hosts expose the
3
+ * turn-tail surface as a list; older Hosts exposed it as a chain. The
4
+ * registration supplies both shapes' routing fields so one bundle remains
5
+ * compatible across that transition. The chips open the file in the sidebar
6
+ * instead of the host OS.
8
7
  */
9
8
  import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
10
9
  import type { Context } from '../context-types.ts'
@@ -12,6 +11,7 @@ import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.
12
11
  import { t } from './locales.ts'
13
12
  import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
14
13
  import { wrapOpenPath } from './openpath-intercept.ts'
14
+ import { currentSessionId } from './current-session.ts'
15
15
  import css from './sidebar.module.css'
16
16
 
17
17
  /** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
@@ -113,11 +113,33 @@ export function SidebarProducedFiles(props: {
113
113
  )
114
114
  }
115
115
 
116
+ interface SidebarProducedFilesEntryProps {
117
+ /** Present only when an older chain slot selected the owner before render. */
118
+ matched?: readonly string[]
119
+ store: SidebarStore
120
+ openInSidebar: (path: string) => void
121
+ onShowInFolder: (files: readonly string[]) => void
122
+ }
123
+
124
+ /** Adapt both the legacy chain match and the current list owner to the row. */
125
+ export function SidebarProducedFilesEntry(props: SidebarProducedFilesEntryProps): ReturnType<typeof SidebarProducedFiles> | null {
126
+ if (props.store.getSuspended() || props.store.getPrefs().tabsEnabled['editor'] === false) return null
127
+ const matched = props.matched ?? selectProducedFiles(props)
128
+ if (matched === null) return null
129
+ lastProduced = matched
130
+ return <SidebarProducedFiles
131
+ matched={matched}
132
+ openInSidebar={props.openInSidebar}
133
+ onShowInFolder={props.onShowInFolder}
134
+ />
135
+ }
136
+
116
137
  /**
117
138
  * Register the turn-tail interception (returns the disposer).
118
139
  *
119
140
  * The slot is a CHILD slot the host's ui-conversation declares in its
120
- * `conversation.chat.node` children table (kind: chain, scope: session).
141
+ * `conversation.chat.node` children table (currently kind: list, scope:
142
+ * session; older compatible Hosts declared a chain).
121
143
  * Registering it directly races the declaration — the ui-slots core's
122
144
  * load-time validation throws "not declared (a parent entry's children
123
145
  * table must declare it)" when the parent entry is not on the ledger yet.
@@ -130,6 +152,9 @@ export function SidebarProducedFiles(props: {
130
152
  export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
131
153
  return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
132
154
  name: 'conversation.chat.turnTail',
155
+ // Required by the current list slot. Older chain runtimes harmlessly
156
+ // retain this extra field while using `select` below.
157
+ id: 'dsh-better-sidebar:produced-files',
133
158
  // Decline the takeover while the editor tab type is disabled in the side
134
159
  // card settings: the produced-files row falls back to the default
135
160
  // deliverables behavior instead of offering chips that cannot open. Also
@@ -144,10 +169,11 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
144
169
  priority: -1,
145
170
  registrant: 'dsh-better-sidebar',
146
171
  inject: (sessionId: string) => ({
172
+ store,
147
173
  openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
148
174
  onShowInFolder: (files: readonly string[]) => { revealInExplorer(ctx, store, sessionId, files) },
149
175
  }),
150
- }, SidebarProducedFiles))
176
+ }, SidebarProducedFilesEntry))
151
177
  }
152
178
 
153
179
  /**
@@ -165,7 +191,7 @@ export function registerOpenPathInterception(ctx: Context, store: SidebarStore):
165
191
  takeoverEnabled: () => !store.getSuspended()
166
192
  && store.getPrefs().interceptOpenPath !== false
167
193
  && store.getPrefs().tabsEnabled['editor'] !== false,
168
- currentSessionId: () => ctx.sessions.list.getSnapshot().current,
194
+ currentSessionId: () => currentSessionId(ctx),
169
195
  openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
170
196
  revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
171
197
  })
@@ -16,6 +16,7 @@ import { parsePrefs } from './prefs.ts'
16
16
  import { t } from './locales.ts'
17
17
  import { IconPinOutline16 } from './icons.tsx'
18
18
  import css from './sidebar.module.css'
19
+ import { useCurrentSessionId } from './current-session.ts'
19
20
 
20
21
  const PIN_SETTINGS_KEY = 'session-pins'
21
22
 
@@ -221,6 +222,7 @@ function PinnedSessions({ ctx, store, onChange }: {
221
222
  onChange: (ids: readonly string[]) => void
222
223
  }) {
223
224
  const list = useSyncExternalStore(ctx.sessions.list.subscribe, ctx.sessions.list.getSnapshot, ctx.sessions.list.getSnapshot)
225
+ const currentSessionId = useCurrentSessionId(ctx)
224
226
  const prefs = useSyncExternalStore(store.subscribe.bind(store), store.getSnapshot.bind(store), store.getSnapshot.bind(store)).prefs
225
227
  const ids = pinnedSessionIdsOf(prefs)
226
228
  const pinKey = ids.join('\0')
@@ -290,7 +292,7 @@ function PinnedSessions({ ctx, store, onChange }: {
290
292
  <button
291
293
  type="button"
292
294
  className={css.pinnedSessionOpen}
293
- aria-current={session.id === list.current ? 'page' : undefined}
295
+ aria-current={session.id === currentSessionId ? 'page' : undefined}
294
296
  onClick={() => { ctx.sessions.open?.(session.id) }}
295
297
  >
296
298
  <span className={css.pinnedSessionDot} data-running={session.running || undefined} aria-hidden="true" />
@@ -112,7 +112,7 @@ export interface SidebarSlotRegisterOptions {
112
112
  id?: string
113
113
  order?: number
114
114
  label?: string | (() => string)
115
- /** Chain routing selector (returns the matched value, or null to pass on). */
115
+ /** Legacy chain routing selector (returns the matched value, or null to pass on). */
116
116
  select?: (owner: unknown) => unknown
117
117
  priority?: number
118
118
  locale?: string
@@ -306,7 +306,8 @@ export interface SidebarSessionPersistenceService {
306
306
 
307
307
  /** The client session list snapshot the sidebar subscribes to. */
308
308
  export interface SidebarSessionList {
309
- current: string | undefined
309
+ /** @deprecated Selection moved to `uiSession.adapter.current`; old hosts may still expose this field. */
310
+ current?: string
310
311
  byId: Record<string, SidebarSessionSummary>
311
312
  /** Direct durable catalogs keyed by their selected parent address. */
312
313
  subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
@@ -371,6 +372,16 @@ export interface SidebarSessionsService {
371
372
  refreshSubagents?(parentSessionId: string): Promise<void>
372
373
  }
373
374
 
375
+ /** The host's authoritative current Session scope binding. */
376
+ export interface SidebarUiSessionService {
377
+ adapter: {
378
+ current: {
379
+ getSnapshot(): { readonly key: string | undefined }
380
+ subscribe(fn: () => void): () => void
381
+ }
382
+ }
383
+ }
384
+
374
385
  /**
375
386
  * The client locale service face (mirror of @deepseek-ai/dsh-client-locale's
376
387
  * LocaleRuntime — only the slices the sidebar touches). The sidebar follows
@@ -494,6 +505,8 @@ export interface SidebarContextShape {
494
505
  webServer: SidebarWebServer
495
506
  /** The session store (host `.get`) and the client list feed (`.list`) faces. */
496
507
  sessions: SidebarSessionStore & SidebarSessionsService
508
+ /** Current UI Session scope; selection no longer belongs to the Session list catalog. */
509
+ uiSession: SidebarUiSessionService
497
510
  /** The web runtime trust list (bind-derived). */
498
511
  webRuntime: SidebarWebRuntime
499
512
  /** The client slot registry (register/inject). */