mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cc7ccd3c1 | |||
| db45026c6c | |||
| 77df98db51 |
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
() => {},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user