feat(tui): wire up undo/redo and revert for V2 sessions (#34263)

This commit is contained in:
Dax 2026-06-27 20:04:53 -04:00 committed by GitHub
parent cd942d0669
commit d1d7ebc2c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 478 additions and 87 deletions

View file

@ -89,15 +89,25 @@ const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Inp
Effect.mapError(mapClientError),
)
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
type Endpoint0_6Input = {
readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
readonly id?: Endpoint0_6Request["payload"]["id"]
readonly prompt: Endpoint0_6Request["payload"]["prompt"]
readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
readonly resume?: Endpoint0_6Request["payload"]["resume"]
readonly title: Endpoint0_6Request["payload"]["title"]
}
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
raw["session.rename"]({ params: { sessionID: input.sessionID }, payload: { title: input.title } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_7Input = {
readonly sessionID: Endpoint0_7Request["params"]["sessionID"]
readonly id?: Endpoint0_7Request["payload"]["id"]
readonly prompt: Endpoint0_7Request["payload"]["prompt"]
readonly delivery?: Endpoint0_7Request["payload"]["delivery"]
readonly resume?: Endpoint0_7Request["payload"]["resume"]
}
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
raw["session.prompt"]({
params: { sessionID: input.sessionID },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
@ -106,23 +116,23 @@ const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Inp
Effect.map((value) => value.data),
)
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint0_9Input = {
readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
readonly messageID: Endpoint0_9Request["payload"]["messageID"]
readonly files?: Endpoint0_9Request["payload"]["files"]
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint0_10Input = {
readonly sessionID: Endpoint0_10Request["params"]["sessionID"]
readonly messageID: Endpoint0_10Request["payload"]["messageID"]
readonly files?: Endpoint0_10Request["payload"]["files"]
}
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
raw["session.revert.stage"]({
params: { sessionID: input.sessionID },
payload: { messageID: input.messageID, files: input.files },
@ -131,30 +141,30 @@ const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Inp
Effect.map((value) => value.data),
)
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] }
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint0_13Input = {
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
readonly after?: Endpoint0_13Request["query"]["after"]
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint0_14Input = {
readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
readonly after?: Endpoint0_14Request["query"]["after"]
}
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
Stream.unwrap(
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
Effect.mapError(mapClientError),
@ -162,17 +172,17 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I
),
)
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] }
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint0_15Input = {
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
readonly messageID: Endpoint0_15Request["params"]["messageID"]
type Endpoint0_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint0_16Input = {
readonly sessionID: Endpoint0_16Request["params"]["sessionID"]
readonly messageID: Endpoint0_16Request["params"]["messageID"]
}
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
const Endpoint0_16 = (raw: RawClient["server.session"]) => (input: Endpoint0_16Input) =>
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@ -185,16 +195,17 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
get: Endpoint0_3(raw),
switchAgent: Endpoint0_4(raw),
switchModel: Endpoint0_5(raw),
prompt: Endpoint0_6(raw),
compact: Endpoint0_7(raw),
wait: Endpoint0_8(raw),
stage: Endpoint0_9(raw),
clear: Endpoint0_10(raw),
commit: Endpoint0_11(raw),
context: Endpoint0_12(raw),
events: Endpoint0_13(raw),
interrupt: Endpoint0_14(raw),
message: Endpoint0_15(raw),
rename: Endpoint0_6(raw),
prompt: Endpoint0_7(raw),
compact: Endpoint0_8(raw),
wait: Endpoint0_9(raw),
stage: Endpoint0_10(raw),
clear: Endpoint0_11(raw),
commit: Endpoint0_12(raw),
context: Endpoint0_13(raw),
events: Endpoint0_14(raw),
interrupt: Endpoint0_15(raw),
message: Endpoint0_16(raw),
})
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })

View file

@ -10,6 +10,8 @@ import type {
SessionsSwitchAgentOutput,
SessionsSwitchModelInput,
SessionsSwitchModelOutput,
SessionsRenameInput,
SessionsRenameOutput,
SessionsPromptInput,
SessionsPromptOutput,
SessionsCompactInput,
@ -245,6 +247,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
rename: (input: SessionsRenameInput, requestOptions?: RequestOptions) =>
request<SessionsRenameOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/rename`,
body: { title: input.title },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsPromptOutput }>(
{
@ -286,7 +300,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
body: { messageID: input.messageID, files: input.files },
successStatus: 200,
declaredStatuses: [404, 500, 400, 401],
declaredStatuses: [404, 409, 500, 400, 401],
empty: false,
},
requestOptions,
@ -297,7 +311,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
successStatus: 204,
declaredStatuses: [404, 500, 400, 401],
declaredStatuses: [404, 409, 500, 400, 401],
empty: true,
},
requestOptions,
@ -308,7 +322,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
declaredStatuses: [404, 409, 400, 401],
empty: true,
},
requestOptions,

View file

@ -56,6 +56,14 @@ export type MessageNotFoundError = {
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "MessageNotFoundError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
readonly message: string
}
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionBusyError"
export type UnknownError = {
readonly _tag: "UnknownError"
readonly message: string
@ -297,6 +305,13 @@ export type SessionsSwitchModelInput = {
export type SessionsSwitchModelOutput = void
export type SessionsRenameInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly title: { readonly title: string }["title"]
}
export type SessionsRenameOutput = void
export type SessionsPromptInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
@ -637,6 +652,14 @@ export type SessionsEventsOutput =
readonly subdirectory?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.renamed"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }

View file

@ -102,6 +102,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
@ -136,6 +139,10 @@ export interface Interface {
sessionID: SessionSchema.ID
model: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: {
sessionID: SessionSchema.ID
title: string
}) => Effect.Effect<void, NotFoundError>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@ -165,9 +172,9 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
}
}
@ -404,6 +411,14 @@ export const layer = Layer.unwrap(
model: input.model,
})
}),
rename: Effect.fn("V2Session.rename")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.Renamed, {
sessionID: input.sessionID,
timestamp: yield* DateTime.now,
title: input.title,
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" })
@ -423,6 +438,8 @@ export const layer = Layer.unwrap(
revert: {
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
if ((yield* execution.active).has(input.sessionID))
return yield* new BusyError({ sessionID: input.sessionID })
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(EventV2.Service, events),
@ -431,6 +448,8 @@ export const layer = Layer.unwrap(
}),
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
if ((yield* execution.active).has(sessionID))
return yield* new BusyError({ sessionID })
yield* SessionRevert.clear(session).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
@ -438,6 +457,8 @@ export const layer = Layer.unwrap(
}),
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(sessionID)
if ((yield* execution.active).has(sessionID))
return yield* new BusyError({ sessionID })
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
}),
},

View file

@ -123,6 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
},
"session.next.moved": () => Effect.void,
"session.next.renamed": () => Effect.void,
"session.next.prompted": (event) => {
return adapter.appendMessage(
SessionMessage.User.make({

View file

@ -347,6 +347,14 @@ export const layer = Layer.effectDiscard(
yield* run(db, event)
}),
)
yield* events.project(SessionEvent.Renamed, (event) =>
db
.update(SessionTable)
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")

View file

@ -251,6 +251,9 @@ describe("PublicApi OpenAPI v2 errors", () => {
["post", "/session/{sessionID}/revert"],
["post", "/session/{sessionID}/unrevert"],
["delete", "/session/{sessionID}/message/{messageID}"],
["post", "/api/session/{sessionID}/revert/stage"],
["post", "/api/session/{sessionID}/revert/clear"],
["post", "/api/session/{sessionID}/revert/commit"],
] as const) {
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe(
"SessionBusyError",

View file

@ -25,6 +25,15 @@ export class ConflictError extends Schema.TaggedErrorClass<ConflictError>()(
{ httpApiStatus: 409 },
) {}
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()(
"SessionBusyError",
{
sessionID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 409 },
) {}
export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
"ServiceUnavailableError",
{

View file

@ -13,6 +13,7 @@ import {
InvalidRequestError,
MessageNotFoundError,
ServiceUnavailableError,
SessionBusyError,
SessionNotFoundError,
UnknownError,
} from "../errors"
@ -187,6 +188,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.rename", "/api/session/:sessionID/rename", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ title: Schema.String }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.rename",
summary: "Rename session",
description: "Update the session title.",
}),
),
)
.add(
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: Session.ID },
@ -243,7 +260,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
params: { sessionID: Session.ID },
payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
success: Schema.Struct({ data: Revert.State }),
error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
error: [MessageNotFoundError, SessionNotFoundError, SessionBusyError, UnknownError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
@ -258,7 +275,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, UnknownError],
error: [SessionNotFoundError, SessionBusyError, UnknownError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })),
@ -267,7 +284,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
error: [SessionNotFoundError, SessionBusyError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(

View file

@ -84,6 +84,16 @@ export const Moved = Event.define({
})
export type Moved = typeof Moved.Type
export const Renamed = Event.define({
type: "session.next.renamed",
...options,
schema: {
...Base,
title: Schema.String,
},
})
export type Renamed = typeof Renamed.Type
export const Prompted = Event.define({
type: "session.next.prompted",
...options,
@ -449,6 +459,7 @@ export const DurableDefinitions = Event.inventory(
AgentSwitched,
ModelSwitched,
Moved,
Renamed,
Prompted,
PromptAdmitted,
ContextUpdated,
@ -480,6 +491,7 @@ export const Definitions = Event.inventory(
AgentSwitched,
ModelSwitched,
Moved,
Renamed,
Prompted,
PromptAdmitted,
ContextUpdated,

View file

@ -369,6 +369,8 @@ import type {
V2SessionQuestionRejectResponses,
V2SessionQuestionReplyErrors,
V2SessionQuestionReplyResponses,
V2SessionRenameErrors,
V2SessionRenameResponses,
V2SessionRevertClearErrors,
V2SessionRevertClearResponses,
V2SessionRevertCommitErrors,
@ -5612,6 +5614,41 @@ export class Session3 extends HeyApiClient {
})
}
/**
* Rename session
*
* Update the session title.
*/
public rename<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
title?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "body", key: "title" },
],
},
],
)
return (options?.client ?? this.client).post<V2SessionRenameResponses, V2SessionRenameErrors, ThrowOnError>({
url: "/api/session/{sessionID}/rename",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Send message
*

View file

@ -19,6 +19,7 @@ export type Event =
| EventSessionNextAgentSwitched
| EventSessionNextModelSwitched
| EventSessionNextMoved
| EventSessionNextRenamed
| EventSessionNextPrompted
| EventSessionNextPromptAdmitted
| EventSessionNextContextUpdated
@ -848,6 +849,15 @@ export type GlobalEvent = {
subdirectory?: string
}
}
| {
id: string
type: "session.next.renamed"
properties: {
timestamp: number
sessionID: string
title: string
}
}
| {
id: string
type: "session.next.prompted"
@ -1611,6 +1621,7 @@ export type GlobalEvent = {
| SyncEventSessionNextAgentSwitched
| SyncEventSessionNextModelSwitched
| SyncEventSessionNextMoved
| SyncEventSessionNextRenamed
| SyncEventSessionNextPrompted
| SyncEventSessionNextPromptAdmitted
| SyncEventSessionNextContextUpdated
@ -2760,6 +2771,7 @@ export type V2Event =
| V2EventSessionNextAgentSwitched
| V2EventSessionNextModelSwitched
| V2EventSessionNextMoved
| V2EventSessionNextRenamed
| V2EventSessionNextPrompted
| V2EventSessionNextPromptAdmitted
| V2EventSessionNextContextUpdated
@ -3240,6 +3252,22 @@ export type SyncEventSessionNextMoved = {
}
}
export type SyncEventSessionNextRenamed = {
type: "sync"
id: string
syncEvent: {
type: "session.next.renamed.1"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
title: string
}
}
}
export type SyncEventSessionNextPrompted = {
type: "sync"
id: string
@ -4113,6 +4141,25 @@ export type SessionNextMoved = {
}
}
export type SessionNextRenamed = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.renamed"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
title: string
}
}
export type SessionNextPrompted = {
id: string
metadata?: {
@ -5168,6 +5215,25 @@ export type V2EventSessionNextMoved = {
}
}
export type V2EventSessionNextRenamed = {
id: string
metadata?: {
[key: string]: unknown
}
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
type: "session.next.renamed"
data: {
timestamp: number
sessionID: string
title: string
}
}
export type V2EventSessionNextPrompted = {
id: string
metadata?: {
@ -6836,6 +6902,16 @@ export type EventSessionNextMoved = {
}
}
export type EventSessionNextRenamed = {
id: string
type: "session.next.renamed"
properties: {
timestamp: number
sessionID: string
title: string
}
}
export type EventSessionNextPrompted = {
id: string
type: "session.next.prompted"
@ -12105,6 +12181,43 @@ export type V2SessionSwitchModelResponses = {
export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V2SessionSwitchModelResponses]
export type V2SessionRenameData = {
body: {
title: string
}
path: {
sessionID: string
}
query?: never
url: "/api/session/{sessionID}/rename"
}
export type V2SessionRenameErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundError
}
export type V2SessionRenameError = V2SessionRenameErrors[keyof V2SessionRenameErrors]
export type V2SessionRenameResponses = {
/**
* <No Content>
*/
204: void
}
export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses]
export type V2SessionPromptData = {
body: {
id?: string

View file

@ -8,6 +8,7 @@ import {
InvalidCursorError,
MessageNotFoundError,
ServiceUnavailableError,
SessionBusyError,
SessionNotFoundError,
UnknownError,
} from "@opencode-ai/protocol/errors"
@ -135,6 +136,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.rename",
Effect.fn(function* (ctx) {
yield* session.rename({ sessionID: ctx.params.sessionID, title: ctx.payload.title }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.prompt",
Effect.fn(function* (ctx) {
@ -238,6 +255,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag(
"Session.BusyError",
(error) =>
new SessionBusyError({
sessionID: error.sessionID,
message: `Session is busy: ${error.sessionID}`,
}),
),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
@ -267,6 +292,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag(
"Session.BusyError",
(error) =>
new SessionBusyError({
sessionID: error.sessionID,
message: `Session is busy: ${error.sessionID}`,
}),
),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
@ -296,6 +329,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag(
"Session.BusyError",
(error) =>
new SessionBusyError({
sessionID: error.sessionID,
message: `Session is busy: ${error.sessionID}`,
}),
),
)
return HttpApiSchema.NoContent.make()
}),

View file

@ -13,6 +13,7 @@ import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap"
import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner"
export function DialogSessionList() {
@ -121,7 +122,8 @@ export function DialogSessionList() {
{
command: "session.rename",
title: "rename",
onTrigger: () => unavailable("Renaming"),
onTrigger: (option: { value: string }) =>
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
},
]}
footerHints={quickSwitchFooterHints()}

View file

@ -1,31 +1,33 @@
import { DialogPrompt } from "../ui/dialog-prompt"
import { useDialog } from "../ui/dialog"
import { useSync } from "../context/sync"
import { createMemo } from "solid-js"
import { type DialogContext, useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
interface DialogSessionRenameProps {
session: string
}
export function DialogSessionRename(props: DialogSessionRenameProps) {
export function DialogSessionRename(props: { sessionID: string; currentTitle?: string }) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const session = createMemo(() => sync.session.get(props.session))
const toast = useToast()
return (
<DialogPrompt
title="Rename Session"
value={session()?.title}
title="Rename session"
placeholder="Session title"
value={props.currentTitle}
onConfirm={(value) => {
void sdk.client.session.update({
sessionID: props.session,
title: value,
})
dialog.clear()
const title = value.trim()
if (!title) return
void sdk.client.v2.session
.rename({ sessionID: props.sessionID, title }, { throwOnError: true })
.then(() => dialog.clear())
.catch((error) =>
toast.show({ message: `Failed to rename session: ${errorMessage(error)}`, variant: "error", duration: 5000 }),
)
}}
onCancel={() => dialog.clear()}
/>
)
}
DialogSessionRename.show = (dialog: DialogContext, sessionID: string, currentTitle?: string) =>
dialog.replace(() => <DialogSessionRename sessionID={sessionID} currentTitle={currentTitle} />)

View file

@ -1104,6 +1104,13 @@ export function Prompt(props: PromptProps) {
{ throwOnError: true },
)
}
if (session?.revert) {
const revertResult = await sdk.client.v2.session.revert.commit({ sessionID })
if (revertResult.error) {
toast.show({ title: "Failed to commit revert", message: errorMessage(revertResult.error), variant: "error" })
return false
}
}
const result = await sdk.client.v2.session.prompt({
sessionID,
prompt: {

View file

@ -169,6 +169,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
break
case "session.next.renamed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.next.prompted": {
setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
@ -405,6 +409,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.compaction.started":
setStore("session", "status", event.data.sessionID, "running")
break
case "session.next.revert.staged":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", event.data.revert)
break
case "session.next.revert.cleared":
case "session.next.revert.committed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined)
break
case "session.next.compaction.delta":
break
case "session.next.compaction.ended":

View file

@ -3,11 +3,14 @@ import { useData } from "../../context/data"
import { DialogSelect } from "../../ui/dialog-select"
import { useClipboard } from "../../context/clipboard"
import { useToast } from "../../ui/toast"
import { useSDK } from "../../context/sdk"
import { errorMessage } from "../../util/error"
export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) {
const data = useData()
const clipboard = useClipboard()
const toast = useToast()
const sdk = useSDK()
const message = createMemo(() =>
data.session.message.get(props.sessionID, props.messageID),
)
@ -20,8 +23,12 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
title: "Revert",
value: "session.revert",
description: "undo messages and file changes",
onSelect: (dialog) => {
toast.show({ message: "Reverting is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
onSelect: async (dialog) => {
const result = await sdk.client.v2.session.revert.stage({
sessionID: props.sessionID,
messageID: props.messageID,
})
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
dialog.clear()
},
},

View file

@ -42,6 +42,7 @@ import { useSDK } from "../../context/sdk"
import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
import { Sidebar } from "./sidebar"
@ -335,7 +336,7 @@ export function Session() {
value: "session.rename",
category: "Session",
slash: { name: "rename" },
run: () => unavailable("Renaming"),
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
},
{
title: "Jump to message",
@ -377,7 +378,28 @@ export function Session() {
value: "session.undo",
category: "Session",
slash: { name: "undo" },
run: () => unavailable("Undo"),
run: () => {
void (async () => {
const boundary = session()?.revert?.messageID
const list = messages()
let target: string | undefined
for (let i = list.length - 1; i >= 0; i--) {
const message = list[i]
if (message.type !== "user" || !message.text.trim()) continue
if (boundary && message.id >= boundary) continue
target = message.id
break
}
if (!target) {
toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 })
dialog.clear()
return
}
const result = await sdk.client.v2.session.revert.stage({ sessionID: route.sessionID, messageID: target })
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
dialog.clear()
})()
},
},
{
title: "Redo",
@ -385,7 +407,13 @@ export function Session() {
category: "Session",
enabled: !!session()?.revert?.messageID,
slash: { name: "redo" },
run: () => unavailable("Redo"),
run: () => {
void (async () => {
const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID })
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
dialog.clear()
})()
},
},
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
@ -848,6 +876,11 @@ export function Session() {
/>
)}
</For>
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messages().filter((message) => message.id > session()!.revert!.messageID).length}
/>
</Show>
</scrollbox>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
@ -1136,9 +1169,10 @@ function CompactionMessage() {
function RevertMessage(props: { count: number }) {
const { theme } = useTheme()
const dialog = useDialog()
const renderer = useRenderer()
const route = useRouteData("session")
const sdk = useSDK()
const toast = useToast()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
return (
<box
@ -1146,8 +1180,10 @@ function RevertMessage(props: { count: number }) {
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
toast.show({ message: "Redo is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
dialog.clear()
void (async () => {
const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID })
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
})()
}}
flexShrink={0}
border={["left"]}
@ -1156,7 +1192,7 @@ function RevertMessage(props: { count: number }) {
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}>
<text fg={theme.textMuted}>{props.count} message{props.count === 1 ? "" : "s"} reverted</text>
<text fg={theme.textMuted}>Redo is not implemented for V2 sessions yet</text>
<text fg={theme.textMuted}>Click to redo</text>
</box>
</box>
)

View file

@ -23,6 +23,13 @@ export type SessionRow =
export function createSessionRows(sessionID: Accessor<string>) {
const data = useData()
const [rows, setRows] = createStore<SessionRow[]>([])
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
function reduce() {
const messages = data.session.message.list(sessionID())
const boundary = revertBoundary()
return reduceSessionRows(boundary ? messages.filter((message) => message.id <= boundary) : messages)
}
createEffect(() => {
const pending = new Set(
@ -44,17 +51,24 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect(
on(sessionID, (id) => {
setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
setRows(reconcile(reduce()))
void data.session.message.refresh(id).then(
() => {
if (sessionID() !== id) return
setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
setRows(reconcile(reduce()))
},
() => undefined,
)
}),
)
// Re-reduce when the revert boundary changes (stage/clear/commit).
createEffect(
on(revertBoundary, () => {
setRows(reconcile(reduce()))
}),
)
const appendMessage = (messageID: string) =>
setRows(
produce((draft) => {