Skip to content

Commit ad41151

Browse files
committed
core: add session listing API with filtering and improved pagination
Users can now browse sessions through the new /api/session endpoint with filters for directory, workspace, date range, and title search. Pagination cursors are now labeled 'previous' and 'next' instead of 'before' and 'after' to make navigation direction clearer. Both session lists and message history now support explicit 'asc' or 'desc' ordering so users can choose between newest-first or oldest-first views. The TUI session view now displays messages with the newest at the bottom, matching standard chat interfaces.
1 parent be51f94 commit ad41151

11 files changed

Lines changed: 730 additions & 267 deletions

File tree

packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
3939
const dimensions = useTerminalDimensions()
4040
const { theme, syntax, subtleSyntax } = useTheme()
4141
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
42-
const lastAssistant = createMemo(() => messages().findLast((message) => message.type === "assistant"))
42+
const renderedMessages = createMemo(() => messages().toReversed())
43+
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
4344

4445
createEffect(() => {
4546
void sync.session.message.sync(props.sessionID)
@@ -67,7 +68,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
6768
<Show when={messages().length === 0}>
6869
<MissingData label="Messages" detail="No v2 messages loaded from useSyncV2 yet." />
6970
</Show>
70-
<For each={messages()}>
71+
<For each={renderedMessages()}>
7172
{(message, index) => (
7273
<Switch>
7374
<Match when={message.type === "user"}>
Lines changed: 5 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,14 @@
1-
import { SessionID } from "@/session/schema"
2-
import { SessionMessage } from "@/v2/session-message"
3-
import { Prompt } from "@/v2/session-prompt"
4-
import { SessionV2 } from "@/v2/session"
5-
import { Schema } from "effect"
6-
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
7-
import { Authorization } from "../middleware/authorization"
1+
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
2+
import { MessageGroup } from "./v2/message"
3+
import { SessionGroup } from "./v2/session"
84

95
export const V2Api = HttpApi.make("v2")
10-
.add(
11-
HttpApiGroup.make("v2")
12-
.add(
13-
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
14-
params: { sessionID: SessionID },
15-
query: Schema.Struct({
16-
limit: Schema.optional(
17-
Schema.NumberFromString.check(
18-
Schema.isInt(),
19-
Schema.isGreaterThanOrEqualTo(1),
20-
Schema.isLessThanOrEqualTo(200),
21-
),
22-
).annotate({
23-
description:
24-
"Maximum number of messages to return. When omitted, the endpoint returns its default page size. Use limit without a cursor to fetch the newest page for chat history.",
25-
}),
26-
cursor: Schema.optional(Schema.String).annotate({
27-
description:
28-
"Opaque pagination cursor returned as before or after in the previous response. The cursor encodes whether to fetch older or newer messages. Use start to read from the beginning or end to read from the latest messages; end is the default.",
29-
}),
30-
}).annotate({ identifier: "V2SessionMessagesQuery" }),
31-
success: Schema.Struct({
32-
items: Schema.Array(SessionMessage.Message),
33-
cursor: Schema.Struct({
34-
before: Schema.String.pipe(Schema.optional),
35-
after: Schema.String.pipe(Schema.optional),
36-
}),
37-
}).annotate({ identifier: "V2SessionMessagesResponse" }),
38-
error: HttpApiError.BadRequest,
39-
}).annotateMerge(
40-
OpenApi.annotations({
41-
identifier: "v2.session.messages",
42-
summary: "Get v2 session messages",
43-
description:
44-
"Retrieve projected v2 messages for a session. For chat clients, request the latest page with limit, page backward through older history with the before cursor, and catch up with newer messages using the after cursor.",
45-
}),
46-
),
47-
)
48-
.add(
49-
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
50-
params: { sessionID: SessionID },
51-
payload: Schema.Struct({
52-
prompt: Prompt,
53-
delivery: SessionV2.Delivery.pipe(Schema.optional),
54-
}),
55-
success: SessionMessage.Message,
56-
}).annotateMerge(
57-
OpenApi.annotations({
58-
identifier: "v2.session.prompt",
59-
summary: "Send v2 message",
60-
description: "Create a v2 session message and queue it for the agent loop.",
61-
}),
62-
),
63-
)
64-
.add(
65-
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
66-
params: { sessionID: SessionID },
67-
success: HttpApiSchema.NoContent,
68-
}).annotateMerge(
69-
OpenApi.annotations({
70-
identifier: "v2.session.compact",
71-
summary: "Compact v2 session",
72-
description: "Compact a v2 session conversation.",
73-
}),
74-
),
75-
)
76-
.add(
77-
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
78-
params: { sessionID: SessionID },
79-
success: HttpApiSchema.NoContent,
80-
}).annotateMerge(
81-
OpenApi.annotations({
82-
identifier: "v2.session.wait",
83-
summary: "Wait for v2 session",
84-
description: "Wait for a v2 session agent loop to become idle.",
85-
}),
86-
),
87-
)
88-
.annotateMerge(
89-
OpenApi.annotations({
90-
title: "v2",
91-
description: "Experimental v2 routes.",
92-
}),
93-
)
94-
.middleware(Authorization),
95-
)
6+
.add(SessionGroup)
7+
.add(MessageGroup)
968
.annotateMerge(
979
OpenApi.annotations({
9810
title: "opencode experimental HttpApi",
9911
version: "0.0.1",
10012
description: "Experimental HttpApi surface for selected instance routes.",
10113
}),
10214
)
103-
104-
export * as V2HttpApi from "./v2"
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { SessionID } from "@/session/schema"
2+
import { SessionMessage } from "@/v2/session-message"
3+
import { Schema } from "effect"
4+
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
5+
import { Authorization } from "../../middleware/authorization"
6+
7+
export const MessageGroup = HttpApiGroup.make("v2.message")
8+
.add(
9+
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
10+
params: { sessionID: SessionID },
11+
query: Schema.Union([
12+
Schema.Struct({
13+
limit: Schema.optional(
14+
Schema.NumberFromString.check(
15+
Schema.isInt(),
16+
Schema.isGreaterThanOrEqualTo(1),
17+
Schema.isLessThanOrEqualTo(200),
18+
),
19+
).annotate({
20+
description:
21+
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
22+
}),
23+
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
24+
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
25+
}),
26+
cursor: Schema.optional(Schema.Never),
27+
}),
28+
Schema.Struct({
29+
limit: Schema.optional(
30+
Schema.NumberFromString.check(
31+
Schema.isInt(),
32+
Schema.isGreaterThanOrEqualTo(1),
33+
Schema.isLessThanOrEqualTo(200),
34+
),
35+
).annotate({
36+
description:
37+
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
38+
}),
39+
cursor: Schema.String.annotate({
40+
description:
41+
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
42+
}),
43+
order: Schema.optional(Schema.Never),
44+
}),
45+
]).annotate({ identifier: "V2SessionMessagesQuery" }),
46+
success: Schema.Struct({
47+
items: Schema.Array(SessionMessage.Message),
48+
cursor: Schema.Struct({
49+
previous: Schema.String.pipe(Schema.optional),
50+
next: Schema.String.pipe(Schema.optional),
51+
}),
52+
}).annotate({ identifier: "V2SessionMessagesResponse" }),
53+
error: HttpApiError.BadRequest,
54+
}).annotateMerge(
55+
OpenApi.annotations({
56+
identifier: "v2.session.messages",
57+
summary: "Get v2 session messages",
58+
description:
59+
"Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
60+
}),
61+
),
62+
)
63+
.annotateMerge(
64+
OpenApi.annotations({
65+
title: "v2 messages",
66+
description: "Experimental v2 message routes.",
67+
}),
68+
)
69+
.middleware(Authorization)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { WorkspaceID } from "@/control-plane/schema"
2+
import { SessionID } from "@/session/schema"
3+
import { Session } from "@/session/session"
4+
import { SessionMessage } from "@/v2/session-message"
5+
import { Prompt } from "@/v2/session-prompt"
6+
import { SessionV2 } from "@/v2/session"
7+
import { Schema, SchemaGetter } from "effect"
8+
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
9+
import { Authorization } from "../../middleware/authorization"
10+
11+
export const SessionGroup = HttpApiGroup.make("v2.session")
12+
.add(
13+
HttpApiEndpoint.get("sessions", "/api/session", {
14+
query: Schema.Union([
15+
Schema.Struct({
16+
limit: Schema.optional(
17+
Schema.NumberFromString.check(
18+
Schema.isInt(),
19+
Schema.isGreaterThanOrEqualTo(1),
20+
Schema.isLessThanOrEqualTo(200),
21+
),
22+
).annotate({
23+
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
24+
}),
25+
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
26+
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
27+
}),
28+
directory: Schema.String.pipe(Schema.optional),
29+
path: Schema.String.pipe(Schema.optional),
30+
workspace: WorkspaceID.pipe(Schema.optional),
31+
roots: Schema.Literals(["true", "false"])
32+
.pipe(
33+
Schema.decodeTo(Schema.Boolean, {
34+
decode: SchemaGetter.transform((value) => value === "true"),
35+
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
36+
}),
37+
)
38+
.pipe(Schema.optional),
39+
start: Schema.NumberFromString.pipe(Schema.optional),
40+
search: Schema.String.pipe(Schema.optional),
41+
cursor: Schema.optional(Schema.Never),
42+
}),
43+
Schema.Struct({
44+
limit: Schema.optional(
45+
Schema.NumberFromString.check(
46+
Schema.isInt(),
47+
Schema.isGreaterThanOrEqualTo(1),
48+
Schema.isLessThanOrEqualTo(200),
49+
),
50+
).annotate({
51+
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
52+
}),
53+
cursor: Schema.String.annotate({
54+
description:
55+
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
56+
}),
57+
order: Schema.optional(Schema.Never),
58+
directory: Schema.optional(Schema.Never),
59+
path: Schema.optional(Schema.Never),
60+
workspace: Schema.optional(Schema.Never),
61+
roots: Schema.optional(Schema.Never),
62+
start: Schema.optional(Schema.Never),
63+
search: Schema.optional(Schema.Never),
64+
}),
65+
]).annotate({ identifier: "V2SessionsQuery" }),
66+
success: Schema.Struct({
67+
items: Schema.Array(Session.Info),
68+
cursor: Schema.Struct({
69+
previous: Schema.String.pipe(Schema.optional),
70+
next: Schema.String.pipe(Schema.optional),
71+
}),
72+
}).annotate({ identifier: "V2SessionsResponse" }),
73+
error: HttpApiError.BadRequest,
74+
}).annotateMerge(
75+
OpenApi.annotations({
76+
identifier: "v2.session.list",
77+
summary: "List v2 sessions",
78+
description:
79+
"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
80+
}),
81+
),
82+
)
83+
.add(
84+
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
85+
params: { sessionID: SessionID },
86+
payload: Schema.Struct({
87+
prompt: Prompt,
88+
delivery: SessionV2.Delivery.pipe(Schema.optional),
89+
}),
90+
success: SessionMessage.Message,
91+
}).annotateMerge(
92+
OpenApi.annotations({
93+
identifier: "v2.session.prompt",
94+
summary: "Send v2 message",
95+
description: "Create a v2 session message and queue it for the agent loop.",
96+
}),
97+
),
98+
)
99+
.add(
100+
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
101+
params: { sessionID: SessionID },
102+
success: HttpApiSchema.NoContent,
103+
}).annotateMerge(
104+
OpenApi.annotations({
105+
identifier: "v2.session.compact",
106+
summary: "Compact v2 session",
107+
description: "Compact a v2 session conversation.",
108+
}),
109+
),
110+
)
111+
.add(
112+
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
113+
params: { sessionID: SessionID },
114+
success: HttpApiSchema.NoContent,
115+
}).annotateMerge(
116+
OpenApi.annotations({
117+
identifier: "v2.session.wait",
118+
summary: "Wait for v2 session",
119+
description: "Wait for a v2 session agent loop to become idle.",
120+
}),
121+
),
122+
)
123+
.annotateMerge(
124+
OpenApi.annotations({
125+
title: "v2",
126+
description: "Experimental v2 routes.",
127+
}),
128+
)
129+
.middleware(Authorization)

0 commit comments

Comments
 (0)