1
0

session-provider.tsx 3.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. /** Internal React bindings for the renderer host and active session cell. */
  2. import { createContext, useContext, type ReactNode } from 'react'
  3. import type {
  4. HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook,
  5. } from '@deepseek-ai/dsh-client-ui-slots'
  6. import { bindSnapshotSelector } from './bind.ts'
  7. /**
  8. * A missing-provider assembly error: the shell wired the tree wrong. The slot
  9. * error boundary rethrows this class so misassembly stays fail-loud while
  10. * registrant errors (inject factories, entry components) are contained
  11. * per entry.
  12. */
  13. export class SlotAssemblyError extends Error {}
  14. /** In-package renderer host context. */
  15. export const HostContext = createContext<SlotRendererHost | null>(null)
  16. /**
  17. * Read the installed renderer host; throws outside the rendered root tree
  18. * (framework components must not render detached from the renderer).
  19. * @returns the host surface.
  20. */
  21. export function useHost(): SlotRendererHost {
  22. const host = useContext(HostContext)
  23. if (!host) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree')
  24. return host
  25. }
  26. const BindingContext = createContext<SessionCell | null>(null)
  27. /**
  28. * Read the enclosing session cell; throws outside a SessionProvider subtree
  29. * (session slots must not render without a session).
  30. * @returns the enclosing cell.
  31. */
  32. export function useSessionCell(): SessionCell {
  33. const cell = useContext(BindingContext)
  34. if (!cell) throw new SlotAssemblyError('session slot rendered outside SessionProvider')
  35. return cell
  36. }
  37. /**
  38. * Identity-stable selector hook per host observable. uSES resubscribes when
  39. * the subscribe reference changes, so the bound hook must be created once per
  40. * source — cached here by source identity (sources are host-owned singletons).
  41. * @param source - host-provided observable.
  42. * @returns the cached selector hook.
  43. */
  44. export function observableHook<T>(source: HostObservable<T>): SnapshotSelectorHook<T> {
  45. let hook = hookCache.get(source)
  46. if (hook === undefined) {
  47. hook = bindSnapshotSelector(source)
  48. hookCache.set(source, hook)
  49. }
  50. return hook as SnapshotSelectorHook<T>
  51. }
  52. const hookCache = new WeakMap<object, unknown>()
  53. /** SessionProvider surface: render-prop body plus the no-session branch. */
  54. export interface SessionProviderProps {
  55. /** No-session body (also covers a current id whose session cannot be resolved). */
  56. empty?: (() => ReactNode) | undefined
  57. /** Session body; remounted per session via key={sessionId}. */
  58. children: (sessionId: string) => ReactNode
  59. }
  60. /**
  61. * Framework-wired session area: subscribes to the host's current-session
  62. * source, resolves the session cell, and remounts the body under
  63. * `key={sessionId}` so a session switch rebuilds the session subtree. This
  64. * dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
  65. * branded type at the component boundary.
  66. */
  67. export function SessionProvider({ empty, children }: SessionProviderProps) {
  68. const host = useHost()
  69. const id = observableHook(host.sessions.current)((s) => s)
  70. const cell = id === undefined ? undefined : host.sessions.cell(id)
  71. if (id === undefined || cell === undefined) return <>{empty?.() ?? null}</>
  72. return (
  73. <BindingContext.Provider value={cell} key={id}>
  74. {children(id)}
  75. </BindingContext.Provider>
  76. )
  77. }