Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton 6cc7ccd3c1 feat(tui): preserve scroll position on submit 2026-07-31 14:37:47 +00:00
Kit Langton db45026c6c fix(tui): default tabs to global scope (#39783) 2026-07-31 10:15:53 -04:00
Kit Langton 77df98db51 Revert "feat(tui): delete current session (#39750)"
This reverts commit 7814568ba0.
2026-07-31 10:01:35 -04:00
4 changed files with 100 additions and 80 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ export const settings: Setting[] = [
title: "Scope",
category: "Tabs",
path: ["tabs", "scope"],
default: "cwd",
default: "global",
values: ["cwd", "global"],
labels: ["current directory", "global"],
},
+3 -3
View File
@@ -66,12 +66,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
let closedTabs: ClosedSessionTab[] = []
function state() {
if (config.tabs?.scope === "global") return store.global
return store.cwd[paths.cwd] ?? fallback
if (config.tabs?.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
return store.global
}
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs?.scope ?? "cwd"
const scope = config.tabs?.scope ?? "global"
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
() => {},
)
+78 -70
View File
@@ -51,7 +51,6 @@ import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogConfirm } from "../../ui/dialog-confirm"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
@@ -296,6 +295,7 @@ export function Session() {
let seeded = false
let sent = false
let scroll: ScrollBoxRenderable
const [showJumpToBottom, setShowJumpToBottom] = createSignal(false)
const [prompt, setPrompt] = createSignal<PromptRef>()
const bind = (r: PromptRef | undefined) => {
setPrompt(r)
@@ -315,6 +315,11 @@ export function Session() {
})
}
const updateJumpToBottom = () => {
if (!scroll || scroll.isDestroyed) return
setShowJumpToBottom(scroll.scrollTop + scroll.viewport.height < scroll.scrollHeight - 1)
}
// Tail-first transcript mounting: only the newest rows mount when the session opens, and the
// rest backfill in chunks shortly after, so switching to a long session costs the visible tail
// instead of the whole transcript. Until backfill pins the count, the hidden span derives from
@@ -323,6 +328,10 @@ export function Session() {
const [hiddenRows, setHiddenRows] = createSignal<number>()
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
createEffect(() => {
visibleRows().length
afterLayout(updateJumpToBottom)
})
createEffect(() => {
const current = hidden()
if (current === 0) return
@@ -410,6 +419,7 @@ export function Session() {
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
updateJumpToBottom()
}, 50)
}
@@ -422,6 +432,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
updateJumpToBottom()
dialog.clear()
},
},
@@ -433,6 +444,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
updateJumpToBottom()
dialog.clear()
},
},
@@ -444,6 +456,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
updateJumpToBottom()
dialog.clear()
},
},
@@ -455,6 +468,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
updateJumpToBottom()
dialog.clear()
},
},
@@ -466,6 +480,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
updateJumpToBottom()
dialog.clear()
},
},
@@ -477,6 +492,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
updateJumpToBottom()
dialog.clear()
},
},
@@ -491,6 +507,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
updateJumpToBottom()
dialog.clear()
},
},
@@ -502,6 +519,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight)
updateJumpToBottom()
dialog.clear()
},
},
@@ -523,32 +541,6 @@ export function Session() {
slash: { name: "rename" },
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
},
{
title: "Delete session",
id: "session.delete",
group: "Session",
slash: { name: "delete" },
run: async () => {
const current = session()
if (!current) return
const confirmed = await DialogConfirm.show(
dialog,
"Delete Session",
`Delete "${current.title}"? This action cannot be undone.`,
)
if (confirmed !== true) return
const error = await client.api.session.remove({ sessionID: route.sessionID }).then(
() => undefined,
(error) => error,
)
if (!error) return
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
})
},
},
{
title: "Jump to message",
id: "session.timeline",
@@ -987,48 +979,67 @@ export function Session() {
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
<box flexGrow={1} minHeight={0}>
<scrollbox
id="session-transcript"
ref={(r) => (scroll = r)}
onMouseScroll={() => setTimeout(updateJumpToBottom, 0)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
messages().filter(
(message) => message.id >= session()!.revert!.messageID && message.type === "user",
).length
}
files={session()!.revert!.files ?? []}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
messages().filter(
(message) => message.id >= session()!.revert!.messageID && message.type === "user",
).length
}
files={session()!.revert!.files ?? []}
/>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<Show when={showJumpToBottom()}>
<box
id="session-jump-to-bottom"
position="absolute"
zIndex={1000}
right={showScrollbar() ? 2 : 0}
bottom={0}
backgroundColor={theme.raise(theme.background.default)}
paddingLeft={1}
paddingRight={1}
onMouseUp={toBottom}
>
<text fg={theme.text.default}> jump to bottom</text>
</box>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
</box>
<box flexShrink={0}>
<Composer
sessionID={route.sessionID}
@@ -1061,9 +1072,6 @@ export function Session() {
visible={true}
ref={bind}
disabled={false}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
/>
</Match>
@@ -64,6 +64,7 @@ async function renderSessionTabs(initialSessionID: string) {
return {
tabs,
route,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
destroy() {
app.renderer.destroy()
@@ -72,6 +73,21 @@ async function renderSessionTabs(initialSessionID: string) {
}
}
test("stores session tabs globally by default", async () => {
const setup = await renderSessionTabs("first")
try {
const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0)
expect(await Bun.file(file).json()).toEqual({
global: { tabs: [{ sessionID: "first" }], unread: {} },
cwd: {},
})
} finally {
setup.destroy()
}
})
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
@@ -106,9 +122,7 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
expect(setup.tabs.status("background").promptPulse).toBe(0)
setup.emit(admitted("background", "msg_1"))
await wait(
() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy,
)
await wait(() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy)
setup.emit(admitted("background", "msg_2"))
await wait(() => setup.tabs.status("background").promptPulse === 2)
@@ -144,9 +158,7 @@ test("tracks a temporary new session tab across close and creation", async () =>
await wait(() => setup.tabs.newTab())
setup.route.navigate({ type: "session", sessionID: "third" })
expect(setup.tabs.newTab()).toBe(true)
await wait(
() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"),
)
await wait(() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"))
expect(setup.tabs.newTab()).toBe(false)
expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)