fix: preserve terminal set title during agent updates

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-17 16:53:25 -07:00
co-authored by Orca
parent 10b63bd1a0
commit 7d0ccd862f
14 changed files with 903 additions and 75 deletions
+135
View File
@@ -0,0 +1,135 @@
# Terminal Set Title Custom Label
## Problem
Right-clicking a single terminal pane and choosing `Set Title...` appears to accept the new label, but an active Codex agent can immediately replace the visible tab label with its next OSC title update. The user intent is different in single-pane and split-pane layouts:
- In a single-pane terminal tab, `Set Title...` is effectively a tab-label action because there is no separate pane selection to disambiguate.
- In a split terminal tab, `Set Title...` labels the selected pane overlay while the tab label should continue following the active pane's runtime title.
The goal is to make a user-set single-pane terminal title survive Codex title churn while preserving existing split-pane behavior.
## Current Behavior
- Terminal pane titles are local React state keyed by ephemeral pane id and persisted through leaf ids in the terminal layout snapshot: `src/renderer/src/components/terminal-pane/TerminalPane.tsx:226`.
- `Set Title...` from the terminal context menu resolves the clicked pane and calls the pane rename flow: `src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts:195`.
- Submitting the pane rename writes `paneTitles`, updates `paneTitlesRef`, and persists the layout snapshot: `src/renderer/src/components/terminal-pane/TerminalPane.tsx:998`.
- Active PTY OSC title changes update the runtime pane title and, when the pane is active, call `updateTabTitle`: `src/renderer/src/components/terminal-pane/pty-connection.ts:224`.
- The rendered tab label prefers `customTitle` over the volatile runtime `title`: `src/renderer/src/components/tab-bar/SortableTab.tsx:192` and `src/renderer/src/components/tab-bar/SortableTab.tsx:321`.
- The store-level fallback title also gives `customTitle` top priority: `src/renderer/src/store/slices/terminals.ts:53`.
- `updateTabTitle` updates the live `title` field used by OSC title changes: `src/renderer/src/store/slices/terminals.ts:781`.
- `setTabCustomTitle` updates the stable user-owned label: `src/renderer/src/store/slices/terminals.ts:961`.
- Split-pane lifecycle already treats active pane runtime titles as tab-title drivers when panes close or focus changes: `src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts:704` and `src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts:723`.
The failure mode is that single-pane `Set Title...` currently behaves like a pane overlay title only. It does not claim the tab's `customTitle`, so the next active Codex OSC update can still change the tab's live `title` and therefore the visible label.
## Proposed Design
When the pane rename is submitted:
1. Continue updating `paneTitles` and `paneTitlesRef` exactly as today so the pane overlay and persisted layout snapshot keep working.
2. If the terminal manager has one or zero panes, also call `setTabCustomTitle(tabId, trimmed, { preservePaneTitleMirror: true })` and persist that exact mirrored value in `terminalLayoutsByTabId[tabId].paneTitleMirroredCustomTitle` as the value this pane flow owns on the tab.
3. If the terminal manager has more than one pane, do not write `customTitle`; preserve split-pane semantics where the title labels the pane overlay and the tab label follows the active pane's runtime title.
4. When a terminal transitions from one pane to multiple panes, clear `customTitle` only if the current tab `customTitle` still equals the value mirrored by this pane-title flow. Keep the pane overlay title. This restores split-pane runtime-title behavior without clearing explicit tab renames made through the tab UI.
5. When the user removes a single-pane pane title through the overlay close affordance, or clears the inline title and submits it, clear `customTitle` under the same ownership check: only clear the value the pane-title flow mirrored, not an unrelated explicit tab label.
6. Leave OSC title handling unchanged. Codex can continue writing runtime titles through `updateTabTitle`; the tab renderer already prefers `customTitle`, so user-owned single-pane labels remain visible without suppressing the underlying runtime title.
This keeps the change narrow and uses existing store precedence instead of introducing a new terminal-title ownership model.
## User-Facing Semantics
| Layout state | User action | Pane overlay | Tab label |
| --- | --- | --- | --- |
| Single pane | `Set Title...` with non-empty text | Shows the custom label on the pane. | Uses the same custom label through `customTitle`, so Codex OSC churn cannot replace it visually. |
| Single pane | Empty submit while a title exists | Clears the pane overlay. | Clears the mirrored `customTitle` if this flow still owns it. |
| Single pane | Empty submit on an untitled pane | No change. | No change; empty input remains cancel/no-op. |
| Single pane | Overlay remove-title button | Clears the pane overlay. | Clears the mirrored `customTitle` if this flow still owns it. |
| Single pane becomes split | User splits the terminal after setting a pane title. | The original pane keeps its overlay title. | Drops the mirrored `customTitle` and resumes following the active pane runtime title. |
| Split pane | `Set Title...` on a selected pane | Shows or updates only that pane's overlay title. | Continues following the active pane's runtime title. |
| Split pane | Focus active pane changes | Pane overlays remain local. | Existing lifecycle sync updates the tab runtime title to the newly active pane. |
The important distinction is ownership: the pane-title flow may clear only the tab label it wrote or restored with the persisted mirror marker. If the user separately renames the tab, even to the same text, that explicit tab label wins until the tab rename UI clears or changes it.
## Architecture and Data Flow
```text
[Terminal context menu]
|
v
[TerminalPane rename state] ---> [paneTitles / paneTitlesRef] ---> [layout snapshot titlesByLeafId]
|
+-- single pane only --> [tabsByWorktree.customTitle]
|
+-- single pane only --> [layout snapshot paneTitleMirroredCustomTitle]
|
v
[PTY OSC title] -------------> [tabsByWorktree.title] -----> [SortableTab renders customTitle ?? title]
```
Data-flow paths:
- Happy path: the user submits a non-empty single-pane title, `paneTitlesRef` is updated before persistence, `setTabCustomTitle` writes the stable tab label, and later `updateTabTitle` calls continue updating runtime state without changing the rendered tab label.
- Nil manager path: if `managerRef.current` is temporarily null during submit, treat the layout as single-pane. That matches the only visible user intent available from this `TerminalPane`; there is no split manager to preserve.
- Empty input path: trimmed input length `0` cancels only when the pane has no title. If a title exists, it routes through the same removal path as the overlay remove button and clears the mirrored `customTitle` only under the ownership check.
- Upstream/runtime error path: SSH, PTY, and OSC failures remain outside this change. The mirrored label is renderer/store state, so a transport error can stop future runtime title updates but cannot erase a user-set `customTitle`.
## Invalidation and Concurrency
- Double submit remains guarded by `renameSubmittedRef`; Enter followed by blur must not write or persist twice.
- OSC title updates may race immediately after rename submit. This is safe because they write `title`, while the visible tab label prefers `customTitle`.
- Split creation is the main invalidation event. If pane count moves from one to more than one, the `TerminalPane` should clear only the `customTitle` value it mirrored from pane rename. This avoids the stale-label bug where a former single-pane label would keep hiding active split-pane runtime titles.
- Explicit tab rename remains independent. Generic `setTabCustomTitle` calls clear `paneTitleMirroredCustomTitle`, including same-text renames, so later split/remove actions do not clear a tab-owned label.
- The mirrored ownership marker is persisted in the terminal layout snapshot. Restore, shutdown capture, and the immediate post-restore layout persist preserve it while the layout remains single-pane and the current tab `customTitle` still matches the marker.
## Edge Cases
- Empty rename input should remove an existing pane title, but remain a cancel/no-op for an untitled pane.
- Double submit from Enter followed by blur should still be guarded by `renameSubmittedRef`.
- Split panes should not receive a tab `customTitle` from pane-level `Set Title...`, because that would hide active pane runtime changes across all splits.
- A single-pane title that is mirrored into `customTitle` should be cleared when the user later splits the terminal, while the original pane overlay title remains in `paneTitles`.
- A restored single-pane title should keep its persisted `paneTitleMirroredCustomTitle` through shutdown capture and the initial post-restore layout persist, then clear the mirrored `customTitle` if the user splits after relaunch.
- A restored tab explicitly renamed to the same text as the pane title should clear the persisted mirror marker and survive a later split.
- Removing the pane overlay title in a single-pane tab should clear the mirrored `customTitle`; removing a pane overlay in a split tab should not touch `customTitle`.
- Single-pane behavior should tolerate `managerRef.current` being temporarily null by treating the layout as a single pane, matching the safest visible user intent.
- Existing tab rename UI and launch flows that already use `setTabCustomTitle` should continue to work because this design reuses the same field and render precedence.
- SSH terminals should behave the same as local terminals because the fix is renderer/store state precedence, not PTY transport or filesystem behavior.
## Test Plan
- Add a consolidated Playwright regression in `tests/e2e/terminal-panes.spec.ts` that:
- Opens the terminal context menu on a single pane.
- Chooses `Set Title...`.
- Enters a custom label.
- Asserts the active tab's store entry has `customTitle`.
- Simulates an active Codex OSC title update by calling `updateTabTitle`.
- Asserts the rendered sortable tab keeps `data-tab-title` equal to the custom label.
- In the same Playwright launch, cover:
- Removing a single-pane pane title clears the mirrored `customTitle`, removes the visible title bar, and removes the persisted `titlesByLeafId` entry.
- Clearing the inline title input and pressing Enter uses the same removal path.
- The inline title controls are keyboard accessible, use title-specific accessible names, expose a named textbox, and provide a tooltip-backed remove button with a 24px target.
- Splitting after a single-pane pane title clears only the mirrored `customTitle`, keeps the pane overlay title, and lets the tab label follow active runtime titles again.
- If the tab bar later writes a different explicit `customTitle`, splitting does not clear that unrelated tab-owned label.
- Split-pane pane titles remain pane-local and do not overwrite the existing tab `customTitle`.
- Add restart/remount Playwright coverage in `tests/e2e/terminal-restart-persistence.spec.ts` proving a restored single-pane title preserves ownership, then clears the mirrored `customTitle` when the user splits after relaunch.
- Add restart/remount coverage proving an explicit same-text tab rename clears the mirror marker and survives a later split.
- Unit coverage is not necessary for the primary bug because the observable regression crosses context-menu UI, pane rename state, Zustand tab state, and tab rendering. A Playwright test catches the real integration failure.
- Run:
- `npx playwright test tests/e2e/terminal-panes.spec.ts --config tests/playwright.config.ts --project=electron-headless -g "Set Title ownership"`
- `npx playwright test tests/e2e/terminal-restart-persistence.spec.ts --config tests/playwright.config.ts --project=electron-headless -g "restored single-pane Set Title ownership|restored same-text explicit tab title"`
- `pnpm typecheck`
- `pnpm lint`
## Rollout Order
1. Add the design doc.
2. Update `TerminalPane.tsx` to select `setTabCustomTitle` and write it only for single-pane rename submits.
3. Track the mirrored single-pane value locally so split transitions and remove-title can clear only labels owned by the pane-title flow.
4. Add the focused Playwright regressions.
5. Run targeted Playwright coverage.
6. Run full required checks: `pnpm typecheck` and `pnpm lint`.
7. Run review lanes for code, security, tests, UX, perf, and Windows/Linux compatibility because the change touches terminal UI state and hot OSC title paths.
8. Validate manually in Electron by setting a single-pane terminal title while an agent is actively updating the terminal title, splitting that terminal, and confirming the split tab label follows the active pane again.
## Ref-Oss
`ref-oss` was not used. This is a narrow Orca-specific interaction between pane title state, tab `customTitle`, and active terminal OSC title updates. Mature OSS terminal behavior would not materially reduce risk because the key design choice depends on Orca's existing tab-render precedence and split-pane runtime-title ownership.
+46 -27
View File
@@ -183,6 +183,10 @@
/* ── Pane title bar ──────────────────────────────────── */
:root {
--orca-pane-title-height: 24px;
}
/* Floating tab style — no full-width background bar. The title sits as a
lightweight inline label so the terminal content isn't visually "capped."
Uses muted grey + monospace to read as metadata, not actionable text. */
@@ -191,54 +195,61 @@
top: 0;
left: 0;
right: 0;
height: 20px;
height: var(--orca-pane-title-height);
z-index: 5; /* below drag handle (z:10) */
display: flex;
align-items: center;
padding: 0 8px;
font-size: 13px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
color: rgba(255, 255, 255, 0.52);
color: var(--orca-pane-title-fg, rgb(255 255 255 / 0.52));
background: transparent;
border-bottom: none;
cursor: pointer; /* click to edit title */
user-select: none;
}
.pane-title-text {
align-self: stretch;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
line-height: var(--orca-pane-title-height);
}
.pane-title-text:focus-visible {
outline: 1px solid var(--ring);
outline-offset: 1px;
}
.pane-title-close {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 0;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
line-height: 14px;
transform: translateY(-1px);
cursor: pointer;
color: var(--orca-pane-title-button-fg, rgb(255 255 255 / 0.3));
opacity: 0;
transition:
opacity 120ms ease,
color 120ms ease;
}
.pane-title-bar:hover .pane-title-close {
.pane-title-bar:hover .pane-title-close,
.pane-title-bar:focus-within .pane-title-close,
.pane-title-close:focus-visible {
opacity: 1;
}
.pane-title-close:hover {
color: rgba(255, 255, 255, 0.8);
color: var(--orca-pane-title-button-hover-fg, rgb(255 255 255 / 0.8));
}
/* Inline edit input — matches the title text styling exactly to prevent
@@ -253,19 +264,27 @@
flex: 1;
min-width: 0;
width: 100%;
height: 20px;
height: var(--orca-pane-title-height);
box-sizing: border-box;
padding: 0 8px;
margin: 0;
border: none;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
background: var(--orca-pane-title-input-bg, rgb(255 255 255 / 0.04));
outline: none;
font-size: 13px;
line-height: 20px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
color: rgba(255, 255, 255, 0.7);
caret-color: rgba(255, 255, 255, 0.6);
color: var(--orca-pane-title-input-fg, rgb(255 255 255 / 0.7));
caret-color: var(--orca-pane-title-input-fg, rgb(255 255 255 / 0.7));
}
.pane-title-input::placeholder {
color: var(--orca-pane-title-placeholder, rgb(255 255 255 / 0.38));
}
.pane-title-input:focus-visible {
border-color: var(--ring);
}
/* Baseline xterm-container layout — padding is driven by CSS custom properties
@@ -281,11 +300,11 @@
/* When a pane has a title, shift the terminal content down to make room.
The .pane container uses position: relative, so the absolutely-positioned
title bar occupies the top 20px. Height is reduced by the full 24px
(20px title bar + 4px base margin-top) to prevent overflow/clipping. */
title bar occupies the top title-height pixels. Height is reduced by the
same amount to prevent overflow/clipping. */
.pane[data-has-title] .xterm-container {
margin-top: 24px; /* 20px title bar + 4px base margin-top */
height: calc(100% - 24px); /* must match margin-top to avoid overflow */
margin-top: var(--orca-pane-title-height);
height: calc(100% - var(--orca-pane-title-height)); /* match margin-top */
}
/* Override the inline overflow:hidden set by pane-manager so the title
@@ -303,11 +322,11 @@
.pane[data-has-title]::after {
content: '';
position: absolute;
top: 20px;
top: var(--orca-pane-title-height);
left: -5px;
right: -5px;
height: 1px;
background: rgba(255, 255, 255, 0.06);
background: var(--orca-pane-title-separator, rgb(255 255 255 / 0.06));
z-index: 6;
pointer-events: none;
}
@@ -3,9 +3,13 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import { createPortal } from 'react-dom'
import type { CSSProperties } from 'react'
import type { IDisposable } from '@xterm/xterm'
import { X } from 'lucide-react'
import { useAppStore } from '../../store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DEFAULT_TERMINAL_DIVIDER_DARK,
isTerminalBackgroundLight,
normalizeColor,
resolveEffectiveTerminalAppearance
} from '@/lib/terminal-theme'
@@ -226,6 +230,8 @@ export default function TerminalPane({
const [paneTitles, setPaneTitles] = useState<Record<number, string>>({})
const paneTitlesRef = useRef<Record<number, string>>({})
paneTitlesRef.current = paneTitles
const mirroredPaneTitleCustomTitleRef = useRef<string | null>(null)
const previousPaneCountRef = useRef<number | null>(null)
const [renamingPaneId, setRenamingPaneId] = useState<number | null>(null)
const [renameValue, setRenameValue] = useState('')
const renameInputRef = useRef<HTMLInputElement>(null)
@@ -250,6 +256,12 @@ export default function TerminalPane({
const setTabLayout = useAppStore((store) => store.setTabLayout)
const initialLayoutRef = useRef(savedLayout)
const updateTabTitle = useAppStore((store) => store.updateTabTitle)
const setTabCustomTitle = useAppStore((store) => store.setTabCustomTitle)
const tabCustomTitle = useAppStore(
(store) =>
(store.tabsByWorktree[worktreeId] ?? []).find((entry) => entry.id === tabId)?.customTitle ??
null
)
const setRuntimePaneTitle = useAppStore((store) => store.setRuntimePaneTitle)
const clearRuntimePaneTitle = useAppStore((store) => store.clearRuntimePaneTitle)
const updateTabPtyId = useAppStore((store) => store.updateTabPtyId)
@@ -310,6 +322,25 @@ export default function TerminalPane({
const dispatchNotification = useNotificationDispatch(worktreeId)
const setCacheTimerStartedAt = useAppStore((store) => store.setCacheTimerStartedAt)
const getCurrentTabCustomTitle = useCallback((): string | null => {
const state = useAppStore.getState()
return (
(state.tabsByWorktree[worktreeId] ?? []).find((entry) => entry.id === tabId)?.customTitle ??
null
)
}, [tabId, worktreeId])
const clearMirroredPaneTitleCustomTitle = useCallback((): void => {
const mirroredTitle = mirroredPaneTitleCustomTitleRef.current
if (!mirroredTitle) {
return
}
if (getCurrentTabCustomTitle() === mirroredTitle) {
setTabCustomTitle(tabId, null)
}
mirroredPaneTitleCustomTitleRef.current = null
}, [getCurrentTabCustomTitle, setTabCustomTitle, tabId])
// Memoized with useCallback so downstream hooks (useTerminalKeyboardShortcuts,
// useTerminalPaneLifecycle, createExpandCollapseActions) don't tear down and
// re-register event listeners on every render. All data it reads comes from
@@ -373,8 +404,24 @@ export default function TerminalPane({
if (titleEntries.length > 0) {
layout.titlesByLeafId = Object.fromEntries(titleEntries)
}
const existingMirrorCustomTitle = existing?.paneTitleMirroredCustomTitle?.trim()
const currentTabCustomTitle = getCurrentTabCustomTitle()
const restoredMirrorCustomTitle =
existingMirrorCustomTitle &&
currentPanes.length === 1 &&
currentTabCustomTitle === existingMirrorCustomTitle
? existingMirrorCustomTitle
: null
const mirroredCustomTitle = mirroredPaneTitleCustomTitleRef.current ?? restoredMirrorCustomTitle
if (
mirroredCustomTitle &&
currentPanes.length === 1 &&
currentTabCustomTitle === mirroredCustomTitle
) {
layout.paneTitleMirroredCustomTitle = mirroredCustomTitle
}
setTabLayout(tabId, layout)
}, [tabId, setTabLayout])
}, [getCurrentTabCustomTitle, tabId, setTabLayout])
const syncPanePtyLayoutBinding = useCallback(
(paneId: number, ptyId: string | null): void => {
@@ -941,6 +988,58 @@ export default function TerminalPane({
}
}, [paneTitles, renamingPaneId])
useEffect(() => {
const manager = managerRef.current
if (!manager || mirroredPaneTitleCustomTitleRef.current !== null) {
return
}
const panes = manager.getPanes()
if (panes.length !== 1) {
return
}
const paneTitle = paneTitles[panes[0].id]?.trim()
const mirroredCustomTitle = savedLayout?.paneTitleMirroredCustomTitle?.trim()
if (paneTitle && mirroredCustomTitle && tabCustomTitle === mirroredCustomTitle) {
// Why: after session restore, a single-pane title mirrored into
// customTitle before shutdown should still be invalidated if the user
// later splits the terminal.
mirroredPaneTitleCustomTitleRef.current = mirroredCustomTitle
previousPaneCountRef.current = panes.length
}
}, [paneCount, paneTitles, savedLayout?.paneTitleMirroredCustomTitle, tabCustomTitle])
useEffect(() => {
const mirroredTitle = mirroredPaneTitleCustomTitleRef.current
if (mirroredTitle && tabCustomTitle !== null && tabCustomTitle !== mirroredTitle) {
// Why: a tab-bar rename is a separate, explicit label owner. Once the
// value diverges from the pane-title mirror, later split/remove actions
// must not clear that unrelated customTitle.
mirroredPaneTitleCustomTitleRef.current = null
}
}, [tabCustomTitle])
useEffect(() => {
const currentPaneCount = managerRef.current?.getPanes().length ?? paneCount
const previousPaneCount = previousPaneCountRef.current
previousPaneCountRef.current = currentPaneCount
if (previousPaneCount !== null && previousPaneCount <= 1 && currentPaneCount > 1) {
clearMirroredPaneTitleCustomTitle()
}
}, [clearMirroredPaneTitleCustomTitle, paneCount])
useEffect(() => {
const mirroredTitle = mirroredPaneTitleCustomTitleRef.current
if (!mirroredTitle) {
return
}
if (savedLayout?.paneTitleMirroredCustomTitle !== mirroredTitle) {
// Why: an explicit tab rename is allowed to match the pane title text.
// The persisted owner marker, not text equality, decides whether later
// split/remove cleanup may clear customTitle.
mirroredPaneTitleCustomTitleRef.current = null
}
}, [savedLayout?.paneTitleMirroredCustomTitle])
// Register a capture callback for shutdown. The beforeunload handler in
// App.tsx calls all registered callbacks to serialize terminal buffers.
useEffect(() => {
@@ -994,34 +1093,7 @@ export default function TerminalPane({
setRenamingPaneId(paneId)
}, [])
const handleRenameSubmit = useCallback(() => {
if (renamingPaneId === null || renameSubmittedRef.current) {
return
}
renameSubmittedRef.current = true
const trimmed = renameValue.trim()
if (trimmed.length === 0) {
// Empty input — just cancel, don't change anything.
setRenamingPaneId(null)
return
}
setPaneTitles((prev) => ({ ...prev, [renamingPaneId]: trimmed }))
// Eagerly update the ref so persistLayoutSnapshot (which reads
// paneTitlesRef.current) sees the new title immediately, without
// waiting for React to re-render and assign it during the next
// render pass.
paneTitlesRef.current = { ...paneTitlesRef.current, [renamingPaneId]: trimmed }
setRenamingPaneId(null)
// Persist immediately so the title survives restarts.
persistLayoutSnapshot()
}, [renamingPaneId, renameValue, persistLayoutSnapshot])
const handleRenameCancel = useCallback(() => {
renameSubmittedRef.current = true
setRenamingPaneId(null)
}, [])
const handleRemoveTitle = useCallback(
const removePaneTitle = useCallback(
(paneId: number) => {
setPaneTitles((prev) => {
if (!(paneId in prev)) {
@@ -1037,9 +1109,61 @@ export default function TerminalPane({
delete next[paneId]
paneTitlesRef.current = next
}
if ((managerRef.current?.getPanes().length ?? 1) <= 1) {
clearMirroredPaneTitleCustomTitle()
}
persistLayoutSnapshot()
},
[persistLayoutSnapshot]
[clearMirroredPaneTitleCustomTitle, persistLayoutSnapshot]
)
const handleRenameSubmit = useCallback(() => {
if (renamingPaneId === null || renameSubmittedRef.current) {
return
}
renameSubmittedRef.current = true
const trimmed = renameValue.trim()
if (trimmed.length === 0) {
if (paneTitlesRef.current[renamingPaneId]) {
removePaneTitle(renamingPaneId)
}
setRenamingPaneId(null)
return
}
setPaneTitles((prev) => ({ ...prev, [renamingPaneId]: trimmed }))
// Eagerly update the ref so persistLayoutSnapshot (which reads
// paneTitlesRef.current) sees the new title immediately, without
// waiting for React to re-render and assign it during the next
// render pass.
paneTitlesRef.current = { ...paneTitlesRef.current, [renamingPaneId]: trimmed }
if ((managerRef.current?.getPanes().length ?? 1) <= 1) {
// Why: with one pane, the terminal context menu's title action is the
// tab title action users expect; customTitle keeps Codex OSC title churn
// from immediately replacing their label.
mirroredPaneTitleCustomTitleRef.current = trimmed
previousPaneCountRef.current = managerRef.current?.getPanes().length ?? 1
setTabCustomTitle(tabId, trimmed, { preservePaneTitleMirror: true })
}
setRenamingPaneId(null)
// Persist immediately so the title survives restarts.
persistLayoutSnapshot()
}, [
renamingPaneId,
renameValue,
removePaneTitle,
setTabCustomTitle,
tabId,
persistLayoutSnapshot
])
const handleRenameCancel = useCallback(() => {
renameSubmittedRef.current = true
setRenamingPaneId(null)
}, [])
const handleRemoveTitle = useCallback(
(paneId: number) => removePaneTitle(paneId),
[removePaneTitle]
)
// Auto-focus and select-all in the rename input when the dialog opens.
@@ -1142,6 +1266,30 @@ export default function TerminalPane({
const effectiveAppearance = settings
? resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
: null
// Why: app light/dark mode can diverge from the selected terminal theme, so
// pane-title contrast follows the resolved terminal background instead.
const titleUsesLightSurface = isTerminalBackgroundLight(
settings?.terminalColorOverrides?.background ?? effectiveAppearance?.theme?.background
)
const paneTitlePalette = titleUsesLightSurface
? {
fg: 'rgb(24 24 27 / 0.64)',
inputFg: 'rgb(24 24 27 / 0.82)',
placeholder: 'rgb(24 24 27 / 0.48)',
buttonFg: 'rgb(24 24 27 / 0.42)',
buttonHoverFg: 'rgb(24 24 27 / 0.82)',
inputBg: 'rgb(24 24 27 / 0.05)',
separator: 'rgb(24 24 27 / 0.1)'
}
: {
fg: 'rgb(255 255 255 / 0.52)',
inputFg: 'rgb(255 255 255 / 0.7)',
placeholder: 'rgb(255 255 255 / 0.38)',
buttonFg: 'rgb(255 255 255 / 0.3)',
buttonHoverFg: 'rgb(255 255 255 / 0.8)',
inputBg: 'rgb(255 255 255 / 0.04)',
separator: 'rgb(255 255 255 / 0.06)'
}
const terminalContainerStyle: CSSProperties = {
// Why: split groups can keep one terminal visible in an unfocused group so
@@ -1153,7 +1301,14 @@ export default function TerminalPane({
['--orca-terminal-divider-color-strong' as string]: normalizeColor(
effectiveAppearance?.dividerColor,
DEFAULT_TERMINAL_DIVIDER_DARK
)
),
['--orca-pane-title-fg' as string]: paneTitlePalette.fg,
['--orca-pane-title-input-fg' as string]: paneTitlePalette.inputFg,
['--orca-pane-title-placeholder' as string]: paneTitlePalette.placeholder,
['--orca-pane-title-button-fg' as string]: paneTitlePalette.buttonFg,
['--orca-pane-title-button-hover-fg' as string]: paneTitlePalette.buttonHoverFg,
['--orca-pane-title-input-bg' as string]: paneTitlePalette.inputBg,
['--orca-pane-title-separator' as string]: paneTitlePalette.separator
}
const activePane = managerRef.current?.getActivePane()
@@ -1277,6 +1432,8 @@ export default function TerminalPane({
<input
ref={renameInputRef}
className="pane-title-input"
aria-label="Pane title"
placeholder="Pane title"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
@@ -1290,19 +1447,34 @@ export default function TerminalPane({
/>
) : (
<>
<span className="pane-title-text" onClick={() => handleStartRename(pane.id)}>
{title}
</span>
<button
className="pane-title-close"
onClick={(e) => {
e.stopPropagation()
handleRemoveTitle(pane.id)
}}
aria-label="Remove title"
type="button"
className="pane-title-text"
onClick={() => handleStartRename(pane.id)}
aria-label={`Edit pane title: ${title}`}
>
×
{title}
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="pane-title-close"
onClick={(e) => {
e.stopPropagation()
handleRemoveTitle(pane.id)
}}
aria-label={`Remove pane title: ${title}`}
>
<X className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
Remove title
</TooltipContent>
</Tooltip>
</>
)}
</div>,
@@ -126,7 +126,8 @@ describe('captureTerminalShutdownLayout', () => {
root: null,
activeLeafId: null,
expandedLeafId: null,
buffersByLeafId: { [LEAF_ID]: 'previous-local-scrollback' }
buffersByLeafId: { [LEAF_ID]: 'previous-local-scrollback' },
paneTitleMirroredCustomTitle: 'local shell'
},
captureBuffers: false
})
@@ -136,5 +137,6 @@ describe('captureTerminalShutdownLayout', () => {
expect(layout.buffersByLeafId).toBeUndefined()
expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID]: 'pty-1' })
expect(layout.titlesByLeafId).toEqual({ [LEAF_ID]: 'local shell' })
expect(layout.paneTitleMirroredCustomTitle).toBe('local shell')
})
})
@@ -108,6 +108,9 @@ export function captureTerminalShutdownLayout({
if (titleEntries.length > 0) {
layout.titlesByLeafId = Object.fromEntries(titleEntries)
}
if (panes.length === 1 && existingLayout?.paneTitleMirroredCustomTitle) {
layout.paneTitleMirroredCustomTitle = existingLayout.paneTitleMirroredCustomTitle
}
return layout
}
@@ -824,6 +824,10 @@ export function useTerminalPaneLifecycle({
// Merge (not replace) so we don't discard any concurrent state
// updates from onPaneClosed that React may have batched.
setPaneTitles((prev) => ({ ...prev, ...restored }))
// Why: the lifecycle immediately persists a fresh layout after restore,
// before React state has flushed. Keep the ref in sync now so that
// persist preserves restored titles instead of rewriting them away.
paneTitlesRef.current = { ...paneTitlesRef.current, ...restored }
}
}
@@ -3,6 +3,7 @@ import {
DEFAULT_TERMINAL_THEME_DARK,
DEFAULT_TERMINAL_THEME_LIGHT,
getTerminalThemePreview,
isTerminalBackgroundLight,
resolveEffectiveTerminalAppearance
} from './terminal-theme'
@@ -91,3 +92,17 @@ describe('resolveEffectiveTerminalAppearance', () => {
expect(appearance.theme).toEqual(getTerminalThemePreview(DEFAULT_TERMINAL_THEME_DARK))
})
})
describe('isTerminalBackgroundLight', () => {
it('classifies common terminal background color formats by luminance', () => {
expect(isTerminalBackgroundLight('#ffffff')).toBe(true)
expect(isTerminalBackgroundLight('#18181b')).toBe(false)
expect(isTerminalBackgroundLight('rgb(245 245 244)')).toBe(true)
expect(isTerminalBackgroundLight('rgba(24, 24, 27, 0.92)')).toBe(false)
})
it('defaults unknown colors to dark-surface title styling', () => {
expect(isTerminalBackgroundLight(undefined)).toBe(false)
expect(isTerminalBackgroundLight('var(--background)')).toBe(false)
})
})
+81
View File
@@ -154,6 +154,87 @@ export function colorToCss(
return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`
}
type RgbColor = {
r: number
g: number
b: number
}
const NAMED_TERMINAL_BACKGROUND_COLORS: Record<string, RgbColor> = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
transparent: { r: 0, g: 0, b: 0 }
}
export function isTerminalBackgroundLight(background: string | undefined): boolean {
const rgb = parseCssRgbColor(background)
if (!rgb) {
return false
}
const toLinear = (channel: number): number => {
const normalized = channel / 255
return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4)
}
const luminance = 0.2126 * toLinear(rgb.r) + 0.7152 * toLinear(rgb.g) + 0.0722 * toLinear(rgb.b)
return luminance > 0.5
}
function parseCssRgbColor(color: string | undefined): RgbColor | null {
const value = color?.trim().toLowerCase()
if (!value) {
return null
}
const named = NAMED_TERMINAL_BACKGROUND_COLORS[value]
if (named) {
return named
}
const hexMatch = value.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i)
if (hexMatch) {
const hex = hexMatch[1]
const channels =
hex.length === 3 || hex.length === 4
? hex
.slice(0, 3)
.split('')
.map((part) => Number.parseInt(part + part, 16))
: [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4, 6)].map((part) =>
Number.parseInt(part, 16)
)
return { r: channels[0], g: channels[1], b: channels[2] }
}
const rgbMatch = value.match(/^rgba?\((.+)\)$/)
if (!rgbMatch) {
return null
}
const parts = rgbMatch[1].includes(',')
? rgbMatch[1].split(',').slice(0, 3)
: rgbMatch[1].replace('/', ' ').trim().split(/\s+/).slice(0, 3)
if (parts.length !== 3) {
return null
}
const channels = parts.map(parseCssRgbChannel)
if (channels.some((channel) => channel === null)) {
return null
}
return { r: channels[0]!, g: channels[1]!, b: channels[2]! }
}
function parseCssRgbChannel(channel: string): number | null {
const trimmed = channel.trim()
const value = trimmed.endsWith('%')
? (Number.parseFloat(trimmed.slice(0, -1)) / 100) * 255
: Number.parseFloat(trimmed)
if (!Number.isFinite(value)) {
return null
}
return Math.min(255, Math.max(0, Math.round(value)))
}
const PALETTE_KEYS = [
'black',
'red',
+27 -3
View File
@@ -221,7 +221,11 @@ export type TerminalSlice = {
* model where the bell stays visible until the user engages with the
* surface that raised it. */
clearTerminalTabUnread: (tabId: string) => void
setTabCustomTitle: (tabId: string, title: string | null) => void
setTabCustomTitle: (
tabId: string,
title: string | null,
options?: { preservePaneTitleMirror?: boolean }
) => void
setTabColor: (tabId: string, color: string | null) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
clearTabPtyId: (tabId: string, ptyId?: string) => void
@@ -958,14 +962,34 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
},
setTabCustomTitle: (tabId, title) => {
setTabCustomTitle: (tabId, title, options) => {
set((s) => {
const next = { ...s.tabsByWorktree }
for (const wId of Object.keys(next)) {
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, customTitle: title } : t))
}
let terminalLayoutsByTabId = s.terminalLayoutsByTabId
const layout = s.terminalLayoutsByTabId[tabId]
if (options?.preservePaneTitleMirror && title) {
terminalLayoutsByTabId = {
...s.terminalLayoutsByTabId,
[tabId]: {
...(layout ?? emptyLayoutSnapshot()),
paneTitleMirroredCustomTitle: title
}
}
} else if (layout?.paneTitleMirroredCustomTitle !== undefined) {
const { paneTitleMirroredCustomTitle: _mirrorOwner, ...layoutWithoutMirrorOwner } = layout
// Why: generic tab renames, including same-text renames, are explicit
// owners. TerminalPane immediately restores this marker when Set Title
// is the caller that is intentionally mirroring a single-pane title.
terminalLayoutsByTabId = {
...s.terminalLayoutsByTabId,
[tabId]: layoutWithoutMirrorOwner
}
}
scheduleRuntimeGraphSync()
return { tabsByWorktree: next }
return { tabsByWorktree: next, terminalLayoutsByTabId }
})
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
+2
View File
@@ -485,6 +485,8 @@ export type TerminalLayoutSnapshot = {
/** User-assigned pane titles, keyed by stable layout leaf UUID.
* Persisted alongside buffers via the existing session:set flow. */
titlesByLeafId?: Record<string, string>
/** Single-pane title mirrored into the tab custom title by Terminal Set Title. */
paneTitleMirroredCustomTitle?: string
}
/** Minimal subset of OpenFile persisted across restarts.
+2 -1
View File
@@ -43,7 +43,8 @@ describe('parseWorkspaceSession', () => {
},
activeLeafId: 'pane:1',
expandedLeafId: null,
ptyIdsByLeafId: { 'pane:1': 'daemon-session-A' }
ptyIdsByLeafId: { 'pane:1': 'daemon-session-A' },
paneTitleMirroredCustomTitle: 'build'
}
},
activeWorktreeIdsOnShutdown: ['repo1::/path/wt1']
+2 -1
View File
@@ -49,7 +49,8 @@ const terminalLayoutSnapshotSchema = z.object({
expandedLeafId: z.string().nullable(),
ptyIdsByLeafId: z.record(z.string(), z.string()).optional(),
buffersByLeafId: z.record(z.string(), z.string()).optional(),
titlesByLeafId: z.record(z.string(), z.string()).optional()
titlesByLeafId: z.record(z.string(), z.string()).optional(),
paneTitleMirroredCustomTitle: z.string().optional()
})
// ─── Terminal tab (legacy) ──────────────────────────────────────────
+154
View File
@@ -9,6 +9,7 @@
* - closing panes works
*/
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
UUID_RE,
@@ -30,6 +31,7 @@ import {
waitForSessionReady,
waitForActiveWorktree,
getActiveWorktreeId,
getActiveTabId,
getActiveTabType,
getWorktreeTabs,
getAllWorktreeIds,
@@ -39,6 +41,81 @@ import {
} from './helpers/store'
import { pressShortcut } from './helpers/shortcuts'
async function setPaneTitleFromTerminalMenu(page: Page, title: string): Promise<void> {
const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] =
process.platform === 'win32' ? ['Control'] : []
await page
.locator('.xterm:visible')
.first()
.click({ button: 'right', position: { x: 40, y: 40 }, modifiers })
await page.getByText('Set Title…', { exact: true }).click()
const titleInput = page.locator('.pane-title-input').first()
await expect(titleInput).toBeVisible()
await titleInput.fill(title)
await titleInput.press('Enter')
}
async function getTabCustomTitle(
page: Page,
worktreeId: string,
tabId: string
): Promise<string | null> {
return page.evaluate(
({ targetWorktreeId, targetTabId }) => {
const state = window.__store!.getState()
const tab = (state.tabsByWorktree[targetWorktreeId] ?? []).find(
(entry) => entry.id === targetTabId
)
return tab?.customTitle ?? null
},
{ targetWorktreeId: worktreeId, targetTabId: tabId }
)
}
async function expectTabCustomTitle(
page: Page,
worktreeId: string,
tabId: string,
expected: string | null
): Promise<void> {
await expect
.poll(() => getTabCustomTitle(page, worktreeId, tabId), { timeout: 3_000 })
.toBe(expected)
}
async function setTabCustomTitleDirect(
page: Page,
tabId: string,
title: string | null
): Promise<void> {
await page.evaluate(
({ targetTabId, title }) => {
window.__store!.getState().setTabCustomTitle(targetTabId, title)
},
{ targetTabId: tabId, title }
)
}
async function expectSavedLayoutNotToContainTitle(
page: Page,
tabId: string,
title: string
): Promise<void> {
await expect
.poll(
() =>
page.evaluate(
({ targetTabId, title }) => {
const layout = window.__store!.getState().terminalLayoutsByTabId[targetTabId]
return Object.values(layout?.titlesByLeafId ?? {}).includes(title)
},
{ targetTabId: tabId, title }
),
{ timeout: 3_000 }
)
.toBe(false)
}
// Why: only the pointer-drag resize test needs a visible window (pointer
// capture requires a real pointer id). Every other pane operation here is
// driven through the exposed PaneManager API and runs fine headless, so the
@@ -139,6 +216,83 @@ test.describe('Terminal Panes', () => {
expect(activeLeafId).toMatch(UUID_RE)
})
test('Set Title ownership survives agent churn and preserves split semantics', async ({
orcaPage
}) => {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
let tabId = (await getActiveTabId(orcaPage))!
const customTitle = `Codex label ${Date.now()}`
const removeButtonTitle = `Remove button label ${Date.now()}`
const splitTitle = `Split label ${Date.now()}`
const runtimeTitle = '⠋ Codex split working'
const explicitTitle = `Explicit tab ${Date.now()}`
const paneOnlyTitle = `Pane only ${Date.now()}`
await setPaneTitleFromTerminalMenu(orcaPage, customTitle)
await expectTabCustomTitle(orcaPage, worktreeId, tabId, customTitle)
await orcaPage.getByRole('button', { name: `Edit pane title: ${customTitle}` }).focus()
await orcaPage.keyboard.press('Enter')
await expect(orcaPage.getByRole('textbox', { name: 'Pane title' })).toBeVisible()
await orcaPage.keyboard.press('Escape')
await orcaPage.evaluate((targetTabId) => {
window.__store!.getState().updateTabTitle(targetTabId, '⠋ Codex working')
}, tabId)
// Why: active agent CLIs continuously write OSC titles. A user-set label
// must stay visible as customTitle while the live title keeps changing.
await expect(
orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`)
).toHaveAttribute('data-tab-title', customTitle)
await setPaneTitleFromTerminalMenu(orcaPage, '')
await expectTabCustomTitle(orcaPage, worktreeId, tabId, null)
await expect(orcaPage.locator('.pane-title-text', { hasText: customTitle })).toBeHidden()
await expectSavedLayoutNotToContainTitle(orcaPage, tabId, customTitle)
await setPaneTitleFromTerminalMenu(orcaPage, removeButtonTitle)
await orcaPage.locator('.pane-title-bar', { hasText: removeButtonTitle }).hover()
await orcaPage.getByRole('button', { name: `Remove pane title: ${removeButtonTitle}` }).click()
await expectTabCustomTitle(orcaPage, worktreeId, tabId, null)
await expect(orcaPage.locator('.pane-title-text', { hasText: removeButtonTitle })).toBeHidden()
await expectSavedLayoutNotToContainTitle(orcaPage, tabId, removeButtonTitle)
await setPaneTitleFromTerminalMenu(orcaPage, splitTitle)
await expectTabCustomTitle(orcaPage, worktreeId, tabId, splitTitle)
await splitActiveTerminalPane(orcaPage, 'vertical')
await waitForPaneCount(orcaPage, 2)
await expectTabCustomTitle(orcaPage, worktreeId, tabId, null)
await expect(orcaPage.locator('.pane-title-text', { hasText: splitTitle })).toBeVisible()
await orcaPage.evaluate(
({ targetTabId, title }) => {
window.__store!.getState().updateTabTitle(targetTabId, title)
},
{ targetTabId: tabId, title: runtimeTitle }
)
await expect(
orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`)
).toHaveAttribute('data-tab-title', runtimeTitle)
await orcaPage.evaluate((targetWorktreeId) => {
window.__store!.getState().createTab(targetWorktreeId)
}, worktreeId)
await waitForPaneCount(orcaPage, 1)
tabId = (await getActiveTabId(orcaPage))!
await setPaneTitleFromTerminalMenu(orcaPage, `Mirror before explicit ${Date.now()}`)
await setTabCustomTitleDirect(orcaPage, tabId, explicitTitle)
await splitActiveTerminalPane(orcaPage, 'vertical')
await waitForPaneCount(orcaPage, 2)
await expectTabCustomTitle(orcaPage, worktreeId, tabId, explicitTitle)
await setPaneTitleFromTerminalMenu(orcaPage, paneOnlyTitle)
await expect(orcaPage.locator('.pane-title-text', { hasText: paneOnlyTitle })).toBeVisible()
await expectTabCustomTitle(orcaPage, worktreeId, tabId, explicitTitle)
})
test('closing a split pane prunes its leaf-keyed PTY binding without remapping siblings', async ({
orcaPage
}) => {
+216 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Restart persistence E2E covers separate lifecycle regressions that need real relaunches. */
/**
* E2E tests for terminal scrollback persistence across clean app restarts.
*
@@ -34,12 +35,14 @@ import {
waitForActiveTerminalManager,
waitForTerminalOutput,
waitForPaneCount,
getTerminalContent
getTerminalContent,
splitActiveTerminalPane
} from './helpers/terminal'
import {
waitForSessionReady,
waitForActiveWorktree,
getActiveWorktreeId,
getActiveTabId,
getWorktreeTabs,
ensureTerminalVisible
} from './helpers/store'
@@ -101,6 +104,77 @@ async function bootstrapRestoredLaunch(page: Page, expectedWorktreeId: string):
await waitForPaneCount(page, 1, 30_000)
}
async function setPaneTitleFromTerminalMenu(page: Page, title: string): Promise<void> {
const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] =
process.platform === 'win32' ? ['Control'] : []
await page
.locator('.xterm:visible')
.first()
.click({ button: 'right', position: { x: 40, y: 40 }, modifiers })
await page.getByText('Set Title…', { exact: true }).click()
const titleInput = page.locator('.pane-title-input').first()
await expect(titleInput).toBeVisible()
await titleInput.fill(title)
await titleInput.press('Enter')
}
async function getTabCustomTitle(
page: Page,
worktreeId: string,
tabId: string
): Promise<string | null> {
return page.evaluate(
({ targetWorktreeId, targetTabId }) => {
const state = window.__store!.getState()
const tab = (state.tabsByWorktree[targetWorktreeId] ?? []).find(
(entry) => entry.id === targetTabId
)
return tab?.customTitle ?? null
},
{ targetWorktreeId: worktreeId, targetTabId: tabId }
)
}
async function setTabCustomTitleDirect(
page: Page,
tabId: string,
title: string | null
): Promise<void> {
await page.evaluate(
({ targetTabId, title }) => {
window.__store!.getState().setTabCustomTitle(targetTabId, title)
},
{ targetTabId: tabId, title }
)
}
async function getSavedLayoutMirrorTitle(page: Page, tabId: string): Promise<string | null> {
return page.evaluate((targetTabId) => {
const layout = window.__store!.getState().terminalLayoutsByTabId[targetTabId]
return layout?.paneTitleMirroredCustomTitle ?? null
}, tabId)
}
async function expectSavedLayoutToContainTitle(
page: Page,
tabId: string,
title: string
): Promise<void> {
await expect
.poll(
() =>
page.evaluate(
({ targetTabId, title }) => {
const layout = window.__store!.getState().terminalLayoutsByTabId[targetTabId]
return Object.values(layout?.titlesByLeafId ?? {}).includes(title)
},
{ targetTabId: tabId, title }
),
{ timeout: 3_000 }
)
.toBe(true)
}
test.describe('Terminal restart persistence', () => {
test('scrollback survives clean quit and relaunch', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
@@ -227,6 +301,147 @@ test.describe('Terminal restart persistence', () => {
}
})
test('restored single-pane Set Title ownership clears after split', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
if (!repoPath || !existsSync(repoPath)) {
test.skip(true, 'Global setup did not produce a seeded test repo')
return
}
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
let secondApp: ElectronApplication | null = null
try {
const firstLaunch = await session.launch()
firstApp = firstLaunch.app
const { worktreeId } = await bootstrapFirstLaunch(firstLaunch.page, repoPath)
const title = `Restored pane label ${Date.now()}`
const firstTabId = (await getActiveTabId(firstLaunch.page))!
await setPaneTitleFromTerminalMenu(firstLaunch.page, title)
await expect
.poll(() => getTabCustomTitle(firstLaunch.page, worktreeId, firstTabId), {
timeout: 3_000
})
.toBe(title)
await session.close(firstApp)
firstApp = null
const secondLaunch = await session.launch()
secondApp = secondLaunch.app
await bootstrapRestoredLaunch(secondLaunch.page, worktreeId)
const restoredTabId = (await getActiveTabId(secondLaunch.page))!
await expect(secondLaunch.page.locator('.pane-title-text', { hasText: title })).toBeVisible()
await expect
.poll(() => getTabCustomTitle(secondLaunch.page, worktreeId, restoredTabId), {
timeout: 3_000
})
.toBe(title)
await expectSavedLayoutToContainTitle(secondLaunch.page, restoredTabId, title)
await expect
.poll(() => getSavedLayoutMirrorTitle(secondLaunch.page, restoredTabId), {
timeout: 3_000
})
.toBe(title)
await splitActiveTerminalPane(secondLaunch.page, 'vertical')
await waitForPaneCount(secondLaunch.page, 2)
await expect
.poll(() => getTabCustomTitle(secondLaunch.page, worktreeId, restoredTabId), {
timeout: 3_000
})
.toBe(null)
const runtimeTitle = '⠋ Codex restored split working'
await secondLaunch.page.evaluate(
({ targetTabId, title }) => {
window.__store!.getState().updateTabTitle(targetTabId, title)
},
{ targetTabId: restoredTabId, title: runtimeTitle }
)
await expect(
secondLaunch.page.locator(`[data-testid="sortable-tab"][data-tab-id="${restoredTabId}"]`)
).toHaveAttribute('data-tab-title', runtimeTitle)
} finally {
if (secondApp) {
await session.close(secondApp)
}
if (firstApp) {
await session.close(firstApp)
}
await session.dispose()
}
})
test('restored same-text explicit tab title survives split', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
if (!repoPath || !existsSync(repoPath)) {
test.skip(true, 'Global setup did not produce a seeded test repo')
return
}
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
let secondApp: ElectronApplication | null = null
try {
const firstLaunch = await session.launch()
firstApp = firstLaunch.app
const { worktreeId } = await bootstrapFirstLaunch(firstLaunch.page, repoPath)
const title = `Explicit same label ${Date.now()}`
const firstTabId = (await getActiveTabId(firstLaunch.page))!
await setPaneTitleFromTerminalMenu(firstLaunch.page, title)
await expect
.poll(() => getTabCustomTitle(firstLaunch.page, worktreeId, firstTabId), {
timeout: 3_000
})
.toBe(title)
await session.close(firstApp)
firstApp = null
const secondLaunch = await session.launch()
secondApp = secondLaunch.app
await bootstrapRestoredLaunch(secondLaunch.page, worktreeId)
const restoredTabId = (await getActiveTabId(secondLaunch.page))!
await expect
.poll(() => getSavedLayoutMirrorTitle(secondLaunch.page, restoredTabId), {
timeout: 3_000
})
.toBe(title)
await setTabCustomTitleDirect(secondLaunch.page, restoredTabId, title)
await expect
.poll(() => getSavedLayoutMirrorTitle(secondLaunch.page, restoredTabId), {
timeout: 3_000
})
.toBe(null)
await splitActiveTerminalPane(secondLaunch.page, 'vertical')
await waitForPaneCount(secondLaunch.page, 2)
await expect
.poll(() => getTabCustomTitle(secondLaunch.page, worktreeId, restoredTabId), {
timeout: 3_000
})
.toBe(title)
} finally {
if (secondApp) {
await session.close(secondApp)
}
if (firstApp) {
await session.close(firstApp)
}
await session.dispose()
}
})
test('idle session does not spam session.set writes', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()