Files
deepseek-harness/packages/experimental/client-ui-forge/src/client/mount.ts
T

352 lines
17 KiB
TypeScript

/** Remote contribution and slot registrations of the forge panel. */
import type { Context } from '@deepseek-ai/cordis'
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import { IconBranchOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import type {} from '@deepseek-ai/dsh-experimental-client-ui-forge/remote'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame, ForgePanelChangeRequest } from '../types.ts'
import { ReviewQueue } from './review-queue.ts'
import { formatReviewComment, type ReviewSelection } from './review.ts'
import { ActionToast } from './ActionToast.tsx'
import { CHANGES_ID, changesDefinition } from './changes-address.ts'
import { createChangesStore } from './changes-store.ts'
import { ChangesTab, type ChangesTabInjected } from './ChangesTab.tsx'
import { ChangesTabTitle } from './ChangesTabTitle.tsx'
import { ForgeTab, type ForgeTabInjected } from './ForgeTab.tsx'
import { ForgeTabTitle } from './ForgeTabTitle.tsx'
import { HeaderButton, type HeaderButtonInjected } from './HeaderButton.tsx'
import { de, en, es, fr, ja, NS, pt, ru, zh, type ForgePanelKey } from './locales.ts'
import type { SessionListInjected } from './session-list.ts'
import { SessionHoverForge } from './SessionHoverForge.tsx'
import { SessionRowForge } from './SessionRowForge.tsx'
import {
createNoticeSource, createPanelSource, createStreamSource, createVisibilitySource, foldStates, type PanelSource, type VisibilitySource,
} from './source.ts'
import { numberOf } from './status.ts'
import { WorktreeProgress, type WorktreeProgressInjected } from './WorktreeProgress.tsx'
import { WorktreeToggle, type WorktreeToggleInjected } from './WorktreeToggle.tsx'
/** Services required before the Remote contribution mounts. */
export const inject = ['remote', 'slots', 'locale', 'sidebarRightTabs', 'sidebarRight', 'sessions']
/** Registry id of the forge tab type, also its slot key. */
export const TAB_ID = '@deepseek-ai/dsh-experimental-client-ui-forge'
/** Kind of the forge tab. */
export const TAB_KIND = 'forge-panel'
/**
* Open a URL in a new browsing context without giving it access to this page.
* @param url - change request web URL.
*/
function openLink(url: string): void {
window.open(url, '_blank', 'noopener,noreferrer')
}
/** Per-Session stores shared by the tab, the header button, the New Session option, and the worktree card. */
interface SessionStores {
readonly panel: PanelSource
readonly visibility: VisibilitySource
readonly reviews: WeakMap<AbortSignal, ReviewQueue>
readonly release: () => void
}
function registerUi(ctx: Context): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'forge-panel: dictionaries')
const t = ctx.locale.bind(NS)
const notices = createNoticeSource()
const stores = new Map<SessionId, SessionStores>()
ctx.effect(() => () => {
for (const store of [...stores.values()]) store.release()
stores.clear()
}, 'forge-panel: panel streams')
const storesOf = (sessionId: SessionId): SessionStores => {
let store = stores.get(sessionId)
if (store === undefined) {
const scope = ctx.sessions.scope(sessionId)
if (scope === undefined) throw new Error(`forge-panel: Session ${sessionId} is not retained`)
const panel = createPanelSource({
open: signal => ctx.remote.forgePanel.follow(sessionId, signal),
subscribeReset: listener => ctx.on('connection/reset', listener),
})
const dispose = scope.effect(() => () => {
panel.close()
stores.delete(sessionId)
}, 'forge-panel: release Session state')
store = { panel, visibility: createVisibilitySource(), reviews: new WeakMap(), release: () => { void dispose() } }
stores.set(sessionId, store)
}
return store
}
const settled = (result: RemoteResult<unknown>, failure: ForgePanelKey, codes: Partial<Record<string, ForgePanelKey>> = {}): boolean => {
if (result.ok) return true
notices.report(codes[result.error.code] ?? failure)
return false
}
const copy = (text: string, copied: ForgePanelKey): void => {
void navigator.clipboard.writeText(text).then(
() => { notices.report(copied, { tone: 'success' }) },
() => { notices.report('toast.copyFailed') },
)
}
const copyText = (text: string): void => { copy(text, 'toast.copied') }
const copyPath = (path: string): void => { copy(path, 'toast.pathCopied') }
/** Send the request for a change request to the Session as a message from the user. */
const createChangeRequest = (sessionId: SessionId): void => {
const text = t('changes.local.prompt')
const conversation = ctx.sessions.scope(sessionId)?.get('conversation')
if (conversation === undefined) {
notices.report('toast.promptFailed')
return
}
void conversation.send(text).then(
() => { notices.report('toast.prompted', { tone: 'success', params: { text } }) },
() => { notices.report('toast.promptFailed') },
)
}
const addReviewToInput = (sessionId: SessionId, selection: ReviewSelection): boolean => {
const scope = ctx.sessions.scope(sessionId)
const conversation = scope?.get('conversation')
if (scope === undefined || conversation === undefined) return false
const input = conversation.input.for(scope)
const numbers = Array.from(input.state.getSnapshot().draft.matchAll(/(?:^|\n)(?:> )?## User Comment (\d+)/g), match => Number(match[1]))
if (!input.insertQuote(formatReviewComment(selection, Math.max(0, ...numbers) + 1))) return false
input.focus()
return true
}
/** How toasts name a change request: its number, else its title, else its URL. */
const nameOf = (entry: ForgePanelChangeRequest): string => {
const number = numberOf(entry)
return number === null ? entry.snapshot?.title ?? entry.url : t('change.number', { number })
}
const remove = (sessionId: SessionId, entry: ForgePanelChangeRequest): void => {
void ctx.remote.forgePanel.dismiss(sessionId, entry.url).then((result) => {
if (!settled(result, 'toast.removeFailed')) return
notices.report('toast.removed', {
tone: 'success',
params: { name: nameOf(entry) },
// Restoring needs a provider that recognizes the URL; an unrecognized one is removed without undo.
...entry.ref === null ? {} : {
undo: () => {
void ctx.remote.forgePanel.track(sessionId, entry.url).then(restored => settled(restored, 'toast.undoFailed'))
},
},
})
})
}
const host: HostObservable<RemoteHostFacts> = {
getSnapshot: () => ctx.remote.$host,
subscribe: listener => ctx.on('connection/reset', listener),
}
const restore = async (sessionId: SessionId): Promise<string | undefined> => {
const result = await ctx.remote.forgePanel.restoreWorktree(sessionId)
if (!result.ok) return result.error.message
notices.report('toast.restored', { tone: 'success' })
return undefined
}
const locale: HostObservable<string> = {
getSnapshot: () => ctx.locale.getLocale().active,
subscribe: listener => ctx.locale.subscribe(listener),
}
const tabFace = (sessionId: SessionId): ForgeTabInjected => {
const store = storesOf(sessionId)
return {
hooks: { panel: store.panel.hooks.panel, host, locale },
reconnect: store.panel.reconnect,
refresh: async () => {
settled(await ctx.remote.forgePanel.refresh(sessionId), 'toast.refreshFailed')
},
remove: (entry) => { remove(sessionId, entry) },
readChecks: async (url, headSha, signal) => {
const result = await ctx.remote.forgePanel.changeRequestChecks(sessionId, url, headSha, signal)
if (!result.ok) throw new Error(result.error.message)
return result.value
},
markReady: async (entry) => {
const result = await ctx.remote.forgePanel.markReady(sessionId, entry.url)
if (result.ok) notices.report('toast.markedReady', { tone: 'success', params: { name: nameOf(entry) } })
else notices.report('toast.markReadyFailed', { params: { message: result.error.message } })
},
merge: async (entry, request) => {
const result = await ctx.remote.forgePanel.merge(sessionId, entry.url, request.method, request.headSha)
if (result.ok) notices.report('toast.merged', { tone: 'success', params: { name: nameOf(entry) } })
else if (result.error.code === 'forgePanel/stale-head') notices.report('toast.mergeStale', { params: { name: nameOf(entry) } })
else notices.report('toast.mergeFailed', { params: { message: result.error.message } })
},
restore: () => {
void restore(sessionId).then((message) => {
if (message !== undefined) notices.report('toast.restoreFailed', { params: { message } })
})
},
openLink,
copyText,
copyPath,
reportVisible: store.visibility.set,
createChangeRequest: () => { createChangeRequest(sessionId) },
}
}
const changesFace = (sessionId: SessionId): ChangesTabInjected => {
const store = storesOf(sessionId)
return {
keyedHooks: { panel: visibility => visibility === 'visible' ? store.panel.hooks.panel : undefined },
reconnect: store.panel.reconnect,
localDiff: (scope, signal) => ctx.remote.forgePanel.localDiff(sessionId, scope, signal),
localText: (scope, path) => ctx.remote.forgePanel.localText(sessionId, scope, path),
changeRequestDiff: (url, signal) => ctx.remote.forgePanel.changeRequestDiff(sessionId, url, signal),
changeRequestText: (url, commit, path) => ctx.remote.forgePanel.changeRequestText(sessionId, url, commit, path),
addReviewToInput: selection => addReviewToInput(sessionId, selection),
reviewQueue: ({ id, signal }) => {
let queue = store.reviews.get(signal)
if (queue === undefined) {
const records = new ReviewQueue(`dsh.forge-review.tab.v1.${JSON.stringify([sessionId, id])}`)
queue = records
store.reviews.set(signal, records)
const clear = (): void => { if (!records.close()) notices.report('review.saveFailed') }
if (signal.aborted) clear()
else {
const release = ctx.effect(() => {
const close = (): void => { clear(); void release() }
signal.addEventListener('abort', close, { once: true })
return () => { signal.removeEventListener('abort', close) }
}, 'forge-panel: review tab lifetime')
}
}
return queue
},
submitReview: (url, request) => ctx.remote.forgePanel.submitReview(sessionId, url, request),
openLink,
copyPath,
createChangeRequest: () => { createChangeRequest(sessionId) },
report: (key, params) => {
notices.report(key, { tone: key === 'review.published' || key === 'review.submitted' ? 'success' : 'failure',
...params === undefined ? {} : { params } })
},
}
}
const progressFace = (sessionId: SessionId): WorktreeProgressInjected => {
const store = storesOf(sessionId)
return {
hooks: { panel: store.panel.hooks.panel },
stop: () => {
void ctx.remote.forgePanel.stopCreation(sessionId).then(result => settled(result, 'toast.worktreeFailed'))
},
retry: () => {
void ctx.remote.forgePanel.retryWorktree(sessionId).then(result => settled(result, 'toast.worktreeFailed'))
},
withdraw: () => {
void ctx.remote.forgePanel.cancelWorktree(sessionId).then(result => settled(result, 'toast.worktreeFailed'))
},
restore: () => restore(sessionId),
leave: () => {
void ctx.remote.forgePanel.continueInProject(sessionId).then(result => settled(result, 'toast.leaveFailed'))
},
}
}
const worktreeFace = (sessionId: SessionId): WorktreeToggleInjected => {
const worktreeCodes: Partial<Record<string, ForgePanelKey>> = { 'forgePanel/session-started': 'toast.worktreeStarted' }
const store = storesOf(sessionId)
return {
hooks: { panel: store.panel.hooks.panel },
request: async from => settled(await ctx.remote.forgePanel.requestWorktree(sessionId, from), 'toast.worktreeFailed', worktreeCodes),
cancel: async () => settled(await ctx.remote.forgePanel.cancelWorktree(sessionId), 'toast.worktreeFailed', worktreeCodes),
branches: async () => {
const result = await ctx.remote.forgePanel.branches(sessionId)
return settled(result, 'toast.branchesFailed') && result.ok ? result.value : undefined
},
}
}
ctx.effect(() => ctx.sidebarRightTabs.register({
id: TAB_ID, kind: TAB_KIND, title: () => t('tab.title'),
guide: [{
id: 'review', order: 15, title: () => t('guide.title'), description: () => t('guide.description'), icon: IconBranchOutlineRegular,
}],
}), 'forge-panel: sidebar tab')
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
name: 'sidebar.right.pane.tab', key: TAB_ID, locale: NS, inject: tabFace,
}, ForgeTab))
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
name: 'sidebar.right.pane.tab.title', key: TAB_ID, locale: NS,
}, ForgeTabTitle))
ctx.effect(() => ctx.sidebarRightTabs.register(changesDefinition(t)), 'forge-panel: changes tab')
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
name: 'sidebar.right.pane.tab', key: CHANGES_ID, locale: NS, store: createChangesStore(), inject: changesFace,
}, ChangesTab))
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
name: 'sidebar.right.pane.tab.title', key: CHANGES_ID, locale: NS,
}, ChangesTabTitle))
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
name: 'conversation.session.header.utilities',
id: 'forge-panel',
// Left of the schedule entry (-5) and the overflow menu (0), right of open-in-app (-10).
order: -8,
locale: NS,
inject: (sessionId: SessionId): HeaderButtonInjected => {
const store = storesOf(sessionId)
return {
hooks: { panel: store.panel.hooks.panel, visible: store.visibility.hooks.visible },
toggle: (visible) => {
if (!visible) { ctx.sidebarRight.openTab(TAB_KIND); return }
const tab = ctx.sidebarRight.tabsIn(sessionId).find(tab => tab.kind === TAB_KIND)
if (tab === undefined) return
ctx.sidebarRight.hide(tab.id)
},
}
},
}, HeaderButton))
ctx.slots.inject('conversation.hero.options', () => ctx.slots.register({
name: 'conversation.hero.options', id: 'forge-panel.worktree', locale: NS, inject: worktreeFace,
}, WorktreeToggle))
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
name: 'conversation.input.dock', id: 'forge-panel.worktree', order: 5, locale: NS, inject: progressFace,
}, WorktreeProgress))
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay', id: 'forge-panel.toast', locale: NS,
inject: () => ({ hooks: notices.hooks, dismiss: notices.dismiss }),
}, ActionToast))
// One stream of change request states serves every Session row while any row reads it.
const states = createStreamSource<ForgeChangeRequestStatesFrame, ReadonlyMap<string, ForgeChangeRequestListState>>({
open: signal => ctx.remote.forgePanel.changeRequestStates(signal),
subscribeReset: listener => ctx.on('connection/reset', listener),
}, new Map(), foldStates)
ctx.effect(() => () => { states.close() }, 'forge-panel: change request states')
const listFace = (): SessionListInjected => ({ hooks: { states: states.observable } })
ctx.slots.inject('sidebar.session.row.trailing', () => ctx.slots.register({
name: 'sidebar.session.row.trailing', id: 'forge-panel.row', order: 10, locale: NS, inject: listFace,
}, SessionRowForge))
ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({
name: 'sidebar.session.row.hover', id: 'forge-panel.row', order: 20, locale: NS, inject: listFace,
}, SessionHoverForge))
}
/**
* Mount the forge panel Remote, then the UI that calls it; the UI is withdrawn before the Remote.
* @param ctx - client plugin lifetime.
* @param contribution - generated forge panel Remote definitions.
* @returns the disposer that removes both contributions.
*/
export async function mountForgePanel(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise<void>> {
const unmountRemote = await ctx.remote.$mount(contribution)
const ui = ctx.inject(['remote.forgePanel', ...inject.slice(1)], registerUi)
try {
await ui
} catch (error) {
await ui.dispose()
await unmountRemote()
throw error
}
return async () => {
await ui.dispose()
await unmountRemote()
}
}