fix(tui): preserve family session recency
This commit is contained in:
parent
dbd934689f
commit
729531550c
7 changed files with 165 additions and 56 deletions
|
|
@ -444,6 +444,18 @@ function run(db: DatabaseService, event: MessageEvent) {
|
|||
})
|
||||
}
|
||||
|
||||
function runAndTouch(db: DatabaseService, event: MessageEvent) {
|
||||
return Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
}
|
||||
|
||||
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
|
||||
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const encoded = encodeMessage(message)
|
||||
|
|
@ -678,9 +690,9 @@ const layer = Layer.effectDiscard(
|
|||
})
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => runAndTouch(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => runAndTouch(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => runAndTouch(db, event))
|
||||
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
|
||||
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -659,6 +659,7 @@ describe("SessionProjector", () => {
|
|||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
time_updated: -1,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -670,9 +671,17 @@ describe("SessionProjector", () => {
|
|||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const updated = () =>
|
||||
db
|
||||
.select({ value: SessionTable.time_updated })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
|
||||
expect((yield* suspended())?.timeSuspended).toBeNull()
|
||||
expect((yield* updated())?.value ?? -1).toBeGreaterThan(-1)
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Started, { sessionID })
|
||||
expect((yield* suspended())?.timeSuspended).toBeNull()
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
|
|||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { useSessionTabs } from "../../context/session-tabs"
|
||||
import { SESSION_INBOX_MIN_TERMINAL_WIDTH } from "../../context/session-tabs-model"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
|
|
@ -821,7 +822,7 @@ export function Prompt(props: PromptProps) {
|
|||
store.mode === "normal" &&
|
||||
!auto()?.visible &&
|
||||
config.tabs?.layout === "inbox" &&
|
||||
dimensions().width >= 72 &&
|
||||
dimensions().width >= SESSION_INBOX_MIN_TERMINAL_WIDTH &&
|
||||
sessionTabs.enabled() &&
|
||||
sessionTabs.tabs().length > 0 &&
|
||||
store.prompt.text === "" &&
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useConfig } from "../config"
|
|||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { sessionInboxGroup, sessionInboxWidth, type SessionInboxGroup } from "../context/session-tabs-model"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
|
|
@ -25,7 +24,7 @@ const labels: Record<SessionInboxGroup, string> = {
|
|||
earlier: "Earlier",
|
||||
}
|
||||
|
||||
export type SessionInboxRowInfo = {
|
||||
type SessionInboxRowInfo = {
|
||||
sessionID: string
|
||||
title: string
|
||||
updated: number
|
||||
|
|
@ -34,13 +33,13 @@ export type SessionInboxRowInfo = {
|
|||
group: SessionInboxGroup
|
||||
}
|
||||
|
||||
export function SessionInboxRow(props: {
|
||||
function SessionInboxRow(props: {
|
||||
row: SessionInboxRowInfo
|
||||
selected?: boolean
|
||||
focused?: boolean
|
||||
pendingDone?: boolean
|
||||
number?: number
|
||||
verb?: string
|
||||
verb: string
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
const theme = useTheme("elevated")
|
||||
|
|
@ -132,8 +131,8 @@ export function SessionInboxRow(props: {
|
|||
</text>
|
||||
</Match>
|
||||
<Match when={props.row.status.busy}>
|
||||
<Spinner color={accent()}>
|
||||
<span style={{ fg: accent() }}>{props.verb ?? activityVerb(props.row.sessionID)}</span>
|
||||
<Spinner color={accent()}>
|
||||
<span style={{ fg: accent() }}>{props.verb}</span>
|
||||
</Spinner>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -147,24 +146,26 @@ export function SessionInboxRow(props: {
|
|||
export function SessionInbox() {
|
||||
const tabs = useSessionTabs()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const theme = useTheme("elevated")
|
||||
const themes = useThemes()
|
||||
const config = useConfig().data
|
||||
const prompt = usePromptRef()
|
||||
const keymap = Keymap.use()
|
||||
const dimensions = useTerminalDimensions()
|
||||
let scroll: ScrollBoxRenderable
|
||||
const [verbCycle, setVerbCycle] = createSignal(0)
|
||||
const [groupClock, setGroupClock] = createSignal(Date.now())
|
||||
const verbTimer = setInterval(() => setVerbCycle((value) => value + 1), 3_500)
|
||||
const groupTimer = setInterval(() => setGroupClock(Date.now()), 60_000)
|
||||
onCleanup(() => {
|
||||
clearInterval(verbTimer)
|
||||
clearInterval(groupTimer)
|
||||
tabs.navigation.blur()
|
||||
})
|
||||
const width = createMemo(() => sessionInboxWidth(dimensions().width))
|
||||
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const rows = createMemo(() => {
|
||||
verbCycle()
|
||||
return tabs
|
||||
.recent()
|
||||
.map((tab) => {
|
||||
|
|
@ -187,17 +188,16 @@ export function SessionInbox() {
|
|||
updated,
|
||||
preview: markdownPreview(preview ?? "") || "No assistant response yet",
|
||||
status,
|
||||
group: sessionInboxGroup(updated, status.busy),
|
||||
group: sessionInboxGroup(updated, status.busy, groupClock()),
|
||||
}
|
||||
})
|
||||
.toSorted((a, b) => b.updated - a.updated)
|
||||
})
|
||||
const groups = createMemo(() =>
|
||||
(["running", "today", "yesterday", "earlier"] as const)
|
||||
.map((group) => ({ group, rows: rows().filter((row) => row.group === group) }))
|
||||
.filter((group) => group.rows.length > 0),
|
||||
)
|
||||
const order = () => groups().flatMap((group) => group.rows.map((row) => row.sessionID))
|
||||
const numbers = createMemo(() => new Map(rows().map((row, index) => [row.sessionID, index + 1])))
|
||||
|
||||
createEffect(() => {
|
||||
if (!tabs.navigation.active()) return
|
||||
|
|
@ -219,12 +219,7 @@ export function SessionInbox() {
|
|||
prompt.current?.focus()
|
||||
}
|
||||
const newSession = () => {
|
||||
tabs.navigation.blur()
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
|
||||
})
|
||||
setTimeout(() => prompt.current?.focus(), 0)
|
||||
keymap.dispatch("session.new")
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
|
|
@ -236,18 +231,17 @@ export function SessionInbox() {
|
|||
bind: "up,shift+tab",
|
||||
title: "Previous session",
|
||||
group: "Session",
|
||||
run: () => tabs.navigation.move(-1, order()),
|
||||
run: () => tabs.navigation.move(-1),
|
||||
},
|
||||
{
|
||||
bind: "down,tab",
|
||||
title: "Next session",
|
||||
group: "Session",
|
||||
run: () => tabs.navigation.move(1, order()),
|
||||
run: () => tabs.navigation.move(1),
|
||||
},
|
||||
{ bind: "return", title: "Open session", group: "Session", run: () => tabs.navigation.select() },
|
||||
{ bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done(order()) },
|
||||
{ bind: "right", title: "Return to prompt", group: "Session", run: leave },
|
||||
{ bind: "escape", title: "Return to prompt", group: "Session", run: leave },
|
||||
{ bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done() },
|
||||
{ bind: "right,escape", title: "Return to prompt", group: "Session", run: leave },
|
||||
],
|
||||
}))
|
||||
|
||||
|
|
@ -301,7 +295,7 @@ export function SessionInbox() {
|
|||
selected={tabs.current() === row.sessionID}
|
||||
focused={tabs.navigation.active() && tabs.navigation.selected() === row.sessionID}
|
||||
pendingDone={tabs.navigation.pendingDone() === row.sessionID}
|
||||
number={order().indexOf(row.sessionID) + 1}
|
||||
number={numbers().get(row.sessionID)}
|
||||
verb={activityVerb(row.sessionID, verbCycle())}
|
||||
onSelect={() => {
|
||||
tabs.navigation.blur()
|
||||
|
|
|
|||
|
|
@ -84,17 +84,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
}
|
||||
|
||||
const root = (sessionID: string) => data.session.root(sessionID)
|
||||
const updated = (sessionID: string) =>
|
||||
Math.max(data.session.get(sessionID)?.time.updated ?? 0, lastActivity()[root(sessionID)] ?? 0)
|
||||
const touch = (sessionID: string, created: number) => {
|
||||
const updated = (sessionID: string) => {
|
||||
const session = root(sessionID)
|
||||
setLastActivity((value) => ({ ...value, [session]: Math.max(value[session] ?? 0, created) }))
|
||||
const members = data.session.family(session)
|
||||
return (members.length > 0 ? members : [session]).reduce(
|
||||
(latest, id) =>
|
||||
Math.max(
|
||||
latest,
|
||||
data.session.get(id)?.time.updated ?? 0,
|
||||
lastActivity()[id] ?? 0,
|
||||
),
|
||||
0,
|
||||
)
|
||||
}
|
||||
const touch = (sessionID: string, created: number) => {
|
||||
if (!enabled()) return
|
||||
if ((lastActivity()[sessionID] ?? 0) >= created) return
|
||||
setLastActivity((value) => ({ ...value, [sessionID]: Math.max(value[sessionID] ?? 0, created) }))
|
||||
}
|
||||
const title = (sessionID: string, persisted?: string, fallback?: string) => {
|
||||
const session = data.session.get(sessionID)
|
||||
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
|
||||
}
|
||||
const normalize = (value: TabsState) => ({
|
||||
const normalize = (value: TabsState): TabsState => ({
|
||||
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
const sessionID = root(tab.sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
|
||||
|
|
@ -212,7 +224,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
for (const sessionID of sessions) {
|
||||
if (stale) return
|
||||
await Promise.allSettled([
|
||||
data.session.sync(sessionID),
|
||||
data.session.sync(sessionID, { children: true }),
|
||||
data.session.message.sync(sessionID),
|
||||
data.session.pending.sync(sessionID),
|
||||
data.session.permission.sync(sessionID),
|
||||
|
|
@ -247,8 +259,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
onCleanup(
|
||||
event.on("session.input.admitted", (evt) => {
|
||||
if (!enabled() || evt.data.input.type !== "user") return
|
||||
touch(evt.data.sessionID, evt.created)
|
||||
const sessionID = root(evt.data.sessionID)
|
||||
touch(sessionID, evt.created)
|
||||
if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
|
||||
setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))
|
||||
}),
|
||||
|
|
@ -286,6 +298,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
delete next[target]
|
||||
return next
|
||||
})
|
||||
setLastActivity((activity) => {
|
||||
const family = data.session.family(target)
|
||||
const members = family.length > 0 ? family : [target]
|
||||
if (!members.some((id) => activity[id] !== undefined)) return activity
|
||||
const next = { ...activity }
|
||||
for (const id of members) delete next[id]
|
||||
return next
|
||||
})
|
||||
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
|
||||
}
|
||||
|
||||
|
|
@ -364,9 +384,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
active: navigationActive,
|
||||
selected: navigationSelection,
|
||||
pendingDone: navigationPendingDone,
|
||||
focus(order: readonly string[] = recent().map((tab) => tab.sessionID)) {
|
||||
focus() {
|
||||
if (!enabled() || state().tabs.length === 0) return false
|
||||
setNavigationSelection(current() ?? order.find((sessionID) => state().tabs.some((tab) => tab.sessionID === sessionID)))
|
||||
setNavigationSelection(current() ?? recent()[0]?.sessionID)
|
||||
setNavigationPendingDone(undefined)
|
||||
setNavigationActive(true)
|
||||
return true
|
||||
|
|
@ -375,10 +395,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
setNavigationActive(false)
|
||||
setNavigationPendingDone(undefined)
|
||||
},
|
||||
move(direction: 1 | -1, order?: readonly string[]) {
|
||||
const tabs = (order ?? state().tabs.map((tab) => tab.sessionID)).filter((sessionID) =>
|
||||
state().tabs.some((tab) => tab.sessionID === sessionID),
|
||||
)
|
||||
move(direction: 1 | -1) {
|
||||
const tabs = recent().map((tab) => tab.sessionID)
|
||||
if (!navigationActive() || tabs.length === 0) return
|
||||
const index = tabs.findIndex((sessionID) => sessionID === navigationSelection())
|
||||
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
|
||||
|
|
@ -392,21 +410,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
setNavigationPendingDone(undefined)
|
||||
route.navigate({ type: "session", sessionID })
|
||||
},
|
||||
done(order?: readonly string[]) {
|
||||
done() {
|
||||
const sessionID = navigationSelection()
|
||||
if (!navigationActive() || !sessionID) return
|
||||
if (navigationPendingDone() !== sessionID) {
|
||||
setNavigationPendingDone(sessionID)
|
||||
return
|
||||
}
|
||||
const tabs = (order ?? state().tabs.map((tab) => tab.sessionID)).filter((id) =>
|
||||
state().tabs.some((tab) => tab.sessionID === id),
|
||||
)
|
||||
const tabs = recent().map((tab) => tab.sessionID)
|
||||
const index = tabs.indexOf(sessionID)
|
||||
const next = tabs[index + 1] ?? tabs[index - 1]
|
||||
const selected = current() === sessionID
|
||||
setNavigationPendingDone(undefined)
|
||||
setNavigationSelection(next)
|
||||
remove(sessionID, true)
|
||||
setNavigationSelection(selected ? (current() ?? next) : next)
|
||||
if (!next) setNavigationActive(false)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -49,22 +49,43 @@ function stateDir(prefix: string) {
|
|||
return dir
|
||||
}
|
||||
|
||||
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
|
||||
type SessionFixture = { parentID?: string; title?: string; updated?: number }
|
||||
|
||||
async function renderSessionTabs(
|
||||
initialSessionID: string,
|
||||
options?: { state?: string; title?: string; sessions?: Record<string, SessionFixture> },
|
||||
) {
|
||||
const state = options?.state ?? stateDir("opencode-session-tabs-")
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== `/api/session/${initialSessionID}`) return
|
||||
return json({
|
||||
data: {
|
||||
id: initialSessionID,
|
||||
title: options?.title,
|
||||
if (url.pathname === "/api/session" && url.searchParams.has("parentID")) {
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
return json({
|
||||
data: Object.entries(options?.sessions ?? {}).flatMap(([id, fixture]) =>
|
||||
fixture.parentID === parentID ? [session(id, fixture)] : [],
|
||||
),
|
||||
cursor: {},
|
||||
})
|
||||
}
|
||||
const match = /^\/api\/session\/([^/]+)$/.exec(url.pathname)
|
||||
if (!match) return
|
||||
const id = match[1]!
|
||||
const fixture = options?.sessions?.[id] ?? (id === initialSessionID ? { title: options?.title } : undefined)
|
||||
if (!fixture) return
|
||||
return json({ data: session(id, fixture) })
|
||||
|
||||
function session(id: string, fixture: SessionFixture) {
|
||||
return {
|
||||
id,
|
||||
parentID: fixture.parentID,
|
||||
title: fixture.title,
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
},
|
||||
})
|
||||
time: { created: 0, updated: fixture.updated ?? 0 },
|
||||
}
|
||||
}
|
||||
}, events)
|
||||
let tabs!: ReturnType<typeof useSessionTabs>
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
|
|
@ -249,6 +270,37 @@ test("tracks live inbox recency beyond cached session metadata", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("tracks child activity before family hydration and after restart", async () => {
|
||||
const state = stateDir("opencode-session-tabs-recency-")
|
||||
const sessions: Record<string, SessionFixture> = { child: { parentID: "parent" } }
|
||||
const first = await renderSessionTabs("parent", { state, sessions })
|
||||
|
||||
try {
|
||||
first.emit({
|
||||
id: "evt_child_succeeded",
|
||||
created: 200,
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: { sessionID: "child" },
|
||||
})
|
||||
expect(first.tabs.updated("parent")).toBe(0)
|
||||
await first.data.session.sync("child")
|
||||
await wait(() => first.tabs.updated("parent") === 200)
|
||||
} finally {
|
||||
first.destroy()
|
||||
}
|
||||
|
||||
sessions.child.updated = 200
|
||||
const second = await renderSessionTabs("parent", { state, sessions })
|
||||
try {
|
||||
expect(second.tabs.updated("parent")).toBe(0)
|
||||
await second.data.session.sync("parent", { children: true })
|
||||
await wait(() => second.tabs.updated("parent") === 200)
|
||||
} finally {
|
||||
second.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("tracks a temporary new session tab across close and creation", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
|
|
@ -309,3 +361,27 @@ test("navigates the inbox without changing sessions and confirms done twice", as
|
|||
setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps inbox focus aligned with history after marking the current session done", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.current() === "first")
|
||||
setup.route.navigate({ type: "session", sessionID: "second" })
|
||||
await wait(() => setup.tabs.current() === "second")
|
||||
setup.route.navigate({ type: "session", sessionID: "third" })
|
||||
await wait(() => setup.tabs.current() === "third")
|
||||
setup.route.navigate({ type: "session", sessionID: "first" })
|
||||
await wait(() => setup.tabs.current() === "first" && setup.tabs.tabs().length === 3)
|
||||
|
||||
setup.tabs.navigation.focus()
|
||||
setup.tabs.navigation.done()
|
||||
setup.tabs.navigation.done()
|
||||
await wait(() => setup.tabs.tabs().length === 2)
|
||||
|
||||
expect(setup.tabs.current()).toBe("third")
|
||||
expect(setup.tabs.navigation.selected()).toBe("third")
|
||||
} finally {
|
||||
setup.destroy()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ACTIVITY_VERBS, activityVerb } from "../../src/util/activity-verb"
|
|||
|
||||
test("rotates through 60 stable activity verbs", () => {
|
||||
expect(ACTIVITY_VERBS).toHaveLength(60)
|
||||
expect(new Set(ACTIVITY_VERBS).size).toBe(60)
|
||||
expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", 60))
|
||||
expect(new Set(ACTIVITY_VERBS).size).toBe(ACTIVITY_VERBS.length)
|
||||
expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", ACTIVITY_VERBS.length))
|
||||
expect(activityVerb("session-a", 1)).not.toBe(activityVerb("session-a", 0))
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue