diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8ebc415b4..4d6f1973c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -87,6 +87,7 @@ import { createShellNavigation } from "./layout/shell-navigation" import { buildPawworkSessionWindow, nextPawworkSessionWindowLimit, + type PawworkWindowSession, PAWWORK_SESSION_WINDOW_INITIAL, sortPawworkSessionWindowSessions, } from "./layout/pawwork-session-window" @@ -524,9 +525,9 @@ export default function Layout(props: ParentProps) { const [pawworkSessionWindowState, setPawworkSessionWindowState] = createStore({ limit: PAWWORK_SESSION_WINDOW_INITIAL, - normal: [] as Session[], - pinned: [] as Session[], - active: undefined as Session | undefined, + normal: [] as PawworkWindowSession[], + pinned: [] as PawworkWindowSession[], + active: undefined as PawworkWindowSession | undefined, hasMore: false, loading: false, }) @@ -581,10 +582,26 @@ export default function Layout(props: ParentProps) { const [store] = globalSync.child(session.directory, { bootstrap: false, pin: false }) return store.message[session.id] }, + partsForMessage: (session, messageID) => { + const [store] = globalSync.child(session.directory, { bootstrap: false, pin: false }) + return store.part[messageID] + }, }) return sortPawworkSidebarSessions(rows.map((item) => ({ ...item, id: item.session.id }))).map(({ id: _, ...item }) => item) }) + const mergePawworkWindowSessionMetadata = ( + session: Session | PawworkWindowSession, + existing?: PawworkWindowSession, + ): PawworkWindowSession => { + const next = session as PawworkWindowSession + return { + ...session, + activityAt: next.activityAt ?? existing?.activityAt, + lastUserMessageAt: next.lastUserMessageAt ?? existing?.lastUserMessageAt, + } + } + async function loadPawworkSessionWindow() { if (!pageReady()) return if (!layoutReady()) return @@ -595,17 +612,33 @@ export default function Layout(props: ParentProps) { const response = await globalSDK.client.experimental.session.list({ roots: true, limit: pawworkSessionWindowState.limit, - sort: "created", + sort: "activity", }) if (rev !== pawworkSessionWindowRev) return - const normal = ((response.data ?? []) as Session[]).filter((session) => !session.time?.archived) + const normal = ((response.data ?? []) as PawworkWindowSession[]).filter((session) => !session.time?.archived) const loaded = new Map(normal.map((session) => [session.id, session])) + const existing = new Map( + [ + ...pawworkSessionWindowState.normal, + ...pawworkSessionWindowState.pinned, + ...(pawworkSessionWindowState.active ? [pawworkSessionWindowState.active] : []), + ].map((session) => [session.id, session] as const), + ) const pinned = ( await Promise.all( - store.pawworkPinnedSessions.map(async (id) => loaded.get(id) ?? (await loadSessionByID(id))), + store.pawworkPinnedSessions.map(async (id) => { + const session = loaded.get(id) ?? (await loadSessionByID(id)) + return session ? mergePawworkWindowSessionMetadata(session, existing.get(id)) : undefined + }), ) - ).filter((session): session is Session => !!session) - const active = params.id ? (loaded.get(params.id) ?? (await loadSessionByID(params.id))) : undefined + ).filter((session): session is PawworkWindowSession => !!session) + const activeID = params.id + const active = activeID + ? await (async () => { + const session = loaded.get(activeID) ?? (await loadSessionByID(activeID)) + return session ? mergePawworkWindowSessionMetadata(session, existing.get(activeID)) : undefined + })() + : undefined if (rev !== pawworkSessionWindowRev) return batch(() => { @@ -644,17 +677,20 @@ export default function Layout(props: ParentProps) { const upsertPawworkWindowSession = (info: Session) => { if (info.parentID || info.time?.archived) return + const mergeWindowSession = (current: PawworkWindowSession[]) => { + const existing = current.find((session) => session.id === info.id) + const next = mergePawworkWindowSessionMetadata(info, existing) + return sortPawworkSessionWindowSessions([...current.filter((session) => session.id !== info.id), next]) + } batch(() => { - setPawworkSessionWindowState("normal", (current) => - sortPawworkSessionWindowSessions([...current.filter((session) => session.id !== info.id), info]), - ) + setPawworkSessionWindowState("normal", mergeWindowSession) if (store.pawworkPinnedSessions.includes(info.id)) { - setPawworkSessionWindowState("pinned", (current) => - sortPawworkSessionWindowSessions([...current.filter((session) => session.id !== info.id), info]), - ) + setPawworkSessionWindowState("pinned", mergeWindowSession) } if (params.id === info.id) { - setPawworkSessionWindowState("active", info) + setPawworkSessionWindowState("active", (current) => + current?.id === info.id ? mergePawworkWindowSessionMetadata(info, current) : mergePawworkWindowSessionMetadata(info), + ) } }) } diff --git a/packages/app/src/pages/layout/pawwork-project-labels.test.ts b/packages/app/src/pages/layout/pawwork-project-labels.test.ts new file mode 100644 index 000000000..21fbc2a3c --- /dev/null +++ b/packages/app/src/pages/layout/pawwork-project-labels.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { resolvePawworkProjectLabels } from "./pawwork-session-source" + +describe("resolvePawworkProjectLabels", () => { + test("keeps unique project names unchanged", () => { + const result = resolvePawworkProjectLabels( + [ + { worktree: "/Users/yuhan/dev/pawwork", name: "PawWork" }, + { worktree: "/Users/yuhan/oss/opencli", name: "OpenCLI" }, + ], + "/Users/yuhan", + ) + + expect(result.get("/Users/yuhan/dev/pawwork")).toBe("PawWork") + expect(result.get("/Users/yuhan/oss/opencli")).toBe("OpenCLI") + }) + + test("falls back to a shortened worktree path when display names collide", () => { + const result = resolvePawworkProjectLabels( + [ + { worktree: "/Users/yuhan/dev/one/app", name: "app" }, + { worktree: "/Users/yuhan/oss/two/app", name: "app" }, + ], + "/Users/yuhan", + ) + + expect(result.get("/Users/yuhan/dev/one/app")).toBe("~/dev/one/app") + expect(result.get("/Users/yuhan/oss/two/app")).toBe("~/oss/two/app") + }) +}) diff --git a/packages/app/src/pages/layout/pawwork-session-source.test.ts b/packages/app/src/pages/layout/pawwork-session-source.test.ts deleted file mode 100644 index 714e69dc4..000000000 --- a/packages/app/src/pages/layout/pawwork-session-source.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - buildPawworkSidebarSessionRows, - pawworkSidebarSessionTime, - resolvePawworkProjectLabels, - sortPawworkSidebarSessions, -} from "./pawwork-session-source" - -describe("resolvePawworkProjectLabels", () => { - test("keeps unique project names unchanged", () => { - const result = resolvePawworkProjectLabels( - [ - { worktree: "/Users/yuhan/dev/pawwork", name: "PawWork" }, - { worktree: "/Users/yuhan/oss/opencli", name: "OpenCLI" }, - ], - "/Users/yuhan", - ) - - expect(result.get("/Users/yuhan/dev/pawwork")).toBe("PawWork") - expect(result.get("/Users/yuhan/oss/opencli")).toBe("OpenCLI") - }) - - test("falls back to a shortened worktree path when display names collide", () => { - const result = resolvePawworkProjectLabels( - [ - { worktree: "/Users/yuhan/dev/one/app", name: "app" }, - { worktree: "/Users/yuhan/oss/two/app", name: "app" }, - ], - "/Users/yuhan", - ) - - expect(result.get("/Users/yuhan/dev/one/app")).toBe("~/dev/one/app") - expect(result.get("/Users/yuhan/oss/two/app")).toBe("~/oss/two/app") - }) -}) - -describe("sortPawworkSidebarSessions", () => { - test("sorts sessions globally by creation time before project label", () => { - const result = sortPawworkSidebarSessions([ - { id: "older-a", created: 100, projectLabel: "alpha" }, - { id: "newer-b", created: 300, projectLabel: "beta" }, - { id: "middle-a", created: 200, projectLabel: "alpha" }, - ]) - - expect(result.map((item) => item.id)).toEqual(["newer-b", "middle-a", "older-a"]) - }) - - test("uses project label then id ascending when creation times match", () => { - const result = sortPawworkSidebarSessions([ - { id: "zeta", created: 100, projectLabel: "beta" }, - { id: "zebra", created: 100, projectLabel: "alpha" }, - { id: "alpha", created: 100, projectLabel: "alpha" }, - ]) - - expect(result.map((item) => item.id)).toEqual(["alpha", "zebra", "zeta"]) - }) - - test("sorts by the latest loaded user message time", () => { - const result = sortPawworkSidebarSessions([ - { - id: "older-session-with-new-user-message", - created: pawworkSidebarSessionTime( - { time: { created: 100, updated: 400 } }, - [ - { id: "msg_1", role: "user", time: { created: 300 } }, - { id: "msg_2", role: "assistant", time: { created: 500 } }, - ], - ), - projectLabel: "pawwork", - }, - { - id: "newer-session-with-older-user-message", - created: pawworkSidebarSessionTime( - { time: { created: 200, updated: 600 } }, - [ - { id: "msg_3", role: "user", time: { created: 250 } }, - { id: "msg_4", role: "assistant", time: { created: 700 } }, - ], - ), - projectLabel: "opencli", - }, - ]) - - expect(result.map((item) => item.id)).toEqual([ - "older-session-with-new-user-message", - "newer-session-with-older-user-message", - ]) - }) - - test("falls back to creation time instead of update time when user messages are not loaded", () => { - const result = sortPawworkSidebarSessions([ - { - id: "old-recently-updated", - created: pawworkSidebarSessionTime( - { time: { created: 1777610000000, updated: 1777689073008 } }, - undefined, - ), - projectLabel: "pawwork", - }, - { - id: "newer-session", - created: pawworkSidebarSessionTime( - { time: { created: 1777680000000, updated: 1777681000000 } }, - undefined, - ), - projectLabel: "opencli", - }, - ]) - - expect(result.map((item) => item.id)).toEqual(["newer-session", "old-recently-updated"]) - }) - - test("does not promote sessions from assistant-only message caches", () => { - const result = sortPawworkSidebarSessions([ - { - id: "old-with-new-assistant", - created: pawworkSidebarSessionTime( - { time: { created: 100, updated: 900 } }, - [{ id: "msg_1", role: "assistant", time: { created: 800 } }], - ), - projectLabel: "pawwork", - }, - { - id: "newer-session", - created: pawworkSidebarSessionTime({ time: { created: 200, updated: 300 } }, undefined), - projectLabel: "opencli", - }, - ]) - - expect(result.map((item) => item.id)).toEqual(["newer-session", "old-with-new-assistant"]) - }) -}) - -describe("buildPawworkSidebarSessionRows", () => { - test("uses loaded user message time for sidebar rows", () => { - const result = buildPawworkSidebarSessionRows( - [ - { - id: "session-old", - directory: "/repo", - time: { created: 100, updated: 900 }, - }, - ], - { - slugForDirectory: (directory) => `slug:${directory}`, - projectLabelForSession: () => "PawWork", - messagesForSession: () => [ - { id: "msg_1", role: "assistant", time: { created: 950 } }, - { id: "msg_2", role: "user", time: { created: 800 } }, - ], - }, - ) - - expect(result).toEqual([ - { - session: { - id: "session-old", - directory: "/repo", - time: { created: 100, updated: 900 }, - }, - slug: "slug:/repo", - projectLabel: "PawWork", - created: 800, - }, - ]) - }) - - test("falls back to session creation time when messages are missing", () => { - const result = buildPawworkSidebarSessionRows( - [ - { - id: "session-without-cache", - directory: "/repo", - time: { created: 300, updated: 900 }, - }, - ], - { - slugForDirectory: (directory) => `slug:${directory}`, - projectLabelForSession: () => "PawWork", - }, - ) - - expect(result[0].created).toBe(300) - }) -}) - -describe("pawworkSidebarSessionTime", () => { - test("uses the latest loaded user message time", () => { - expect( - pawworkSidebarSessionTime( - { - time: { - created: 100, - updated: 600, - }, - }, - [ - { id: "msg_1", role: "assistant", time: { created: 700 } }, - { id: "msg_2", role: "user", time: { created: 300 } }, - { id: "msg_3", role: "user", time: { created: 500 } }, - ], - ), - ).toBe(500) - }) - - test("ignores user messages without a valid created time", () => { - expect( - pawworkSidebarSessionTime( - { - time: { - created: 100, - updated: 600, - }, - }, - [ - { id: "msg_1", role: "user", time: { created: 300 } }, - { id: "msg_2", role: "user", time: {} }, - ], - ), - ).toBe(300) - }) - - test("ignores user messages with non-finite created times", () => { - expect( - pawworkSidebarSessionTime( - { - time: { - created: 100, - updated: 600, - }, - }, - [ - { id: "msg_1", role: "user", time: { created: 300 } }, - { id: "msg_2", role: "user", time: { created: Number.NaN } }, - { id: "msg_3", role: "user", time: { created: Number.POSITIVE_INFINITY } }, - ], - ), - ).toBe(300) - }) - - test("uses the session creation time instead of last update time when messages are missing", () => { - expect( - pawworkSidebarSessionTime( - { - time: { - created: 100, - updated: 300, - }, - }, - undefined, - ), - ).toBe(100) - }) - - test("falls back to 0 when creation time is non-finite", () => { - expect(pawworkSidebarSessionTime({ time: { created: Number.NaN, updated: 300 } })).toBe(0) - }) - - test("falls back to 0 when creation time is missing", () => { - expect(pawworkSidebarSessionTime({ time: { updated: 300 } })).toBe(0) - }) -}) diff --git a/packages/app/src/pages/layout/pawwork-session-source.ts b/packages/app/src/pages/layout/pawwork-session-source.ts index df863e883..22662da4c 100644 --- a/packages/app/src/pages/layout/pawwork-session-source.ts +++ b/packages/app/src/pages/layout/pawwork-session-source.ts @@ -15,6 +15,7 @@ type SessionLike = { } type SessionTimeLike = { + activityAt?: number time?: { created?: number updated?: number @@ -29,6 +30,11 @@ type MessageTimeLike = { } } +type PartTimeLike = { + type?: string + synthetic?: boolean +} + type SidebarRowSessionLike = SessionTimeLike & { id: string directory: string @@ -68,13 +74,49 @@ export function sortPawworkSidebarSessions(sessions: T[]) }) } -export function pawworkSidebarSessionTime(session: SessionTimeLike, messages?: MessageTimeLike[]) { +const isActivityEligibleUserMessage = (parts: PartTimeLike[] | undefined) => { + if (!parts) return false + if (parts.some((part) => part.type === "compaction")) return false + const hasSynthetic = parts.some((part) => part.synthetic === true) + if (!hasSynthetic) return true + return parts.some((part) => part.synthetic !== true) +} + +const latestLoadedUserMessageTime = ( + messages: MessageTimeLike[] | undefined, + partsForMessage: ((messageID: string) => PartTimeLike[] | undefined) | undefined, + requireEligibility: boolean, +) => { + let latestLoadedUserAt: number | undefined for (let i = (messages?.length ?? 0) - 1; i >= 0; i--) { const message = messages?.[i] if (message?.role !== "user") continue + const parts = message.id ? partsForMessage?.(message.id) : undefined + if (requireEligibility || parts) { + if (!parts || !isActivityEligibleUserMessage(parts)) continue + } const created = message.time?.created - if (isFiniteNumber(created)) return created + if (isFiniteNumber(created)) { + latestLoadedUserAt = created + break + } + } + return latestLoadedUserAt +} + +export function pawworkSidebarSessionTime( + session: SessionTimeLike, + messages?: MessageTimeLike[], + partsForMessage?: (messageID: string) => PartTimeLike[] | undefined, +) { + if (isFiniteNumber(session.activityAt)) { + const latestEligibleLoadedUserAt = latestLoadedUserMessageTime(messages, partsForMessage, true) + return latestEligibleLoadedUserAt === undefined + ? session.activityAt + : Math.max(session.activityAt, latestEligibleLoadedUserAt) } + const latestLoadedUserAt = latestLoadedUserMessageTime(messages, partsForMessage, false) + if (latestLoadedUserAt !== undefined) return latestLoadedUserAt const sessionCreated = session.time?.created return isFiniteNumber(sessionCreated) ? sessionCreated : 0 } @@ -85,13 +127,18 @@ export function buildPawworkSidebarSessionRows( slugForDirectory: (directory: string) => string projectLabelForSession: (session: T) => string messagesForSession?: (session: T) => MessageTimeLike[] | undefined + partsForMessage?: (session: T, messageID: string) => PartTimeLike[] | undefined }, ) { return sessions.map((session) => ({ session, slug: input.slugForDirectory(session.directory), projectLabel: input.projectLabelForSession(session), - created: pawworkSidebarSessionTime(session, input.messagesForSession?.(session)), + created: pawworkSidebarSessionTime( + session, + input.messagesForSession?.(session), + input.partsForMessage ? (messageID) => input.partsForMessage?.(session, messageID) : undefined, + ), })) } diff --git a/packages/app/src/pages/layout/pawwork-session-window.test.ts b/packages/app/src/pages/layout/pawwork-session-window.test.ts index 0dee5df53..c933d57a0 100644 --- a/packages/app/src/pages/layout/pawwork-session-window.test.ts +++ b/packages/app/src/pages/layout/pawwork-session-window.test.ts @@ -1,21 +1,22 @@ import { describe, expect, test } from "bun:test" -import type { Session } from "@opencode-ai/sdk/v2/client" import { PAWWORK_SESSION_WINDOW_INITIAL, PAWWORK_SESSION_WINDOW_MAX, PAWWORK_SESSION_WINDOW_STEP, + type PawworkWindowSession, buildPawworkSessionWindow, nextPawworkSessionWindowLimit, sortPawworkSessionWindowSessions, } from "./pawwork-session-window" -const session = (id: string, created: number, directory = "/repo") => +const session = (id: string, created: number, directory = "/repo", activityAt?: number) => ({ id, directory, title: id, time: { created, updated: created }, - }) as Session + activityAt, + }) as PawworkWindowSession describe("nextPawworkSessionWindowLimit", () => { test("moves 30 to 60 to 90 and caps there", () => { @@ -29,16 +30,16 @@ describe("nextPawworkSessionWindowLimit", () => { }) describe("buildPawworkSessionWindow", () => { - test("sorts the normal window by creation time before applying the limit", () => { + test("sorts the normal window by activity time before applying the limit", () => { const result = buildPawworkSessionWindow({ - normal: [session("old", 1), session("new", 3), session("middle", 2)], + normal: [session("old-active", 1, "/repo", 5), session("new-inactive", 3, "/repo", 3), session("middle", 2, "/repo", 4)], pinned: [], active: undefined, limit: 30, hasMore: false, }) - expect(result.normalIDs).toEqual(["new", "middle", "old"]) + expect(result.normalIDs).toEqual(["old-active", "middle", "new-inactive"]) }) test("keeps the normal window capped while preserving pinned and active sessions", () => { @@ -101,6 +102,15 @@ describe("buildPawworkSessionWindow", () => { }) describe("sortPawworkSessionWindowSessions", () => { + test("uses activity time before creation time", () => { + expect( + sortPawworkSessionWindowSessions([ + session("newer-created", 3, "/repo", 3), + session("older-with-user-activity", 1, "/repo", 5), + ]).map((item) => item.id), + ).toEqual(["older-with-user-activity", "newer-created"]) + }) + test("uses id as the creation-time tiebreaker", () => { expect(sortPawworkSessionWindowSessions([session("z", 1), session("a", 1)]).map((item) => item.id)).toEqual([ "a", diff --git a/packages/app/src/pages/layout/pawwork-session-window.ts b/packages/app/src/pages/layout/pawwork-session-window.ts index 75c038082..1049e756c 100644 --- a/packages/app/src/pages/layout/pawwork-session-window.ts +++ b/packages/app/src/pages/layout/pawwork-session-window.ts @@ -1,13 +1,16 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" +import type { GlobalSession, Session } from "@opencode-ai/sdk/v2/client" + +export type PawworkWindowSession = Session & Pick export const PAWWORK_SESSION_WINDOW_INITIAL = 30 export const PAWWORK_SESSION_WINDOW_STEP = 30 export const PAWWORK_SESSION_WINDOW_MAX = 90 -const byID = (a: Session, b: Session) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) -const byCreatedDesc = (a: Session, b: Session) => { - const created = b.time.created - a.time.created - if (created !== 0) return created +const byID = (a: PawworkWindowSession, b: PawworkWindowSession) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) +const sessionActivityTime = (session: PawworkWindowSession) => session.activityAt ?? session.time.created +const byActivityDesc = (a: PawworkWindowSession, b: PawworkWindowSession) => { + const activity = sessionActivityTime(b) - sessionActivityTime(a) + if (activity !== 0) return activity return byID(a, b) } @@ -18,8 +21,8 @@ export function nextPawworkSessionWindowLimit(current: number) { ) } -export function mergeSessionsByID(...lists: Array) { - const map = new Map() +export function mergeSessionsByID(...lists: Array) { + const map = new Map() for (const list of lists) { for (const item of list ?? []) { if (!item?.id || item.time?.archived) continue @@ -29,14 +32,14 @@ export function mergeSessionsByID(...lists: Array) { return [...map.values()].sort(byID) } -export function sortPawworkSessionWindowSessions(sessions: Session[]) { - return sessions.filter((item) => !!item?.id && !item.time?.archived).slice().sort(byCreatedDesc) +export function sortPawworkSessionWindowSessions(sessions: PawworkWindowSession[]) { + return sessions.filter((item) => !!item?.id && !item.time?.archived).slice().sort(byActivityDesc) } export function buildPawworkSessionWindow(input: { - normal: Session[] - pinned: Session[] - active?: Session + normal: PawworkWindowSession[] + pinned: PawworkWindowSession[] + active?: PawworkWindowSession limit: number hasMore: boolean }) { diff --git a/packages/app/src/pages/layout/pawwork-sidebar-session-rows.test.ts b/packages/app/src/pages/layout/pawwork-sidebar-session-rows.test.ts new file mode 100644 index 000000000..cbe8ff037 --- /dev/null +++ b/packages/app/src/pages/layout/pawwork-sidebar-session-rows.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test" +import { buildPawworkSidebarSessionRows } from "./pawwork-session-source" + +describe("buildPawworkSidebarSessionRows", () => { + test("uses API activity time before loaded message cache for sidebar rows", () => { + const result = buildPawworkSidebarSessionRows( + [ + { + id: "session-old", + directory: "/repo", + activityAt: 900, + time: { created: 100, updated: 950 }, + }, + ], + { + slugForDirectory: (directory) => `slug:${directory}`, + projectLabelForSession: () => "PawWork", + messagesForSession: () => [{ id: "msg_1", role: "user", time: { created: 800 } }], + }, + ) + + expect(result[0].created).toBe(900) + }) + + test("does not let unqualified loaded user message cache override API activity time", () => { + const result = buildPawworkSidebarSessionRows( + [ + { + id: "session-old", + directory: "/repo", + activityAt: 700, + time: { created: 100, updated: 950 }, + }, + ], + { + slugForDirectory: (directory) => `slug:${directory}`, + projectLabelForSession: () => "PawWork", + messagesForSession: () => [{ id: "msg_1", role: "user", time: { created: 900 } }], + }, + ) + + expect(result[0].created).toBe(700) + }) + + test("uses fresher loaded real user message parts over stale API activity time", () => { + const result = buildPawworkSidebarSessionRows( + [ + { + id: "session-old", + directory: "/repo", + activityAt: 700, + time: { created: 100, updated: 950 }, + }, + ], + { + slugForDirectory: (directory) => `slug:${directory}`, + projectLabelForSession: () => "PawWork", + messagesForSession: () => [{ id: "msg_1", role: "user", time: { created: 900 } }], + partsForMessage: (_session, messageID) => + messageID === "msg_1" ? [{ type: "text", synthetic: false }] : undefined, + }, + ) + + expect(result[0].created).toBe(900) + }) + + test("does not let loaded synthetic-only user message parts override API activity time", () => { + const result = buildPawworkSidebarSessionRows( + [ + { + id: "session-old", + directory: "/repo", + activityAt: 700, + time: { created: 100, updated: 950 }, + }, + ], + { + slugForDirectory: (directory) => `slug:${directory}`, + projectLabelForSession: () => "PawWork", + messagesForSession: () => [{ id: "msg_1", role: "user", time: { created: 900 } }], + partsForMessage: (_session, messageID) => + messageID === "msg_1" ? [{ type: "text", synthetic: true }] : undefined, + }, + ) + + expect(result[0].created).toBe(700) + }) + + test("uses loaded user message time for sidebar rows", () => { + const result = buildPawworkSidebarSessionRows( + [ + { + id: "session-old", + directory: "/repo", + time: { created: 100, updated: 900 }, + }, + ], + { + slugForDirectory: (directory) => `slug:${directory}`, + projectLabelForSession: () => "PawWork", + messagesForSession: () => [ + { id: "msg_1", role: "assistant", time: { created: 950 } }, + { id: "msg_2", role: "user", time: { created: 800 } }, + ], + }, + ) + + expect(result).toEqual([ + { + session: { + id: "session-old", + directory: "/repo", + time: { created: 100, updated: 900 }, + }, + slug: "slug:/repo", + projectLabel: "PawWork", + created: 800, + }, + ]) + }) + + test("falls back to session creation time when messages are missing", () => { + const result = buildPawworkSidebarSessionRows( + [ + { + id: "session-without-cache", + directory: "/repo", + time: { created: 300, updated: 900 }, + }, + ], + { + slugForDirectory: (directory) => `slug:${directory}`, + projectLabelForSession: () => "PawWork", + }, + ) + + expect(result[0].created).toBe(300) + }) +}) diff --git a/packages/app/src/pages/layout/pawwork-sidebar-session-sort.test.ts b/packages/app/src/pages/layout/pawwork-sidebar-session-sort.test.ts new file mode 100644 index 000000000..45385f515 --- /dev/null +++ b/packages/app/src/pages/layout/pawwork-sidebar-session-sort.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { pawworkSidebarSessionTime, sortPawworkSidebarSessions } from "./pawwork-session-source" + +describe("sortPawworkSidebarSessions", () => { + test("sorts sessions globally by creation time before project label", () => { + const result = sortPawworkSidebarSessions([ + { id: "older-a", created: 100, projectLabel: "alpha" }, + { id: "newer-b", created: 300, projectLabel: "beta" }, + { id: "middle-a", created: 200, projectLabel: "alpha" }, + ]) + + expect(result.map((item) => item.id)).toEqual(["newer-b", "middle-a", "older-a"]) + }) + + test("uses project label then id ascending when creation times match", () => { + const result = sortPawworkSidebarSessions([ + { id: "zeta", created: 100, projectLabel: "beta" }, + { id: "zebra", created: 100, projectLabel: "alpha" }, + { id: "alpha", created: 100, projectLabel: "alpha" }, + ]) + + expect(result.map((item) => item.id)).toEqual(["alpha", "zebra", "zeta"]) + }) + + test("sorts by the latest loaded user message time", () => { + const result = sortPawworkSidebarSessions([ + { + id: "older-session-with-new-user-message", + created: pawworkSidebarSessionTime( + { time: { created: 100, updated: 400 } }, + [ + { id: "msg_1", role: "user", time: { created: 300 } }, + { id: "msg_2", role: "assistant", time: { created: 500 } }, + ], + ), + projectLabel: "pawwork", + }, + { + id: "newer-session-with-older-user-message", + created: pawworkSidebarSessionTime( + { time: { created: 200, updated: 600 } }, + [ + { id: "msg_3", role: "user", time: { created: 250 } }, + { id: "msg_4", role: "assistant", time: { created: 700 } }, + ], + ), + projectLabel: "opencli", + }, + ]) + + expect(result.map((item) => item.id)).toEqual([ + "older-session-with-new-user-message", + "newer-session-with-older-user-message", + ]) + }) + + test("falls back to creation time instead of update time when user messages are not loaded", () => { + const result = sortPawworkSidebarSessions([ + { + id: "old-recently-updated", + created: pawworkSidebarSessionTime( + { time: { created: 1777610000000, updated: 1777689073008 } }, + undefined, + ), + projectLabel: "pawwork", + }, + { + id: "newer-session", + created: pawworkSidebarSessionTime( + { time: { created: 1777680000000, updated: 1777681000000 } }, + undefined, + ), + projectLabel: "opencli", + }, + ]) + + expect(result.map((item) => item.id)).toEqual(["newer-session", "old-recently-updated"]) + }) + + test("does not promote sessions from assistant-only message caches", () => { + const result = sortPawworkSidebarSessions([ + { + id: "old-with-new-assistant", + created: pawworkSidebarSessionTime( + { time: { created: 100, updated: 900 } }, + [{ id: "msg_1", role: "assistant", time: { created: 800 } }], + ), + projectLabel: "pawwork", + }, + { + id: "newer-session", + created: pawworkSidebarSessionTime({ time: { created: 200, updated: 300 } }, undefined), + projectLabel: "opencli", + }, + ]) + + expect(result.map((item) => item.id)).toEqual(["newer-session", "old-with-new-assistant"]) + }) +}) diff --git a/packages/app/src/pages/layout/pawwork-sidebar-session-time.test.ts b/packages/app/src/pages/layout/pawwork-sidebar-session-time.test.ts new file mode 100644 index 000000000..1aff6aee7 --- /dev/null +++ b/packages/app/src/pages/layout/pawwork-sidebar-session-time.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test" +import { pawworkSidebarSessionTime } from "./pawwork-session-source" + +describe("pawworkSidebarSessionTime", () => { + test("uses the latest loaded user message time", () => { + expect( + pawworkSidebarSessionTime( + { + time: { + created: 100, + updated: 600, + }, + }, + [ + { id: "msg_1", role: "assistant", time: { created: 700 } }, + { id: "msg_2", role: "user", time: { created: 300 } }, + { id: "msg_3", role: "user", time: { created: 500 } }, + ], + ), + ).toBe(500) + }) + + test("uses API activity time before loaded user messages when activity is available", () => { + expect( + pawworkSidebarSessionTime( + { + activityAt: 400, + time: { + created: 100, + updated: 600, + }, + }, + [{ id: "msg_1", role: "user", time: { created: 500 } }], + ), + ).toBe(400) + expect( + pawworkSidebarSessionTime( + { + activityAt: 700, + time: { + created: 100, + updated: 800, + }, + }, + [{ id: "msg_2", role: "user", time: { created: 500 } }], + ), + ).toBe(700) + }) + + test("uses newer eligible loaded user time when activity is stale", () => { + expect( + pawworkSidebarSessionTime( + { + activityAt: 400, + time: { + created: 100, + updated: 600, + }, + }, + [{ id: "msg_1", role: "user", time: { created: 500 } }], + (messageID) => (messageID === "msg_1" ? [{ type: "text" }] : undefined), + ), + ).toBe(500) + }) + + test("does not use synthetic-only or compaction loaded user messages when activity is available", () => { + expect( + pawworkSidebarSessionTime( + { + activityAt: 400, + time: { + created: 100, + updated: 600, + }, + }, + [ + { id: "msg_1", role: "user", time: { created: 600 } }, + { id: "msg_2", role: "user", time: { created: 500 } }, + ], + (messageID) => + messageID === "msg_1" + ? [{ type: "text", synthetic: true }] + : messageID === "msg_2" + ? [{ type: "compaction" }] + : undefined, + ), + ).toBe(400) + }) + + test("uses real user messages that also include synthetic reminder parts", () => { + expect( + pawworkSidebarSessionTime( + { + activityAt: 400, + time: { + created: 100, + updated: 600, + }, + }, + [{ id: "msg_1", role: "user", time: { created: 500 } }], + (messageID) => + messageID === "msg_1" + ? [ + { type: "text" }, + { type: "text", synthetic: true }, + ] + : undefined, + ), + ).toBe(500) + }) + + test("does not use known synthetic-only or compaction loaded user messages without activity metadata", () => { + expect( + pawworkSidebarSessionTime( + { + time: { + created: 100, + updated: 600, + }, + }, + [ + { id: "msg_1", role: "user", time: { created: 600 } }, + { id: "msg_2", role: "user", time: { created: 500 } }, + ], + (messageID) => + messageID === "msg_1" + ? [{ type: "text", synthetic: true }] + : messageID === "msg_2" + ? [{ type: "compaction" }] + : undefined, + ), + ).toBe(100) + }) + + test("keeps the loose fallback when message parts are not loaded", () => { + expect( + pawworkSidebarSessionTime( + { + time: { + created: 100, + updated: 600, + }, + }, + [{ id: "msg_1", role: "user", time: { created: 500 } }], + () => undefined, + ), + ).toBe(500) + }) + + test("ignores user messages without a valid created time", () => { + expect( + pawworkSidebarSessionTime( + { + time: { + created: 100, + updated: 600, + }, + }, + [ + { id: "msg_1", role: "user", time: { created: 300 } }, + { id: "msg_2", role: "user", time: {} }, + ], + ), + ).toBe(300) + }) + + test("ignores user messages with non-finite created times", () => { + expect( + pawworkSidebarSessionTime( + { + time: { + created: 100, + updated: 600, + }, + }, + [ + { id: "msg_1", role: "user", time: { created: 300 } }, + { id: "msg_2", role: "user", time: { created: Number.NaN } }, + { id: "msg_3", role: "user", time: { created: Number.POSITIVE_INFINITY } }, + ], + ), + ).toBe(300) + }) + + test("uses the session creation time instead of last update time when messages are missing", () => { + expect( + pawworkSidebarSessionTime( + { + time: { + created: 100, + updated: 300, + }, + }, + undefined, + ), + ).toBe(100) + }) + + test("falls back to 0 when creation time is non-finite", () => { + expect(pawworkSidebarSessionTime({ time: { created: Number.NaN, updated: 300 } })).toBe(0) + }) + + test("falls back to 0 when creation time is missing", () => { + expect(pawworkSidebarSessionTime({ time: { updated: 300 } })).toBe(0) + }) +}) diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index eb1d87075..3825988e1 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -43,6 +43,7 @@ function encodeCreatedSessionCursor(session: Session.GlobalInfo) { } const CreatedSessionCursor = z.object({ created: z.number(), id: SessionID.zod }) +const ActivitySessionCursor = z.object({ activityAt: z.number(), id: SessionID.zod }) function decodeCreatedSessionCursor(value: string | number | undefined) { if (value === undefined) return undefined @@ -56,6 +57,23 @@ function decodeCreatedSessionCursor(value: string | number | undefined) { } } +function encodeActivitySessionCursor(session: Session.GlobalInfo) { + if (session.activityAt === undefined) return undefined + return Buffer.from(JSON.stringify({ activityAt: session.activityAt, id: session.id }), "utf8").toString("base64url") +} + +function decodeActivitySessionCursor(value: string | number | undefined) { + if (value === undefined) return undefined + if (typeof value === "number") return undefined + try { + const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) + const parsed = ActivitySessionCursor.safeParse(decoded) + return parsed.success ? parsed.data : undefined + } catch { + return undefined + } +} + function decodeUpdatedSessionCursor(value: string | number | undefined) { if (value === undefined) return undefined const cursor = typeof value === "number" ? value : Number(value) @@ -352,7 +370,7 @@ export const ExperimentalRoutes = lazy(() => describeRoute({ summary: "List sessions", description: - "Get a list of all OpenCode sessions across projects. Defaults to most recently updated; use sort=created for creation-time order. Archived sessions are excluded by default.", + "Get a list of all OpenCode sessions across projects. Defaults to most recently updated; use sort=created for creation-time order or sort=activity for latest user-message activity order. Archived sessions are excluded by default.", operationId: "experimental.session.list", responses: { 200: { @@ -385,9 +403,9 @@ export const ExperimentalRoutes = lazy(() => limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }), sort: z - .enum(["updated", "created"]) + .enum(["updated", "created", "activity"]) .optional() - .meta({ description: "Sort sessions by last update or creation time" }), + .meta({ description: "Sort sessions by last update, creation time, or latest user-message activity" }), }), ), async (c) => { @@ -401,6 +419,8 @@ export const ExperimentalRoutes = lazy(() => cursor: query.sort === "created" ? decodeCreatedSessionCursor(query.cursor) + : query.sort === "activity" + ? decodeActivitySessionCursor(query.cursor) : decodeUpdatedSessionCursor(query.cursor), search: query.search, limit: limit + 1, @@ -417,6 +437,8 @@ export const ExperimentalRoutes = lazy(() => "x-next-cursor", query.sort === "created" ? encodeCreatedSessionCursor(list[list.length - 1]) + : query.sort === "activity" + ? encodeActivitySessionCursor(list[list.length - 1]) : String(list[list.length - 1].time.updated), ) } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 728813de3..e3b6e91af 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -8,10 +8,26 @@ import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { Flag } from "@opencode-ai/core/flag/flag" import { Installation } from "../installation" -import { Database, NotFoundError, eq, and, or, gte, isNull, desc, asc, like, inArray, lt, gt, sql } from "../storage/db" +import { + Database, + NotFoundError, + eq, + and, + or, + gte, + isNull, + desc, + asc, + like, + inArray, + lt, + gt, + sql, + getTableColumns, +} from "../storage/db" import { SyncEvent } from "../sync" import type { SQL } from "../storage/db" -import { PartTable, SessionTable } from "./session.sql" +import { MessageTable, PartTable, SessionTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import { Log } from "@opencode-ai/core/util/log" @@ -51,6 +67,10 @@ export function isDefaultTitle(title: string) { } type SessionRow = typeof SessionTable.$inferSelect +type GlobalListRow = SessionRow & { + activityAt?: number + lastUserMessageAt?: number | null +} type ProjectFallback = { worktree?: string | null; vcs?: string | null } function legacyExecutionContext(row: SessionRow, project: ProjectFallback | undefined) { @@ -278,6 +298,8 @@ export type ProjectInfo = z.output export const GlobalInfo = Info.extend({ project: ProjectInfo.nullable(), + activityAt: z.number().optional(), + lastUserMessageAt: z.number().optional(), }).meta({ ref: "GlobalSession", }) @@ -1012,18 +1034,63 @@ export const findActiveWorktreeBinding = fn(FindActiveWorktreeBindingInput, (dir runPromise((svc) => svc.findActiveWorktreeBinding(directory)), ) -type ListSort = "updated" | "created" +type SessionListSort = "updated" | "created" +type GlobalListSort = SessionListSort | "activity" type GlobalListCursor = | number | { created: number id: SessionID } + | { + activityAt: number + id: SessionID + } -function sessionOrder(sort: ListSort) { - return sort === "created" - ? [desc(SessionTable.time_created), asc(SessionTable.id)] - : [desc(SessionTable.time_updated), desc(SessionTable.id)] +function sessionOrder(sort: SessionListSort) { + if (sort === "created") return [desc(SessionTable.time_created), asc(SessionTable.id)] + return [desc(SessionTable.time_updated), desc(SessionTable.id)] +} + +const lastUserMessageAtExpr = sql`( + select max(${MessageTable.time_created}) + from ${MessageTable} + where ${MessageTable.session_id} = ${SessionTable.id} + and json_extract(${MessageTable.data}, '$.role') = 'user' + and not exists ( + select 1 + from ${PartTable} + where ${PartTable.message_id} = ${MessageTable.id} + and ${PartTable.session_id} = ${SessionTable.id} + and json_extract(${PartTable.data}, '$.type') = 'compaction' + ) + and not ( + exists ( + select 1 + from ${PartTable} + where ${PartTable.message_id} = ${MessageTable.id} + and ${PartTable.session_id} = ${SessionTable.id} + and json_extract(${PartTable.data}, '$.synthetic') = 1 + ) + and not exists ( + select 1 + from ${PartTable} + where ${PartTable.message_id} = ${MessageTable.id} + and ${PartTable.session_id} = ${SessionTable.id} + and ( + json_extract(${PartTable.data}, '$.synthetic') is null + or json_extract(${PartTable.data}, '$.synthetic') != 1 + ) + ) + ) +)` + +const activityAtExpr = sql`coalesce(${lastUserMessageAtExpr}, ${SessionTable.time_created})` + +const activitySelect = { + ...getTableColumns(SessionTable), + activityAt: activityAtExpr, + lastUserMessageAt: lastUserMessageAtExpr, } export function* list(input?: { @@ -1033,7 +1100,7 @@ export function* list(input?: { start?: number search?: string limit?: number - sort?: ListSort + sort?: SessionListSort }) { const project = Instance.project const conditions = [eq(SessionTable.project_id, project.id)] @@ -1094,7 +1161,7 @@ export function* listGlobal(input?: { search?: string limit?: number archived?: boolean - sort?: ListSort + sort?: GlobalListSort }) { const conditions: SQL[] = [] const sort = input?.sort ?? "updated" @@ -1110,7 +1177,7 @@ export function* listGlobal(input?: { } if (input?.cursor !== undefined) { if (sort === "created") { - if (typeof input.cursor !== "number") { + if (typeof input.cursor !== "number" && "created" in input.cursor) { conditions.push( or( lt(SessionTable.time_created, input.cursor.created), @@ -1120,9 +1187,19 @@ export function* listGlobal(input?: { } else { // Numeric cursors are invalid for created-order pagination and are ignored. } + } else if (sort === "activity") { + if (typeof input.cursor !== "number" && "activityAt" in input.cursor) { + conditions.push( + or( + lt(activityAtExpr, input.cursor.activityAt), + and(eq(activityAtExpr, input.cursor.activityAt), gt(SessionTable.id, input.cursor.id)), + )!, + ) + } } else { - const cursor = typeof input.cursor === "number" ? input.cursor : input.cursor.created - conditions.push(lt(SessionTable.time_updated, cursor)) + if (typeof input.cursor === "number" && Number.isFinite(input.cursor)) { + conditions.push(lt(SessionTable.time_updated, input.cursor)) + } } } if (input?.search) { @@ -1138,15 +1215,18 @@ export function* listGlobal(input?: { const query = conditions.length > 0 ? db - .select() + .select(sort === "activity" ? activitySelect : getTableColumns(SessionTable)) .from(SessionTable) .where(and(...conditions)) - : db.select().from(SessionTable) - const order = sessionOrder(sort) + : db + .select(sort === "activity" ? activitySelect : getTableColumns(SessionTable)) + .from(SessionTable) + const order = + sort === "activity" ? [desc(activityAtExpr), asc(SessionTable.id)] : sessionOrder(sort) return query .orderBy(...order) .limit(limit) - .all() + .all() as GlobalListRow[] }) const ids = [...new Set(rows.map((row) => row.project_id))] @@ -1178,6 +1258,13 @@ export function* listGlobal(input?: { for (const row of rows) { const project = projects.get(row.project_id) ?? null - yield { ...fromRow(row, projectFallbacks.get(row.project_id)), project } + const activity = sort === "activity" ? { activityAt: row.activityAt } : {} + const lastUserMessageAt = + sort === "activity" + ? (row.lastUserMessageAt ?? (row.activityAt !== row.time_created ? row.activityAt : undefined)) + : undefined + const lastUserMessage = + lastUserMessageAt !== null && lastUserMessageAt !== undefined ? { lastUserMessageAt } : {} + yield { ...fromRow(row, projectFallbacks.get(row.project_id)), project, ...activity, ...lastUserMessage } } } diff --git a/packages/opencode/test/server/global-session-activity-list.test.ts b/packages/opencode/test/server/global-session-activity-list.test.ts new file mode 100644 index 000000000..cf33ccb66 --- /dev/null +++ b/packages/opencode/test/server/global-session-activity-list.test.ts @@ -0,0 +1,394 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Instance } from "../../src/project/instance" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Server } from "../../src/server/server" +import { Session as SessionNs } from "../../src/session" +import type { MessageV2 } from "../../src/session/message-v2" +import { MessageID, PartID, type SessionID } from "../../src/session/schema" +import { Log } from "../../src/util" +import { tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +function run(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) +} + +const svc = { + ...SessionNs, + create(input?: Parameters[0]) { + return run(SessionNs.Service.use((svc) => svc.create(input))) + }, + touch(sessionID: SessionID) { + return run(SessionNs.Service.use((svc) => svc.touch(sessionID))) + }, + updateMessage(input: MessageV2.Info) { + return run(SessionNs.Service.use((svc) => svc.updateMessage(input))) + }, + updatePart(input: MessageV2.Part) { + return run(SessionNs.Service.use((svc) => svc.updatePart(input))) + }, +} + +describe("session.listGlobal activity order", () => { + const userMessage = (sessionID: SessionID, created: number) => + svc.updateMessage({ + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created }, + agent: "build", + model: { + providerID: ProviderID.openai, + modelID: "test-model" as ModelID, + }, + }) + + const assistantMessage = (sessionID: SessionID, created: number, parentID: MessageID = MessageID.ascending()) => + svc.updateMessage({ + id: MessageID.ascending(), + sessionID, + role: "assistant", + time: { created }, + parentID, + modelID: "test-model" as ModelID, + providerID: ProviderID.openai, + mode: "build", + agent: "build", + path: { + cwd: "/tmp", + root: "/tmp", + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { + read: 0, + write: 0, + }, + }, + }) + + const compactionMessage = async (sessionID: SessionID, created: number) => { + const message = await userMessage(sessionID, created) + await svc.updatePart({ + id: PartID.ascending(), + messageID: message.id, + sessionID, + type: "compaction", + auto: true, + }) + return message + } + + const syntheticContinueMessage = async (sessionID: SessionID, created: number) => { + const message = await userMessage(sessionID, created) + await svc.updatePart({ + id: PartID.ascending(), + messageID: message.id, + sessionID, + type: "text", + text: "Continue if you have next steps.", + synthetic: true, + metadata: { compaction_continue: true }, + time: { start: created, end: created }, + }) + return message + } + + const userMessageWithSyntheticReminder = async (sessionID: SessionID, created: number) => { + const message = await userMessage(sessionID, created) + await svc.updatePart({ + id: PartID.ascending(), + messageID: message.id, + sessionID, + type: "text", + text: "Real user prompt", + time: { start: created, end: created }, + }) + await svc.updatePart({ + id: PartID.ascending(), + messageID: message.id, + sessionID, + type: "text", + text: "Plan mode is active.", + synthetic: true, + time: { start: created, end: created }, + }) + return message + } + + test("orders global sessions by latest user message activity when requested", async () => { + await using tmp = await tmpdir({ git: true }) + const originalNow = Date.now + try { + let now = 1_000 + Date.now = () => now + const oldActive = await Instance.provide({ + directory: tmp.path, + fn: async () => svc.create({ title: "old-active" }), + }) + now = 2_000 + const newerFallback = await Instance.provide({ + directory: tmp.path, + fn: async () => svc.create({ title: "newer-fallback" }), + }) + now = 3_000 + await Instance.provide({ + directory: tmp.path, + fn: async () => userMessage(oldActive.id, 4_000), + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => svc.touch(newerFallback.id), + }) + + const sessions = [ + ...svc.listGlobal({ + directory: tmp.path, + limit: 2, + sort: "activity" as never, + }), + ] + + expect(sessions.map((session) => session.id)).toEqual([oldActive.id, newerFallback.id]) + expect(sessions[0]).toMatchObject({ + activityAt: 4_000, + lastUserMessageAt: 4_000, + }) + expect(sessions[1]).toMatchObject({ + activityAt: 2_000, + }) + expect(Object.hasOwn(sessions[1] as Record, "lastUserMessageAt")).toBe(false) + } finally { + Date.now = originalNow + } + }) + + test("does not promote activity order from assistant messages or session updates", async () => { + await using tmp = await tmpdir({ git: true }) + const originalNow = Date.now + try { + let now = 1_000 + Date.now = () => now + const oldAssistantOnly = await Instance.provide({ + directory: tmp.path, + fn: async () => svc.create({ title: "old-assistant-only" }), + }) + now = 2_000 + const newerFallback = await Instance.provide({ + directory: tmp.path, + fn: async () => svc.create({ title: "newer-fallback" }), + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await assistantMessage(oldAssistantOnly.id, 5_000) + await svc.touch(oldAssistantOnly.id) + }, + }) + + const sessions = [ + ...svc.listGlobal({ + directory: tmp.path, + limit: 2, + sort: "activity" as never, + }), + ] + + expect(sessions.map((session) => session.id)).toEqual([newerFallback.id, oldAssistantOnly.id]) + expect(sessions.map((session) => (session as typeof session & { activityAt?: number }).activityAt)).toEqual([ + 2_000, + 1_000, + ]) + } finally { + Date.now = originalNow + } + }) + + test("does not promote activity order from compaction or synthetic user messages", async () => { + await using tmp = await tmpdir({ git: true }) + const originalNow = Date.now + try { + let now = 1_000 + Date.now = () => now + const compactionOnly = await Instance.provide({ + directory: tmp.path, + fn: async () => svc.create({ title: "compaction-only" }), + }) + now = 2_000 + const syntheticOnly = await Instance.provide({ + directory: tmp.path, + fn: async () => svc.create({ title: "synthetic-only" }), + }) + now = 3_000 + const realUser = await Instance.provide({ + directory: tmp.path, + fn: async () => svc.create({ title: "real-user" }), + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await compactionMessage(compactionOnly.id, 6_000) + await syntheticContinueMessage(syntheticOnly.id, 5_000) + await userMessage(realUser.id, 4_000) + }, + }) + + const sessions = [ + ...svc.listGlobal({ + directory: tmp.path, + limit: 3, + sort: "activity" as never, + }), + ] + + expect(sessions.map((session) => session.id)).toEqual([realUser.id, syntheticOnly.id, compactionOnly.id]) + expect(sessions.map((session) => (session as typeof session & { activityAt?: number }).activityAt)).toEqual([ + 4_000, + 2_000, + 1_000, + ]) + expect(sessions.map((session) => Object.hasOwn(session as Record, "lastUserMessageAt"))).toEqual([ + true, + false, + false, + ]) + } finally { + Date.now = originalNow + } + }) + + test("keeps real user activity when the message also has synthetic reminder parts", async () => { + await using tmp = await tmpdir({ git: true }) + const originalNow = Date.now + try { + let now = 1_000 + Date.now = () => now + const oldMixedUser = await Instance.provide({ + directory: tmp.path, + fn: async () => svc.create({ title: "old-mixed-user" }), + }) + now = 2_000 + const newerFallback = await Instance.provide({ + directory: tmp.path, + fn: async () => svc.create({ title: "newer-fallback" }), + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => userMessageWithSyntheticReminder(oldMixedUser.id, 4_000), + }) + + const sessions = [ + ...svc.listGlobal({ + directory: tmp.path, + limit: 2, + sort: "activity" as never, + }), + ] + + expect(sessions.map((session) => session.id)).toEqual([oldMixedUser.id, newerFallback.id]) + expect(sessions[0]).toMatchObject({ + activityAt: 4_000, + lastUserMessageAt: 4_000, + }) + expect(sessions[1]).toMatchObject({ + activityAt: 2_000, + }) + } finally { + Date.now = originalNow + } + }) + + test("paginates activity-order sessions that share the same activity time", async () => { + await using tmp = await tmpdir({ git: true }) + const originalNow = Date.now + try { + Date.now = () => 1_000 + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await svc.create({ title: "same-activity-a" }) + await svc.create({ title: "same-activity-b" }) + }, + }) + + const page = [ + ...svc.listGlobal({ + directory: tmp.path, + limit: 1, + sort: "activity" as never, + }), + ] + expect(page).toHaveLength(1) + + const next = [ + ...svc.listGlobal({ + directory: tmp.path, + limit: 10, + sort: "activity" as never, + cursor: { + activityAt: (page[0] as typeof page[0] & { activityAt: number }).activityAt, + id: page[0].id, + } as never, + }), + ] + + expect(next).toHaveLength(1) + expect((next[0] as typeof next[0] & { activityAt: number }).activityAt).toBe( + (page[0] as typeof page[0] & { activityAt: number }).activityAt, + ) + expect(page[0].id.localeCompare(next[0].id)).toBeLessThan(0) + } finally { + Date.now = originalNow + } + }) + + test("experimental route round-trips activity-order cursor", async () => { + await using tmp = await tmpdir({ git: true }) + const originalNow = Date.now + try { + Date.now = () => 1_000 + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await svc.create({ title: "route-same-activity-a" }) + await svc.create({ title: "route-same-activity-b" }) + }, + }) + } finally { + Date.now = originalNow + } + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const app = Server.Default().app + const first = await app.request( + `/experimental/session?directory=${encodeURIComponent(tmp.path)}&roots=true&limit=1&sort=activity`, + ) + expect(first.status).toBe(200) + const cursor = first.headers.get("x-next-cursor") + expect(cursor).toBeTruthy() + expect(first.headers.get("Access-Control-Expose-Headers")).toContain("X-Next-Cursor") + const firstBody = (await first.json()) as SessionNs.GlobalInfo[] + expect(firstBody).toHaveLength(1) + expect(firstBody[0].activityAt).toBe(1_000) + + const second = await app.request( + `/experimental/session?directory=${encodeURIComponent(tmp.path)}&roots=true&limit=10&sort=activity&cursor=${encodeURIComponent(cursor!)}`, + ) + expect(second.status).toBe(200) + const secondBody = (await second.json()) as SessionNs.GlobalInfo[] + expect(secondBody).toHaveLength(1) + expect(secondBody[0].id).not.toBe(firstBody[0].id) + expect(secondBody[0].activityAt).toBe(1_000) + }, + }) + }) +}) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 8e87690a5..6c3e87eea 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -138,6 +138,33 @@ describe("session.listGlobal", () => { }), ) + test("ignores object cursors for default updated ordering", async () => { + await using tmp = await tmpdir({ git: true }) + const originalNow = Date.now + try { + Date.now = () => 1_000 + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await svc.create({ title: "object-cursor-a" }) + await svc.create({ title: "object-cursor-b" }) + }, + }) + + const sessions = [ + ...svc.listGlobal({ + directory: tmp.path, + limit: 10, + cursor: { activityAt: 1_000, id: "ses_00000000000000000000000000" } as never, + }), + ] + + expect(sessions.map((session) => session.title).sort()).toEqual(["object-cursor-a", "object-cursor-b"]) + } finally { + Date.now = originalNow + } + }) + it.live( "orders global sessions by creation time when requested", Effect.promise(async () => { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 751e979ae..a2647650e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -772,7 +772,7 @@ export class Session extends HeyApiClient { /** * List sessions * - * Get a list of all OpenCode sessions across projects. Defaults to most recently updated; use sort=created for creation-time order. Archived sessions are excluded by default. + * Get a list of all OpenCode sessions across projects. Defaults to most recently updated; use sort=created for creation-time order or sort=activity for latest user-message activity order. Archived sessions are excluded by default. */ public list( parameters?: { @@ -784,7 +784,7 @@ export class Session extends HeyApiClient { search?: string limit?: number archived?: boolean - sort?: "updated" | "created" + sort?: "updated" | "created" | "activity" }, options?: Options, ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2c06561e1..380483d98 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2055,6 +2055,8 @@ export type GlobalSession = { lastChangedAt: number } project: ProjectSummary | null + activityAt?: number + lastUserMessageAt?: number } export type McpResource = { @@ -3345,9 +3347,9 @@ export type ExperimentalSessionListData = { */ archived?: boolean /** - * Sort sessions by last update or creation time + * Sort sessions by last update, creation time, or latest user-message activity */ - sort?: "updated" | "created" + sort?: "updated" | "created" | "activity" } url: "/experimental/session" }