/** * The Session Graph tab body: a scope-bound lineage forest of Canvas Sessions * (Branch edges within clusters, Merge provenance across clusters, and * Subagent Derivations folded into Subagent Summary badges). Derives from the sessions/workspaces * standard feeds; selection stays in the graph while double-click and the * details panel navigate through the sessions verbs. */ import { useMemo, useRef, useState, type ReactElement } from 'react' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionDigestResult } from '../session-digest.ts' import type { SessionHistoryRequest, SessionHistoryResult } from '../session-history.ts' import type { DiscussionSearchRequest, DiscussionSearchResult } from '../session-search.ts' import type { ResearchTopic, ResearchTopicSnapshot, ResearchTopicWrite } from '../research-topic.ts' import { SESSION_GRAPH_BUILD_LABEL, SESSION_GRAPH_BUILD_TITLE } from './build-info.ts' import { GraphCanvas } from './GraphCanvas.tsx' import { DiscussionSearch } from './DiscussionSearch.tsx' import { ResearchTopics } from './ResearchTopics.tsx' import { KnowledgeProvider, useKnowledge } from './Knowledge.tsx' import type { KnowledgeApi } from './knowledge-remote.ts' import { ResearchReuseEntry, ResearchReuseProvider } from './ResearchReuse.tsx' import type { ResearchReuseApi } from './research-reuse-remote.ts' import { deriveSessionGraph, resolveGraphScope } from './graph-model.ts' import { layoutSessionGraph } from './layout.ts' import { retainDialogFocus } from './dialog-focus.ts' import styles from './GraphView.module.css' import { workingPositionKey } from './working-position.ts' /** Business face the browser entry injects into the view (navigation verbs). */ export interface GraphViewInjected { readonly hostId: string readonly reuse: ResearchReuseApi readonly knowledge: KnowledgeApi topics: { readonly list: (signal: AbortSignal) => Promise readonly read: (request: { readonly topicId: string }, signal: AbortSignal) => Promise readonly write: (request: ResearchTopicWrite, signal: AbortSignal) => Promise } searchDiscussion: (request: DiscussionSearchRequest, signal: AbortSignal) => Promise /** Read addressed discussion text without activating an Agent. */ readSessionHistory: (request: SessionHistoryRequest, signal: AbortSignal) => Promise /** Open one session on its own last view (double-click and panel verb). */ openSession: (id: SessionId) => void /** Create a Branch from one session (the panel's New-branch verb). */ branchSession: (id: SessionId) => Promise /** Explicitly generate or refresh one read-only Session Digest. */ generateSessionDigest: ( id: SessionId, options: { readonly refresh: boolean }, signal: AbortSignal, ) => Promise /** Create, capture, and open one independent Merge Session. */ mergeSessions: ( sourceIds: readonly SessionId[], instruction: string, signal: AbortSignal, ) => Promise /** Retry a failed Merge using its already-created target Session. */ retrySessionMerge: ( targetSessionId: SessionId, sourceIds: readonly SessionId[], instruction: string, signal: AbortSignal, ) => Promise } /** The graph view tab's composed props: runtime share + inject face + locale. */ export type GraphViewProps = ConvViewProps & InjectFace & PropsLocale<'sessionGraph'> /** * Render the session graph tab body. * @param props - the composed view props (standard kit, inject face, locale seat). * @returns the tab body element. */ export function GraphView(props: GraphViewProps): ReactElement { const workspaces = props.useWorkspaces(state => state) return
} function GraphViewBody({ sessionId, useSessions, useSessionPendingInteraction, useWorkspaces, hostId, openSession, branchSession, generateSessionDigest, readSessionHistory, searchDiscussion, mergeSessions, retrySessionMerge, topics, knowledge, reuse, t, }: GraphViewProps): ReactElement { const knowledgeContext = useKnowledge() const sessions = useSessions(state => state) const pendingInteractions = useSessionPendingInteraction(state => state) const workspaces = useWorkspaces(state => state) const [searchOpen, setSearchOpen] = useState(false) const [topicMode, setTopicMode] = useState(false) const [adding, setAdding] = useState() const [topicRevision, setTopicRevision] = useState(0) const addTrigger = useRef() const addToTopic = (id: SessionId): void => { if (document.activeElement instanceof HTMLElement) addTrigger.current = document.activeElement setAdding(id) } const closePicker = (): void => { setAdding(undefined) queueMicrotask(() => { addTrigger.current?.focus() }) } const searchButton = useRef(null) const scope = useMemo( () => resolveGraphScope(sessionId, sessions, workspaces), [sessionId, sessions, workspaces], ) const graph = useMemo( () => deriveSessionGraph(sessions, scope, sessionId, pendingInteractions), [sessions, scope, sessionId, pendingInteractions], ) const laid = useMemo(() => layoutSessionGraph(graph), [graph]) const now = Date.now() const workingKey = workingPositionKey(hostId, scope?.arrangement.key ?? `viewed:${sessionId}`) const searchKey = topicMode ? workingPositionKey(hostId, workingKey, knowledgeContext?.topicId ?? 'topic-list') : workingKey return ( // The free canvas owns its viewport. Extend the view behind the floating // composer so GraphCanvas's live clearance reserves the seat exactly once.
{ if (element !== null) element.inert = searchOpen || adding !== undefined }}>
{topicMode ? t('topic.title') : scope === undefined ? null : scope.kind === 'workspace' ? t('scope.workspaceCount', { name: scope.label, count: graph.sessionCount }) : t('scope.directoryCount', { count: graph.sessionCount })}
{topicMode ? : scope === undefined ?
{t('empty.outside')}
: graph.nodes.size === 0 ?
{t('empty.none')}
: }
{searchOpen ?
{ if (element !== null) element.inert = adding !== undefined }}> { setSearchOpen(false); queueMicrotask(() => { searchButton.current?.focus() }) }} />
: null} {adding === undefined ? null :
{ if (event.key === 'Escape') { event.stopPropagation(); closePicker() } retainDialogFocus(event) }}>

{t('topic.add')}

{adding}

{ setTopicRevision(value => value + 1); closePicker() } }} t={t} />
}
) }