@@ -6,6 +6,7 @@ import type {
66 DevframeDockEntry ,
77 DevframeMessageEntry ,
88 DevframeTerminalSession ,
9+ DevframeViewIframe ,
910} from '@devframes/hub/types'
1011import { connectDevframe , createDevframeClientHost } from '@devframes/hub/client'
1112import { useEffect , useMemo , useRef , useState } from 'react'
@@ -34,6 +35,27 @@ function isRenderableDock(d: DevframeDockEntry): boolean {
3435 return isIframeDock ( d ) || RENDERER_TYPES . has ( d . type )
3536}
3637
38+ // A self-contained document for the client-only dock, rendered from a Blob URL
39+ // so the whole dock is synthesized in the browser with no server route.
40+ function createClientNotesUrl ( ) : string {
41+ const html = `<!doctype html><meta charset="utf-8">
42+ <style>
43+ :root { color-scheme: light dark; }
44+ body { margin: 0; padding: 24px; font: 14px/1.6 system-ui, sans-serif; }
45+ h1 { margin: 0 0 8px; font-size: 16px; }
46+ p { max-width: 54ch; opacity: .85; }
47+ code { padding: 1px 5px; border-radius: 4px; background: rgba(127,127,127,.18); font-size: 12px; }
48+ </style>
49+ <h1>Client-only dock</h1>
50+ <p>This dock was registered in the browser with
51+ <code>host.context.docks.register()</code>. It lives only in this page — it
52+ never enters the <code>devframe:docks</code> shared state, so it is not synced
53+ to the hub server or to any other connected viewer.</p>
54+ <p>Patch it live through the returned handle with <code>update()</code> (its
55+ <code>badge</code> was set that way), or remove it with <code>dispose()</code>.</p>`
56+ return URL . createObjectURL ( new Blob ( [ html ] , { type : 'text/html' } ) )
57+ }
58+
3759/** Render a dock icon, falling back to the title's initial when unmapped. */
3860function DockIcon ( { entry } : { entry : DevframeDockEntry } ) {
3961 const cls = iconClass ( entry . icon )
@@ -80,6 +102,24 @@ export default function Page() {
80102 } )
81103 hostRef . current = clientHost
82104
105+ // Register a *client-only* dock — one this page synthesizes itself.
106+ // Unlike the server-authored docks, it's registered on the client host
107+ // context, so it never enters the `devframe:docks` shared state: it
108+ // stays local to this page and is not synced to the hub server or other
109+ // viewers. It merges into `clientHost.context.docks.entries` alongside
110+ // the server docks. `force` lets React StrictMode re-run this effect
111+ // without tripping the duplicate-id guard.
112+ const clientDock = clientHost . context . docks . register < DevframeViewIframe > ( {
113+ id : 'client-notes' ,
114+ title : 'Client Notes' ,
115+ icon : 'ph:note-pencil-duotone' ,
116+ type : 'iframe' ,
117+ url : createClientNotesUrl ( ) ,
118+ category : 'app' ,
119+ } , true )
120+ // Patch it in place with the returned handle (the id is immutable).
121+ clientDock . update ( { badge : clientHost . context . clientType } )
122+
83123 const docksState = await rpc . sharedState . get < DevframeDockEntry [ ] > (
84124 'devframe:docks' ,
85125 { initialValue : [ ] } ,
@@ -89,7 +129,10 @@ export default function Page() {
89129 { initialValue : [ ] } ,
90130 )
91131
92- const renderDocks = ( ) => setDocks ( [ ...( docksState . value ( ) ?? [ ] ) ] as DevframeDockEntry [ ] )
132+ // The merged list from the client host: server docks (projected from
133+ // `devframe:docks` shared state) plus the client-only dock above. We
134+ // still subscribe to the shared state to re-render on server changes.
135+ const renderDocks = ( ) => setDocks ( [ ...clientHost . context . docks . entries ] )
93136 const renderCommands = ( ) => setCommands ( [ ...( commandsState . value ( ) ?? [ ] ) ] as DevframeCommandEntry [ ] )
94137 docksState . on ( 'updated' , renderDocks )
95138 commandsState . on ( 'updated' , renderCommands )
@@ -122,6 +165,8 @@ export default function Page() {
122165
123166 cleanup = ( ) => {
124167 window . clearInterval ( interval )
168+ // Remove the client-only dock, then tear down the host.
169+ clientDock . dispose ( )
125170 clientHost . dispose ( )
126171 }
127172 }
@@ -224,6 +269,7 @@ export default function Page() {
224269 >
225270 < DockIcon entry = { dock } />
226271 < span className = "truncate" > { dock . title } </ span >
272+ { dock . badge && < span className = "ml-auto shrink-0 rounded bg-active px1 py0.5 text-[0.6rem] font-mono color-base" > { dock . badge } </ span > }
227273 </ button >
228274 </ li >
229275 ) ) }
0 commit comments