Skip to content
Merged
Changes from 1 commit
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
ed4c832
wip(app): extension host, sdk, and desktop main host
Hona Sep 30, 2026
10a3678
wip(app): generic side, dock, and mobile regions
Hona Sep 30, 2026
665e7c2
wip(app): shell bottom slot, channel status, builtin manifests
Hona Sep 30, 2026
c61a994
wip: browser contract, system open for file urls
Hona Sep 30, 2026
b31f579
feat(app): move usage, btw, and debug into gui extensions
Hona Sep 30, 2026
96d5666
feat(app): move the terminal into a gui extension
Hona Sep 30, 2026
55a196e
chore: lint gui extension boundaries and module state
Hona Sep 30, 2026
ce2585b
feat(desktop): move the browser pane into a gui extension
Hona Sep 30, 2026
8bbd280
feat(desktop): move the updater and pairing into gui extensions
Hona Sep 30, 2026
6eb9d3f
feat(app): move the file browser and review into gui extensions
Hona Sep 30, 2026
4fc739f
feat(desktop): move ssh and wsl servers into gui extensions
Hona Sep 30, 2026
49f1837
feat(app): host renderers for settings, status, servers, surfaces, an…
Hona Sep 30, 2026
f5bc705
fix(app): tab selection guard, host project picker, early main extens…
Hona Sep 30, 2026
adf0423
feat(app): move the session summary into a gui extension
Hona Sep 30, 2026
7c2235e
refactor(app): move extension translations into extension folders
Hona Sep 30, 2026
ff4a8e6
fix(app): keep the app mounted when an extension is enabled
Hona Sep 30, 2026
78f878c
fix(app): import extension storage from the default storage too
Hona Sep 30, 2026
ab14739
fix(app): close extension dialogs by id and stop hidden summary loads
Hona Sep 30, 2026
6154dc8
test(desktop): prune the desktop test surface
Hona Sep 30, 2026
8b7f10b
fix(ui): dispose each closing dialog on its own timer
Hona Sep 30, 2026
2c8a70c
fix(ui): guard dialog closes and derive layers from the stack
Hona Sep 30, 2026
8dcbae7
test(desktop): restore contracts the prune dropped
Hona Sep 30, 2026
94bb7f2
fix(ui): harden dialog teardown, errors and focus during exits
Hona Sep 30, 2026
35b14b3
fix(ui): tell replaced dialogs they closed; polyfill Promise.try
Hona Sep 30, 2026
3b0481e
refactor(app): leave app and desktop clean after the extension move
Hona Sep 30, 2026
33da15a
test(app): shared e2e harness for the test campaign
Hona Sep 30, 2026
ed33675
docs: test ownership rules for app, desktop and gui extensions
Hona Sep 30, 2026
cc95fd3
fix(app): normalize stored panel tab ids through the owning extension
Hona Sep 30, 2026
da7759d
fix(app): remap stored tabs, selection and preview together
Hona Sep 30, 2026
3978a5e
test(session-ui): run component tests in CI and bring them current
Hona Sep 30, 2026
37456ad
fix(app): keep a tab permanent when a preview collapses into it
Hona Sep 30, 2026
3033748
test(app): tighten the e2e harness mocks
Hona Sep 30, 2026
7da3c91
test(app): prune the composer and command test surface
Hona Sep 30, 2026
8dba9ae
fix(app): name the session's server in the summary when several exist
Hona Sep 30, 2026
b07e633
test(app): prune the gui extension test surface
Hona Sep 30, 2026
a8d4d1e
test(app): scope mock PTYs by workspace and issue unique tickets
Hona Sep 30, 2026
a242629
test(app): restore composer contracts the prune dropped
Hona Sep 30, 2026
85ba40f
test(app): restore extension contracts the prune dropped
Hona Sep 30, 2026
fd2537f
test(app): prune the shell, runtime and settings test surface
Hona Sep 30, 2026
04f8481
test(app): prune the session timeline test surface
Hona Sep 30, 2026
8a37e31
test(app): restore shell contracts the prune dropped
Hona Sep 30, 2026
f478d67
test(app): restore timeline contracts the prune dropped
Hona Sep 30, 2026
86cd53d
test(app): finish the shared e2e harness
Hona Sep 30, 2026
e649c08
test(app): answer the review pane benchmark in the server's diff shape
Hona Sep 30, 2026
c82606b
perf(app): compile extension chunks while the app idles
Hona Sep 30, 2026
2f8dfa1
perf(review): reuse the model's renderable diffs and kinds in the panel
Hona Sep 30, 2026
3ec96fb
perf(review): keep the tree still when a session switch reopens revie…
Hona Sep 30, 2026
6bca5b3
test(app): fail unmocked API requests and tighten harness waits
Hona Sep 30, 2026
6cf58d9
test(app): page mock shell output like the server and acknowledge fin…
Hona Sep 30, 2026
acc9415
refactor(util): share file URL encoding and file kinds
Hona Sep 30, 2026
2fe6172
Merge upstream/v2 into gui-extensions
Hona Sep 30, 2026
6ce809a
chore(app): merge duplicate util path imports
Hona Sep 30, 2026
84e0bc0
fix(app): keep browser comments from pre-extension builds
Hona Sep 30, 2026
afddc7c
test(browser): restore the browser pane's tests
Hona Sep 30, 2026
da3a28c
fix(browser): keep element refs unique across reloads and notes editable
Hona Sep 30, 2026
c84e9b4
test(browser): deliver the stale event in the suspension case
Hona Sep 30, 2026
7ab3087
fix(storybook): restore every designer story with its id
Hona Sep 30, 2026
120f801
fix(storybook): run the real SSH renderer in its stories
Hona Sep 30, 2026
0cee67e
fix(app): stop activating an extension after the host unmounts
Hona Sep 30, 2026
8726611
fix(app): run cleanups an extension registers after it is disposed
Hona Sep 30, 2026
d0f249e
style: format files the extension move added
Hona Sep 30, 2026
5eff053
chore(lint): vendor anti-slop Oxlint rules for the GUI packages
Hona Sep 30, 2026
e2c5866
fix(desktop): refuse the extension manager in packaged builds
Hona Sep 30, 2026
4a27b0b
docs(desktop): mark archive installs as groundwork for .ocdx extensions
Hona Sep 30, 2026
7b8f423
fix(review): keep each session's review state across the upgrade
Hona Sep 30, 2026
4942243
fix(extensions): make reload, disable and dialogs safe mid-activation
Hona Sep 30, 2026
fc1ac2a
fix(extensions): finish teardown before a replacement activates
Hona Oct 1, 2026
a4649b5
fix(app): follow a restarted server's live controller
Hona Oct 1, 2026
64f533f
refactor(desktop): serialize each extension's lifecycle
Hona Oct 1, 2026
85118c8
fix(app): remount a server's routes when sign-in replaces its controller
Hona Oct 1, 2026
b0e8f7b
fix(browser): re-register panes when the browser remote returns
Hona Oct 1, 2026
f0a9e22
fix(app): keep a disposed extension's queued dialog closed
Hona Oct 1, 2026
46409aa
fix(browser): wait for every page to close before the pane settles
Hona Oct 1, 2026
f630927
fix(app): let remote events win over older snapshots
Hona Oct 1, 2026
f3bc1e3
test(app): let the extension host fixture disable and reload
Hona Oct 1, 2026
9e34a23
fix(browser): retry a rejected pane registration
Hona Oct 1, 2026
c37c70d
fix(app): keep the app mounted through extension server reloads
Hona Oct 1, 2026
85924c5
fix(wsl): stop starting servers on teardown
Hona Oct 1, 2026
74555b9
fix(app): keep extension servers' tabs through an outage
Hona Oct 1, 2026
e24dc88
fix(summary): show the global-sync project like v2
Hona Oct 1, 2026
5ae5ded
fix(app): close the details drawer from its changes row like v2
Hona Oct 1, 2026
9dd0186
fix(app): restore v2's Context tab order and saved tree tab
Hona Oct 1, 2026
39910ec
fix(app): realign native desktop labels in hr, hu, is and lt
Hona Oct 1, 2026
fdd5cc3
fix(app): restore v2's side region opener and mobile view reset
Hona Oct 1, 2026
62a8129
fix(app): restore v2's mobile changes, browser pruning and file heading
Hona Oct 1, 2026
dab184a
fix(btw): drop a /btw tab saved before extensions
Hona Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(ui): guard dialog closes and derive layers from the stack
A dialog is marked closing before its callbacks run, so a close that re-enters from onClose cannot start a second timer. Escape, backdrop clicks and Kobalte dismissals share one guarded path that only closes the top dialog, one per exit animation; closes by id stay immediate. Layers follow the live stack position, so a new dialog always renders above older ones, and the provider disposes remaining dialogs when it unmounts.
  • Loading branch information
Hona committed Sep 30, 2026
commit 2c8a70cc41272ce0d2b798d981e299f03cd0cbb6
72 changes: 43 additions & 29 deletions packages/ui/src/context/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,51 +37,68 @@ export function useDialogLayer() {

function init() {
const [stack, setStack] = createSignal<Active[]>([])
// Each closing dialog disposes after its own exit animation.
const timers = new Map<string, ReturnType<typeof setTimeout>>()
// A dialog is closing from the moment its close starts until its exit animation ends and it is disposed.
const closing = new Map<string, ReturnType<typeof setTimeout> | undefined>()
const lock = { value: false }
const clearTimers = () => {
timers.forEach((timer) => clearTimeout(timer))
timers.clear()

const disposeAll = () => {
closing.forEach((timer) => clearTimeout(timer))
closing.clear()
stack().forEach((item) => item.dispose())
}

onCleanup(clearTimers)
onCleanup(disposeAll)

const close = (id?: string) => {
const items = stack()
const current = id ? items.find((item) => item.id === id) : items.at(-1)
// One Escape or backdrop click closes one dialog; closing a dialog by id never waits for another.
if (!current || timers.has(current.id) || (!id && lock.value)) return
lock.value = true
const finish = (current: Active) => {
current.onClose?.()
current.setClosing(true)
timers.set(
closing.set(
current.id,
setTimeout(() => {
timers.delete(current.id)
current.dispose()
setStack((items) => items.filter((item) => item.id !== current.id))
if (timers.size === 0) lock.value = false
closing.delete(current.id)
if (closing.size === 0) lock.value = false
}, 100),
)
}

/** Programmatic close. Without an id it closes the top dialog, one at a time; with an id it never waits. */
const close = (id?: string) => {
const current = id ? stack().find((item) => item.id === id) : stack().at(-1)
if (!current || closing.has(current.id) || (!id && lock.value)) return
closing.set(current.id, undefined)
lock.value = true
finish(current)
}

/** Escape, a backdrop click, or Kobalte dismissing: only the top dialog, and one per exit animation. */
const dismiss = (id?: string) => {
const current = stack().at(-1)
if (!current || (id && current.id !== id) || closing.has(current.id) || lock.value) return
closing.set(current.id, undefined)
lock.value = true
finish(current)
}

createEffect(() => {
if (stack().length === 0) return

const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
close()
dismiss()
event.preventDefault()
event.stopPropagation()
}

makeEventListener(window, "keydown", onKeyDown, { capture: true })
})

const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, layer: number, key?: string) => {
const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, key?: string) => {
const id = key ?? Math.random().toString(36).slice(2)
const zIndex = 50 + layer * 10
// The layer follows the dialog's current place in the stack, so a new top dialog always renders above.
const layer = () => Math.max(0, stack().findIndex((item) => item.id === id))
const zIndex = () => String(50 + layer() * 10)
let dispose: (() => void) | undefined
let setClosing: ((closing: boolean) => void) | undefined

Expand All @@ -97,24 +114,23 @@ function init() {
modal={stack().at(-1)?.id === id}
open={!closing()}
onOpenChange={(open: boolean) => {
if (open || stack().at(-1)?.id !== id) return
close(id)
if (!open) dismiss(id)
}}
>
<Kobalte.Portal>
<Kobalte.Overlay
data-component="dialog-overlay"
style={{ "z-index": String(zIndex) }}
style={{ "z-index": zIndex() }}
onClick={() => {
if (backdropDismiss()) close(id)
if (backdropDismiss()) dismiss(id)
}}
/>
<div
data-dialog-layer={layer}
data-dialog-layer={layer()}
style={{
position: "fixed",
inset: "0",
"z-index": String(zIndex),
"z-index": zIndex(),
display: "flex",
"align-items": "center",
"justify-content": "center",
Expand All @@ -137,15 +153,14 @@ function init() {

const push = (element: DialogElement, owner: Owner, onClose?: () => void, id?: string) => {
lock.value = false
mount(element, owner, onClose, stack().length, id)
mount(element, owner, onClose, id)
}

const show = (element: DialogElement, owner: Owner, onClose?: () => void, id?: string) => {
for (const item of stack()) item.dispose()
disposeAll()
setStack([])
clearTimers()
lock.value = false
mount(element, owner, onClose, 0, id)
mount(element, owner, onClose, id)
}

return {
Expand All @@ -155,7 +170,6 @@ function init() {
push,
}
}

export function DialogProvider(props: ParentProps) {
const ctx = init()
return (
Expand Down