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