Skip to content

Commit 1b148c8

Browse files
committed
fix(diff): reconcile rejected opens with actual pane state
Distinguish unplaced, placed and unknown outcomes after an open rejects so later edits can retry safely and toggles do not blindly open another pane. Keep uncertain state visible to the user, track waiting-pane cleanup, and discard stale pane lookups after a newer opening starts. Preserve the cumulative lifecycle regressions and add rejected-open and reconciliation coverage.
1 parent edb677c commit 1b148c8

5 files changed

Lines changed: 660 additions & 11 deletions

File tree

‎mods/diff/hooks/host/host.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
ProcessRunResult,
1010
SessionMessage,
1111
TimerCall,
12+
UiPane,
1213
} from 'claude-code'
1314

1415
/**
@@ -100,6 +101,12 @@ export type Host = {
100101
*/
101102
closePane: (pane: PaneCloseArgs) => Promise<void>
102103

104+
/**
105+
* `$.ui.panes`: the engine's record of the plugin's open panes, not the
106+
* module's.
107+
*/
108+
panes: () => Promise<readonly UiPane[]>
109+
103110
/**
104111
* `$.command.register`; rejects while another `/diff` is listed.
105112
*/
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './dialog-dismissed-text.js'
22
export * from './panel-hidden-text.js'
33
export * from './panel-shown-text.js'
4+
export * from './panel-state-unknown-text.js'
45

56
export * as default from '.'
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* `/diff`'s transcript line while it cannot tell whether the pane is open:
3+
* never worded as shown or hidden, since neither was observed.
4+
*/
5+
export const PANEL_STATE_UNKNOWN_TEXT =
6+
"The diff panel couldn't tell whether it is shown; run /diff again to check"

‎mods/diff/hooks/register.ts‎

Lines changed: 138 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,12 @@ export function register(on: On) {
5858
attempted: number
5959
release: (ready: boolean) => void
6060
}
61+
type PaneLookup =
62+
| { kind: 'placed' | 'waiting' | 'absent' }
63+
| { kind: 'unknown'; reason: string }
6164
let sessionChange: SessionChange | null = null
6265
let isPaneOpen = false
66+
let isPaneUnknown = false
6367
let openingPane: Promise<boolean> | null = null
6468
let paneCloses = 0
6569
let dialogRows: number | null = null
@@ -487,7 +491,7 @@ export function register(on: On) {
487491
const opening = (openingPane ?? Promise.resolve(false))
488492
.catch(() => false)
489493
.then(() => {
490-
if (epoch !== pin.epoch) {
494+
if (epoch !== pin.epoch || isPaneUnknown) {
491495
return false
492496
}
493497

@@ -533,13 +537,22 @@ export function register(on: On) {
533537
return false
534538
}
535539

536-
const opened = await engine.openPane(
537-
isDialog
538-
? { ...dialogPane(), focus: true }
539-
: { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
540-
)
540+
const closesAtOpen = paneCloses
541+
let isWaiting = false
542+
543+
try {
544+
const opened = await engine.openPane(
545+
isDialog
546+
? { ...dialogPane(), focus: true }
547+
: { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
548+
)
541549

542-
const isWaiting = isRecord(opened) && opened.isPlaced === false
550+
isWaiting = isRecord(opened) && opened.isPlaced === false
551+
} catch (error) {
552+
if (!(await isPlacedAfterRejection(engine, error, epoch, closesAtOpen, trigger))) {
553+
return false
554+
}
555+
}
543556

544557
if (epoch !== pin.epoch) {
545558
const closedBefore = paneCloses
@@ -585,6 +598,112 @@ export function register(on: On) {
585598
return true
586599
}
587600

601+
async function paneOf(engine: Host): Promise<PaneLookup> {
602+
let panes: unknown
603+
604+
try {
605+
panes = await engine.panes()
606+
} catch (error) {
607+
return { kind: 'unknown', reason: messageOf(error) }
608+
}
609+
610+
if (!Array.isArray(panes)) {
611+
return { kind: 'unknown', reason: 'the engine listed no panes' }
612+
}
613+
614+
const pane = panes
615+
.filter(isRecord)
616+
.find(listed => listed.id === Names.PANE_ID)
617+
618+
if (!pane) {
619+
return { kind: 'absent' }
620+
}
621+
622+
return typeof pane.isPlaced === 'boolean'
623+
? { kind: pane.isPlaced ? 'placed' : 'waiting' }
624+
: { kind: 'unknown', reason: 'the engine did not say whether the diff panel is placed' }
625+
}
626+
627+
async function isPlacedAfterRejection(
628+
engine: Host,
629+
error: unknown,
630+
epoch: number,
631+
closesAtOpen: number,
632+
trigger: (typeof Record.SHOWN_TRIGGERS)[number],
633+
): Promise<boolean> {
634+
const pane = epoch === pin.epoch ? await paneOf(engine) : null
635+
636+
if (pane === null || epoch !== pin.epoch) {
637+
try {
638+
await engine.closePane({ id: Names.PANE_ID })
639+
} catch (closeError) {
640+
engine.uiLog(
641+
`Could not close the diff panel after the session changed: ${Views.sanitizeName(messageOf(closeError))}`,
642+
)
643+
644+
if (paneCloses === closesAtOpen) {
645+
isPaneUnknown = true
646+
}
647+
}
648+
649+
throw error
650+
}
651+
652+
if (paneCloses !== closesAtOpen) {
653+
throw error
654+
}
655+
656+
const reason = Views.sanitizeName(messageOf(error))
657+
658+
switch (pane.kind) {
659+
case 'absent':
660+
throw error
661+
case 'waiting':
662+
await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined)
663+
throw error
664+
case 'unknown':
665+
isPaneUnknown = true
666+
paneTrigger = trigger
667+
engine.uiLog(
668+
`Could not tell whether the diff panel opened: ${reason}; the pane lookup failed: ${Views.sanitizeName(pane.reason)}`,
669+
)
670+
return false
671+
case 'placed':
672+
engine.uiLog(`The diff panel opened, but opening it reported: ${reason}`)
673+
return true
674+
}
675+
}
676+
677+
async function settleUnknownPane(engine: Host): Promise<void> {
678+
const { epoch } = pin
679+
const closedBefore = paneCloses
680+
const opened = opens
681+
const pane = await paneOf(engine)
682+
683+
if (!isPaneUnknown || epoch !== pin.epoch || paneCloses !== closedBefore || opens !== opened) {
684+
return
685+
}
686+
687+
if (pane.kind === 'unknown') {
688+
engine.uiLog(
689+
`Could not tell whether the diff panel is shown: ${Views.sanitizeName(pane.reason)}`,
690+
)
691+
692+
return
693+
}
694+
695+
isPaneUnknown = false
696+
isPaneOpen = pane.kind === 'placed'
697+
698+
if (isPaneOpen) {
699+
void recordShown(engine)
700+
}
701+
702+
if (pane.kind === 'waiting') {
703+
await closePane(engine, true).catch(() => undefined)
704+
}
705+
}
706+
588707
async function recordShown(engine: Host): Promise<void> {
589708
const { epoch } = pin
590709
const closedBefore = paneCloses
@@ -611,6 +730,7 @@ export function register(on: On) {
611730
return (await lifecycle.run(isCleanup ? 'pane-cleanup' : 'pane-close', owner, async () => {
612731
await engine.closePane({ id: Names.PANE_ID })
613732
isPaneOpen = false
733+
isPaneUnknown = false
614734
return true
615735
}, true)) ?? false
616736
}
@@ -622,7 +742,7 @@ export function register(on: On) {
622742
})
623743
}
624744

625-
const isTaken = () => isPaneOpen || hasAutoOpened
745+
const isTaken = () => isPaneOpen || hasAutoOpened || isPaneUnknown
626746

627747
const hasRoomFor = (floor: number) =>
628748
model.isFullscreen === true && columns !== null && columns >= floor
@@ -922,6 +1042,7 @@ export function register(on: On) {
9221042
uiLog: text => $.ui.log(text),
9231043
openPane: pane => $.ui.open(pane),
9241044
closePane: pane => $.ui.close(pane),
1045+
panes: () => $.ui.panes(),
9251046
registerCommand: spec => $.command.register(spec),
9261047
sessionId: () => $.session.id(),
9271048
startedAt: () =>
@@ -1005,6 +1126,11 @@ export function register(on: On) {
10051126
}
10061127

10071128
const commandOwner = lifecycle.owner()
1129+
if (isPaneUnknown) {
1130+
await settleUnknownPane(host)
1131+
if (!lifecycle.isSession(commandOwner)) return { text: Names.SESSION_CHANGED_TEXT }
1132+
if (isPaneUnknown) return { text: Names.PANEL_STATE_UNKNOWN_TEXT }
1133+
}
10081134
if (!isPaneOpen) {
10091135
let isAnswered = await pinBackend(host)
10101136
if (!lifecycle.isSession(commandOwner)) return { text: Names.SESSION_CHANGED_TEXT }
@@ -1047,6 +1173,8 @@ export function register(on: On) {
10471173
return {
10481174
text: epoch !== pin.epoch
10491175
? Names.SESSION_CHANGED_TEXT
1176+
: isPaneUnknown
1177+
? Names.PANEL_STATE_UNKNOWN_TEXT
10501178
: Names.RESIZE_TERMINAL_TEXT,
10511179
}
10521180
}
@@ -1089,6 +1217,7 @@ export function register(on: On) {
10891217
if (isClosed) {
10901218
paneCloses += 1
10911219
isPaneOpen = false
1220+
isPaneUnknown = false
10921221
paneTrigger = null
10931222
}
10941223

@@ -1201,7 +1330,7 @@ export function register(on: On) {
12011330
const starting = resetSessionStart(engine)
12021331
void starting.catch(() => undefined)
12031332

1204-
if (isPaneOpen && change.reason === 'resume') {
1333+
if ((isPaneOpen || isPaneUnknown) && change.reason === 'resume') {
12051334
try {
12061335
await closePane(engine, true)
12071336
} catch (error) {

0 commit comments

Comments
 (0)