Files
apes-sidebar/src/client/intercept.tsx
T
Atlas Zeng 9ad0a49b8a feat(settings): mutual exclusion with the dsh-web-ui aionui-panel provider (v0.13.0) (#181)
Reads the aionui-panel settings namespace's rightPanel provider choice: while
it resolves to 'aionui-panel', the whole sidebar does not mount (host
settings.get reports externalDisable, the client mount gate skips mounting
and the link/open-path/turn-tail takeovers step aside). Live via the
settings/document-updated push (best effort; deployments without the
'remote' service fall back to boot-time evaluation). No new user-facing
setting — the aionui panel card remains the single source of truth.
2026-08-18 00:44:22 +08:00

111 lines
5.1 KiB
TypeScript

/**
* Interception of the chat's produced-files row: the turn-tail chain entry
* that replaces ui-deliverables' row when the closing turn produced files.
* The takeover looks identical (same chip row); the chips open the file in
* the sidebar instead of the host OS. Priority -1 runs before the default-0
* deliverables entry; when nothing was produced the selector returns null
* and the original row renders unchanged.
*/
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import type { SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import { wrapOpenPath } from './openpath-intercept.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: string, path: string): void {
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
const absolute = resolveSidebarPath(summary?.cwd, path)
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
const title = at === -1 ? absolute : absolute.slice(at + 1)
// Route through the sidebar service so the editor descriptor's dedupeKey
// (per-path) applies; the id is path-derived so multiple editors coexist.
ctx.betterSidebar?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
}
/** The intercepted produced-files row (visual twin of the deliverables chips). */
export function SidebarProducedFiles(props: {
matched: readonly string[]
openInSidebar: (path: string) => void
}) {
const { matched, openInSidebar } = props
const shown = matched.slice(0, 6)
const hidden = matched.length - shown.length
return (
<div className={css.producedRow}>
<span className={css.producedLabel}>{t('produced')}</span>
{shown.map(path => {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
const name = at === -1 ? path : path.slice(at + 1)
return (
<button
key={path}
type="button"
className={css.producedChip}
title={path}
onClick={() => { openInSidebar(path) }}
>
<IconCodeOutline16 size={12} />
<span>{name}</span>
</button>
)
})}
{hidden > 0 && <span className={css.producedMore}>+{hidden}</span>}
</div>
)
}
/**
* Register the turn-tail interception (returns the disposer).
*
* The slot is a CHILD slot the host's ui-conversation declares in its
* `conversation.chat.node` children table (kind: chain, scope: session).
* Registering it directly races the declaration — the ui-slots core's
* load-time validation throws "not declared (a parent entry's children
* table must declare it)" when the parent entry is not on the ledger yet.
* slots.inject waits for the declaration: the callback runs synchronously
* when the slot is already declared, otherwise it runs inside the declaring
* register() call once the declaration commits; declaration collapse
* disposes the entry and a later declaration re-registers it. This mirrors
* @deepseek-ai/dsh-client-ui-deliverables' registration of the same slot.
*/
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
name: 'conversation.chat.turnTail',
// Decline the takeover while the editor tab type is disabled in the side
// card settings: the produced-files row falls back to the default
// deliverables behavior instead of offering chips that cannot open. Also
// while the sidebar is externally disabled (aionui-panel chosen).
select: (owner) => {
if (store.getSuspended()) return null
if (store.getPrefs().tabsEnabled['editor'] === false) return null
return selectProducedFiles(owner)
},
priority: -1,
registrant: 'dsh-better-sidebar',
inject: (sessionId: string) => ({
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
}),
}, SidebarProducedFiles))
}
/**
* Register the chat file-open interception: wraps `ctx.workspaces.openPath`
* — the single funnel every chat-side file open goes through (tool-row path
* links, the produced-files row, prose mentions) — so opens land in the
* sidebar editor instead of the Host OS. Gated by BOTH the `interceptOpenPath`
* pref and the editor tab's enable switch; declined opens fall through to
* the original method. Returns the disposer restoring the original (HMR-safe).
*/
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
return wrapOpenPath(ctx.workspaces, {
takeoverEnabled: () => !store.getSuspended()
&& store.getPrefs().interceptOpenPath !== false
&& store.getPrefs().tabsEnabled['editor'] !== false,
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
})
}