Skip to content

Commit 23cc971

Browse files
authored
improvement(desktop): make each terminal its own resource tab (#7749)
* improvement(desktop): make each terminal its own resource tab * fix(desktop): close hidden terminal tabs from the strip and keep the activity icon on terminal tabs * fix(desktop): keep terminal close ownership with the window showing the panel
1 parent 5e39483 commit 23cc971

50 files changed

Lines changed: 1519 additions & 1236 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/src/main/ipc.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -468,20 +468,20 @@ describe('registerIpcHandlers', () => {
468468
deps.accountDataAvailable = () => false
469469
const { invoke } = collectHandlers()
470470
const localFilesystemHandle = vi.spyOn(deps.localFilesystem, 'handle')
471-
const terminalStart = vi.spyOn(deps.terminal, 'start')
471+
const terminalRestore = vi.spyOn(deps.terminal, 'restoreScope')
472472

473473
await expect(
474474
invoke.get('desktop:local-filesystem')?.(appEvent, { operation: 'list_mounts' })
475475
).resolves.toMatchObject({ ok: false, code: 'ACCESS_DENIED' })
476476
await expect(invoke.get('browser-credentials:list')?.(appEvent)).resolves.toEqual([])
477-
await expect(invoke.get('terminal:start')?.(appEvent, {}, 'chat-a')).resolves.toMatchObject({
478-
ok: false,
479-
code: 'ACCESS_DENIED',
477+
await expect(invoke.get('terminal:restore-scope')?.(appEvent, 'chat-a')).resolves.toEqual({
478+
tabs: [],
479+
activeTerminalId: null,
480480
})
481481

482482
expect(localFilesystemHandle).not.toHaveBeenCalled()
483483
expect(listCredentials).not.toHaveBeenCalled()
484-
expect(terminalStart).not.toHaveBeenCalled()
484+
expect(terminalRestore).not.toHaveBeenCalled()
485485
})
486486

487487
it('requires an active user gesture for granting or revoking folder access', async () => {

apps/desktop/src/main/ipc.ts

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,39 +1516,20 @@ export function registerIpcHandlers(deps: IpcDeps): void {
15161516
return fillCoordinator()?.fillCredential(id, scope) ?? false
15171517
},
15181518
},
1519-
'terminal:start': {
1519+
'terminal:restore-scope': {
15201520
kind: 'invoke',
15211521
gate: 'app-origin',
15221522
requires: 'terminal',
15231523
passSender: true,
1524-
denied: { ok: false, code: 'ACCESS_DENIED', error: 'Not allowed from this page.' },
1525-
handler: (sender, raw, rawScope) => {
1526-
const contents = sender as WebContents
1527-
const scope = rendererScope(terminalScopeBySender, contents, rawScope)
1528-
if (!scope) {
1529-
return { ok: false, code: 'STALE_SCOPE', error: 'This terminal chat is not active.' }
1530-
}
1531-
const options = isRecordLike(raw) ? raw : {}
1532-
const cols = Number(options.cols)
1533-
const rows = Number(options.rows)
1524+
denied: { tabs: [], activeTerminalId: null },
1525+
handler: (sender, rawScope) => {
1526+
const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope)
1527+
if (!scope) return { tabs: [], activeTerminalId: null }
15341528
try {
1535-
return {
1536-
ok: true,
1537-
tabs: {
1538-
...deps.terminal.start(scope, {
1539-
cols: toCellCount(cols, 80),
1540-
rows: toCellCount(rows, 24),
1541-
}),
1542-
scopeId: scope,
1543-
},
1544-
}
1529+
return { ...deps.terminal.restoreScope(scope), scopeId: scope }
15451530
} catch (error) {
1546-
const failure = error as { code?: string; message?: string }
1547-
return {
1548-
ok: false,
1549-
code: failure.code ?? 'SPAWN_FAILED',
1550-
error: failure.message ?? 'Could not open a terminal.',
1551-
}
1531+
logger.warn('Could not restore saved terminals', { error: getErrorMessage(error) })
1532+
return { ...deps.terminal.getTabs(scope), scopeId: scope }
15521533
}
15531534
},
15541535
},
@@ -1778,12 +1759,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
17781759
requires: 'terminal',
17791760
passSender: true,
17801761
denied: { tabs: [], activeTerminalId: null },
1781-
handler: (sender, terminalId, rawScope) => {
1762+
handler: (sender, terminalId, rawScope, rawOptions) => {
17821763
const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope)
17831764
if (!scope) return { tabs: [], activeTerminalId: null }
1765+
const claim = !(isRecordLike(rawOptions) && rawOptions.claim === false)
17841766
const tabs =
17851767
typeof terminalId === 'string'
1786-
? deps.terminal.switchTerminal(scope, terminalId)
1768+
? deps.terminal.switchTerminal(scope, terminalId, { claim })
17871769
: deps.terminal.getTabs(scope)
17881770
return { ...tabs, scopeId: scope }
17891771
},
@@ -1850,7 +1832,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
18501832
handler: (sender, terminalId, cols, rows, rawScope) => {
18511833
// `typeof NaN === 'number'`, and the downstream `cols <= 0` guard is
18521834
// false for NaN, so an unfinite value reached pty.resize() intact.
1853-
// Matches the clamping terminal:start already applies to these fields.
18541835
if (typeof terminalId !== 'string') return
18551836
if (!isPositiveFinite(cols) || !isPositiveFinite(rows)) return
18561837
const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope)

apps/desktop/src/main/terminal/index.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -347,12 +347,20 @@ export class TerminalService {
347347
return this.getAgentTabs()
348348
}
349349

350-
switchTerminal(terminalId: string): TerminalTabsState {
350+
/**
351+
* Shows a terminal. `claim` records it as the user's own; a switch that only
352+
* mirrors the renderer's resource-strip selection passes false so the agent
353+
* can still close or adopt the shell as its own.
354+
*/
355+
switchTerminal(
356+
terminalId: string,
357+
{ claim = true }: { claim?: boolean } = {}
358+
): TerminalTabsState {
351359
if (!this.sessions.has(terminalId)) {
352360
throw new TerminalError('NO_SUCH_TERMINAL', unknownTerminal(terminalId))
353361
}
354362
this.activeId = terminalId
355-
this.activeTerminalUserSelected = true
363+
if (claim) this.activeTerminalUserSelected = true
356364
this.emitTabs()
357365
void this.sessions.get(terminalId)?.refreshCwd()
358366
return this.getTabs()
@@ -387,19 +395,11 @@ export class TerminalService {
387395
}
388396

389397
/**
390-
* Closes a terminal, or resets it when it is the only one left.
391-
*
392-
* Emptying the panel is not an option the close button should have: the
393-
* resource IS a terminal, so a panel with no shell in it is a dead end the
394-
* user has to close and reopen to escape. Replacing the last shell with a
395-
* fresh one in the same directory gives the button a sensible meaning at
396-
* every count — the same shape as closing a browser's last tab, which
397-
* leaves you a tab rather than an empty window.
398-
*
399-
* A shell that ends by itself — `exit`, or Ctrl-D — goes the same way. It
400-
* leaves behind a session that can no longer do anything, so it has to be
401-
* reaped either way; treating it as a close means the last one is replaced
402-
* rather than leaving a dead tab that cannot be typed into.
398+
* Closes a terminal. Each shell is its own resource tab in the renderer, so
399+
* closing the last one simply leaves none; the strip drops the tab and a new
400+
* shell comes back through `+ Terminal` or the agent. A shell that ends by
401+
* itself — `exit`, or Ctrl-D — goes the same way: it leaves behind a session
402+
* that can no longer do anything, so it is reaped like a close.
403403
*/
404404
closeTerminal(terminalId: string): TerminalTabsState {
405405
if (!this.sessions.has(terminalId)) {
@@ -451,32 +451,24 @@ export class TerminalService {
451451
}
452452

453453
/**
454-
* Drops a terminal and decides what replaces it. Closing and exiting share
455-
* this so the two cannot drift into different answers for "what happens to
456-
* the last one".
454+
* Drops a terminal and moves both cursors to a neighbour. Closing and
455+
* exiting share this so the two cannot drift into different answers.
457456
*/
458457
private retire(terminalId: string): TerminalTabsState {
459458
const session = this.sessions.get(terminalId)
460459
if (!session) return this.getTabs()
461460
const closedCwd = session.currentCwd
462-
const cols = session.cols
463-
const rows = session.rows
464461
const order = [...this.sessions.keys()]
465462
const index = order.indexOf(terminalId)
466463
session.dispose()
467464
this.sessions.delete(terminalId)
468465
this.tmuxCache.delete(terminalId)
469466
this.releasePendingRuns(terminalId)
470467

471-
if (this.sessions.size === 0) {
472-
this.spawn(this.resolveCwd(closedCwd), cols, rows, {
473-
activateVisible: true,
474-
activateAgent: true,
475-
})
476-
return this.getTabs()
477-
}
478-
479468
this.rememberClosed(closedCwd)
469+
// Nothing is left for the user to hold on to; the next shell the agent
470+
// opens must not inherit a claim on a terminal that no longer exists.
471+
if (this.sessions.size === 0) this.activeTerminalUserSelected = false
480472
if (this.activeId === terminalId) {
481473
this.activeId = order[index + 1] ?? order[index - 1] ?? null
482474
}
@@ -641,9 +633,17 @@ export class TerminalService {
641633
)
642634
}
643635

644-
/** Whether one renderer may close a tab in the terminal panel it displays. */
636+
/**
637+
* Whether one renderer may close a tab. The strip that lists shells sits
638+
* outside the terminal panel, so a renderer on the chat may close a shell
639+
* nobody is displaying; while a window does display the panel, only that
640+
* window may close, so a second window on the same chat cannot end a shell
641+
* someone is using.
642+
*/
645643
acceptsUserClose(owner: WebContents, terminalId: string): boolean {
646-
return !owner.isDestroyed() && this.visibleOwner === owner && this.sessions.has(terminalId)
644+
if (owner.isDestroyed() || !this.sessions.has(terminalId)) return false
645+
const shown = this.visibleOwner && !this.visibleOwner.isDestroyed() ? this.visibleOwner : null
646+
return shown === null || shown === owner
647647
}
648648

649649
/** Drops the claim and unsubscribes from the owner's lifecycle. */

0 commit comments

Comments
 (0)