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

This commit is contained in:
Dax
2026-06-28 00:04:53 +00:00
committed by GitHub
parent cd942d0669
commit d1d7ebc2c6
20 changed files with 478 additions and 87 deletions
+59 -48
View File
@@ -89,15 +89,25 @@ const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Inp
Effect.mapError(mapClientError), 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 = { type Endpoint0_6Input = {
readonly sessionID: Endpoint0_6Request["params"]["sessionID"] readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
readonly id?: Endpoint0_6Request["payload"]["id"] readonly title: Endpoint0_6Request["payload"]["title"]
readonly prompt: Endpoint0_6Request["payload"]["prompt"]
readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
readonly resume?: Endpoint0_6Request["payload"]["resume"]
} }
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) => 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"]({ raw["session.prompt"]({
params: { sessionID: input.sessionID }, params: { sessionID: input.sessionID },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, 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), Effect.map((value) => value.data),
) )
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint0_8Request = 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_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] } type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) => 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)) raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0] type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint0_9Input = { type Endpoint0_10Input = {
readonly sessionID: Endpoint0_9Request["params"]["sessionID"] readonly sessionID: Endpoint0_10Request["params"]["sessionID"]
readonly messageID: Endpoint0_9Request["payload"]["messageID"] readonly messageID: Endpoint0_10Request["payload"]["messageID"]
readonly files?: Endpoint0_9Request["payload"]["files"] 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"]({ raw["session.revert.stage"]({
params: { sessionID: input.sessionID }, params: { sessionID: input.sessionID },
payload: { messageID: input.messageID, files: input.files }, 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), Effect.map((value) => value.data),
) )
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint0_11Request = 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_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] } type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) => 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"] } type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) => 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( raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0] type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint0_13Input = { type Endpoint0_14Input = {
readonly sessionID: Endpoint0_13Request["params"]["sessionID"] readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
readonly after?: Endpoint0_13Request["query"]["after"] 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( Stream.unwrap(
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe( raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
Effect.mapError(mapClientError), 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_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] } type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] }
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint0_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint0_15Input = { type Endpoint0_16Input = {
readonly sessionID: Endpoint0_15Request["params"]["sessionID"] readonly sessionID: Endpoint0_16Request["params"]["sessionID"]
readonly messageID: Endpoint0_15Request["params"]["messageID"] 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( raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
@@ -185,16 +195,17 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
get: Endpoint0_3(raw), get: Endpoint0_3(raw),
switchAgent: Endpoint0_4(raw), switchAgent: Endpoint0_4(raw),
switchModel: Endpoint0_5(raw), switchModel: Endpoint0_5(raw),
prompt: Endpoint0_6(raw), rename: Endpoint0_6(raw),
compact: Endpoint0_7(raw), prompt: Endpoint0_7(raw),
wait: Endpoint0_8(raw), compact: Endpoint0_8(raw),
stage: Endpoint0_9(raw), wait: Endpoint0_9(raw),
clear: Endpoint0_10(raw), stage: Endpoint0_10(raw),
commit: Endpoint0_11(raw), clear: Endpoint0_11(raw),
context: Endpoint0_12(raw), commit: Endpoint0_12(raw),
events: Endpoint0_13(raw), context: Endpoint0_13(raw),
interrupt: Endpoint0_14(raw), events: Endpoint0_14(raw),
message: Endpoint0_15(raw), interrupt: Endpoint0_15(raw),
message: Endpoint0_16(raw),
}) })
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
+17 -3
View File
@@ -10,6 +10,8 @@ import type {
SessionsSwitchAgentOutput, SessionsSwitchAgentOutput,
SessionsSwitchModelInput, SessionsSwitchModelInput,
SessionsSwitchModelOutput, SessionsSwitchModelOutput,
SessionsRenameInput,
SessionsRenameOutput,
SessionsPromptInput, SessionsPromptInput,
SessionsPromptOutput, SessionsPromptOutput,
SessionsCompactInput, SessionsCompactInput,
@@ -245,6 +247,18 @@ export function make(options: ClientOptions) {
}, },
requestOptions, 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) => prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsPromptOutput }>( request<{ readonly data: SessionsPromptOutput }>(
{ {
@@ -286,7 +300,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
body: { messageID: input.messageID, files: input.files }, body: { messageID: input.messageID, files: input.files },
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 409, 500, 400, 401],
empty: false, empty: false,
}, },
requestOptions, requestOptions,
@@ -297,7 +311,7 @@ export function make(options: ClientOptions) {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 409, 500, 400, 401],
empty: true, empty: true,
}, },
requestOptions, requestOptions,
@@ -308,7 +322,7 @@ export function make(options: ClientOptions) {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 409, 400, 401],
empty: true, empty: true,
}, },
requestOptions, requestOptions,
+23
View File
@@ -56,6 +56,14 @@ export type MessageNotFoundError = {
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError => export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "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 = { export type UnknownError = {
readonly _tag: "UnknownError" readonly _tag: "UnknownError"
readonly message: string readonly message: string
@@ -297,6 +305,13 @@ export type SessionsSwitchModelInput = {
export type SessionsSwitchModelOutput = void 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 = { export type SessionsPromptInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: { readonly id?: {
@@ -637,6 +652,14 @@ export type SessionsEventsOutput =
readonly subdirectory?: string 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 id: string
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
+24 -3
View File
@@ -102,6 +102,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
messageID: SessionMessage.ID, messageID: SessionMessage.ID,
}) {} }) {}
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError
@@ -136,6 +139,10 @@ export interface Interface {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
model: ModelV2.Ref model: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError> }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: {
sessionID: SessionSchema.ID
title: string
}) => Effect.Effect<void, NotFoundError>
readonly prompt: (input: { readonly prompt: (input: {
id?: SessionMessage.ID id?: SessionMessage.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
@@ -165,9 +172,9 @@ export interface Interface {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
messageID: SessionMessage.ID messageID: SessionMessage.ID
files?: boolean files?: boolean
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error> }) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error> readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError> readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
} }
} }
@@ -404,6 +411,14 @@ export const layer = Layer.unwrap(
model: input.model, 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) { compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" }) return yield* new OperationUnavailableError({ operation: "compact" })
@@ -423,6 +438,8 @@ export const layer = Layer.unwrap(
revert: { revert: {
stage: Effect.fn("V2Session.revert.stage")(function* (input) { stage: Effect.fn("V2Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID) 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( return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database), Effect.provideService(Database.Service, database),
Effect.provideService(EventV2.Service, events), Effect.provideService(EventV2.Service, events),
@@ -431,6 +448,8 @@ export const layer = Layer.unwrap(
}), }),
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) { clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID) const session = yield* result.get(sessionID)
if ((yield* execution.active).has(sessionID))
return yield* new BusyError({ sessionID })
yield* SessionRevert.clear(session).pipe( yield* SessionRevert.clear(session).pipe(
Effect.provideService(EventV2.Service, events), Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)), Effect.provide(locations.get(session.location)),
@@ -438,6 +457,8 @@ export const layer = Layer.unwrap(
}), }),
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) { commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(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)) yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
}), }),
}, },
@@ -123,6 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
) )
}, },
"session.next.moved": () => Effect.void, "session.next.moved": () => Effect.void,
"session.next.renamed": () => Effect.void,
"session.next.prompted": (event) => { "session.next.prompted": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.User.make({ SessionMessage.User.make({
+8
View File
@@ -347,6 +347,14 @@ export const layer = Layer.effectDiscard(
yield* run(db, event) 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) => yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
@@ -251,6 +251,9 @@ describe("PublicApi OpenAPI v2 errors", () => {
["post", "/session/{sessionID}/revert"], ["post", "/session/{sessionID}/revert"],
["post", "/session/{sessionID}/unrevert"], ["post", "/session/{sessionID}/unrevert"],
["delete", "/session/{sessionID}/message/{messageID}"], ["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) { ] as const) {
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe( expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe(
"SessionBusyError", "SessionBusyError",
+9
View File
@@ -25,6 +25,15 @@ export class ConflictError extends Schema.TaggedErrorClass<ConflictError>()(
{ httpApiStatus: 409 }, { 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>()( export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
"ServiceUnavailableError", "ServiceUnavailableError",
{ {
+20 -3
View File
@@ -13,6 +13,7 @@ import {
InvalidRequestError, InvalidRequestError,
MessageNotFoundError, MessageNotFoundError,
ServiceUnavailableError, ServiceUnavailableError,
SessionBusyError,
SessionNotFoundError, SessionNotFoundError,
UnknownError, UnknownError,
} from "../errors" } 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( .add(
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
@@ -243,7 +260,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
success: Schema.Struct({ data: Revert.State }), success: Schema.Struct({ data: Revert.State }),
error: [MessageNotFoundError, SessionNotFoundError, UnknownError], error: [MessageNotFoundError, SessionNotFoundError, SessionBusyError, UnknownError],
}) })
.middleware(sessionLocationMiddleware) .middleware(sessionLocationMiddleware)
.annotateMerge( .annotateMerge(
@@ -258,7 +275,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", { HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, UnknownError], error: [SessionNotFoundError, SessionBusyError, UnknownError],
}) })
.middleware(sessionLocationMiddleware) .middleware(sessionLocationMiddleware)
.annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })), .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", { HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
error: SessionNotFoundError, error: [SessionNotFoundError, SessionBusyError],
}) })
.middleware(sessionLocationMiddleware) .middleware(sessionLocationMiddleware)
.annotateMerge( .annotateMerge(
+12
View File
@@ -84,6 +84,16 @@ export const Moved = Event.define({
}) })
export type Moved = typeof Moved.Type 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({ export const Prompted = Event.define({
type: "session.next.prompted", type: "session.next.prompted",
...options, ...options,
@@ -449,6 +459,7 @@ export const DurableDefinitions = Event.inventory(
AgentSwitched, AgentSwitched,
ModelSwitched, ModelSwitched,
Moved, Moved,
Renamed,
Prompted, Prompted,
PromptAdmitted, PromptAdmitted,
ContextUpdated, ContextUpdated,
@@ -480,6 +491,7 @@ export const Definitions = Event.inventory(
AgentSwitched, AgentSwitched,
ModelSwitched, ModelSwitched,
Moved, Moved,
Renamed,
Prompted, Prompted,
PromptAdmitted, PromptAdmitted,
ContextUpdated, ContextUpdated,
+37
View File
@@ -369,6 +369,8 @@ import type {
V2SessionQuestionRejectResponses, V2SessionQuestionRejectResponses,
V2SessionQuestionReplyErrors, V2SessionQuestionReplyErrors,
V2SessionQuestionReplyResponses, V2SessionQuestionReplyResponses,
V2SessionRenameErrors,
V2SessionRenameResponses,
V2SessionRevertClearErrors, V2SessionRevertClearErrors,
V2SessionRevertClearResponses, V2SessionRevertClearResponses,
V2SessionRevertCommitErrors, 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 * Send message
* *
+113
View File
@@ -19,6 +19,7 @@ export type Event =
| EventSessionNextAgentSwitched | EventSessionNextAgentSwitched
| EventSessionNextModelSwitched | EventSessionNextModelSwitched
| EventSessionNextMoved | EventSessionNextMoved
| EventSessionNextRenamed
| EventSessionNextPrompted | EventSessionNextPrompted
| EventSessionNextPromptAdmitted | EventSessionNextPromptAdmitted
| EventSessionNextContextUpdated | EventSessionNextContextUpdated
@@ -848,6 +849,15 @@ export type GlobalEvent = {
subdirectory?: string subdirectory?: string
} }
} }
| {
id: string
type: "session.next.renamed"
properties: {
timestamp: number
sessionID: string
title: string
}
}
| { | {
id: string id: string
type: "session.next.prompted" type: "session.next.prompted"
@@ -1611,6 +1621,7 @@ export type GlobalEvent = {
| SyncEventSessionNextAgentSwitched | SyncEventSessionNextAgentSwitched
| SyncEventSessionNextModelSwitched | SyncEventSessionNextModelSwitched
| SyncEventSessionNextMoved | SyncEventSessionNextMoved
| SyncEventSessionNextRenamed
| SyncEventSessionNextPrompted | SyncEventSessionNextPrompted
| SyncEventSessionNextPromptAdmitted | SyncEventSessionNextPromptAdmitted
| SyncEventSessionNextContextUpdated | SyncEventSessionNextContextUpdated
@@ -2760,6 +2771,7 @@ export type V2Event =
| V2EventSessionNextAgentSwitched | V2EventSessionNextAgentSwitched
| V2EventSessionNextModelSwitched | V2EventSessionNextModelSwitched
| V2EventSessionNextMoved | V2EventSessionNextMoved
| V2EventSessionNextRenamed
| V2EventSessionNextPrompted | V2EventSessionNextPrompted
| V2EventSessionNextPromptAdmitted | V2EventSessionNextPromptAdmitted
| V2EventSessionNextContextUpdated | 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 = { export type SyncEventSessionNextPrompted = {
type: "sync" type: "sync"
id: string 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 = { export type SessionNextPrompted = {
id: string id: string
metadata?: { 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 = { export type V2EventSessionNextPrompted = {
id: string id: string
metadata?: { 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 = { export type EventSessionNextPrompted = {
id: string id: string
type: "session.next.prompted" type: "session.next.prompted"
@@ -12105,6 +12181,43 @@ export type V2SessionSwitchModelResponses = {
export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof 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 = { export type V2SessionPromptData = {
body: { body: {
id?: string id?: string
+41
View File
@@ -8,6 +8,7 @@ import {
InvalidCursorError, InvalidCursorError,
MessageNotFoundError, MessageNotFoundError,
ServiceUnavailableError, ServiceUnavailableError,
SessionBusyError,
SessionNotFoundError, SessionNotFoundError,
UnknownError, UnknownError,
} from "@opencode-ai/protocol/errors" } from "@opencode-ai/protocol/errors"
@@ -135,6 +136,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make() 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( .handle(
"session.prompt", "session.prompt",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
@@ -238,6 +255,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
message: `Message not found: ${error.messageID}`, 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) => { Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}` const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to stage session revert", { cause: error }).pipe( 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}`, 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) => { Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}` const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to clear session revert", { cause: error }).pipe( 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}`, 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() return HttpApiSchema.NoContent.make()
}), }),
@@ -13,6 +13,7 @@ import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap" import { useCommandShortcut } from "../keymap"
import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
export function DialogSessionList() { export function DialogSessionList() {
@@ -121,7 +122,8 @@ export function DialogSessionList() {
{ {
command: "session.rename", command: "session.rename",
title: "rename", title: "rename",
onTrigger: () => unavailable("Renaming"), onTrigger: (option: { value: string }) =>
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
}, },
]} ]}
footerHints={quickSwitchFooterHints()} footerHints={quickSwitchFooterHints()}
@@ -1,31 +1,33 @@
import { DialogPrompt } from "../ui/dialog-prompt" import { DialogPrompt } from "../ui/dialog-prompt"
import { useDialog } from "../ui/dialog" import { type DialogContext, useDialog } from "../ui/dialog"
import { useSync } from "../context/sync"
import { createMemo } from "solid-js"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
interface DialogSessionRenameProps { export function DialogSessionRename(props: { sessionID: string; currentTitle?: string }) {
session: string
}
export function DialogSessionRename(props: DialogSessionRenameProps) {
const dialog = useDialog() const dialog = useDialog()
const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const session = createMemo(() => sync.session.get(props.session)) const toast = useToast()
return ( return (
<DialogPrompt <DialogPrompt
title="Rename Session" title="Rename session"
value={session()?.title} placeholder="Session title"
value={props.currentTitle}
onConfirm={(value) => { onConfirm={(value) => {
void sdk.client.session.update({ const title = value.trim()
sessionID: props.session, if (!title) return
title: value, void sdk.client.v2.session
}) .rename({ sessionID: props.sessionID, title }, { throwOnError: true })
dialog.clear() .then(() => dialog.clear())
.catch((error) =>
toast.show({ message: `Failed to rename session: ${errorMessage(error)}`, variant: "error", duration: 5000 }),
)
}} }}
onCancel={() => dialog.clear()} onCancel={() => dialog.clear()}
/> />
) )
} }
DialogSessionRename.show = (dialog: DialogContext, sessionID: string, currentTitle?: string) =>
dialog.replace(() => <DialogSessionRename sessionID={sessionID} currentTitle={currentTitle} />)
@@ -1104,6 +1104,13 @@ export function Prompt(props: PromptProps) {
{ throwOnError: true }, { 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({ const result = await sdk.client.v2.session.prompt({
sessionID, sessionID,
prompt: { prompt: {
+13
View File
@@ -169,6 +169,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
}) })
break 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": { case "session.next.prompted": {
setStore("session", "status", event.data.sessionID, "running") setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
@@ -405,6 +409,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.compaction.started": case "session.next.compaction.started":
setStore("session", "status", event.data.sessionID, "running") setStore("session", "status", event.data.sessionID, "running")
break 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": case "session.next.compaction.delta":
break break
case "session.next.compaction.ended": case "session.next.compaction.ended":
@@ -3,11 +3,14 @@ import { useData } from "../../context/data"
import { DialogSelect } from "../../ui/dialog-select" import { DialogSelect } from "../../ui/dialog-select"
import { useClipboard } from "../../context/clipboard" import { useClipboard } from "../../context/clipboard"
import { useToast } from "../../ui/toast" 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 }) { export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) {
const data = useData() const data = useData()
const clipboard = useClipboard() const clipboard = useClipboard()
const toast = useToast() const toast = useToast()
const sdk = useSDK()
const message = createMemo(() => const message = createMemo(() =>
data.session.message.get(props.sessionID, props.messageID), data.session.message.get(props.sessionID, props.messageID),
) )
@@ -20,8 +23,12 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
title: "Revert", title: "Revert",
value: "session.revert", value: "session.revert",
description: "undo messages and file changes", description: "undo messages and file changes",
onSelect: (dialog) => { onSelect: async (dialog) => {
toast.show({ message: "Reverting is not implemented for V2 sessions yet", variant: "error", duration: 5000 }) 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() dialog.clear()
}, },
}, },
+44 -8
View File
@@ -42,6 +42,7 @@ import { useSDK } from "../../context/sdk"
import { useEditorContext } from "../../context/editor" import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor" import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { TodoItem } from "../../component/todo-item" import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message" import { DialogMessage } from "./dialog-message"
import { Sidebar } from "./sidebar" import { Sidebar } from "./sidebar"
@@ -335,7 +336,7 @@ export function Session() {
value: "session.rename", value: "session.rename",
category: "Session", category: "Session",
slash: { name: "rename" }, slash: { name: "rename" },
run: () => unavailable("Renaming"), run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
}, },
{ {
title: "Jump to message", title: "Jump to message",
@@ -377,7 +378,28 @@ export function Session() {
value: "session.undo", value: "session.undo",
category: "Session", category: "Session",
slash: { name: "undo" }, 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", title: "Redo",
@@ -385,7 +407,13 @@ export function Session() {
category: "Session", category: "Session",
enabled: !!session()?.revert?.messageID, enabled: !!session()?.revert?.messageID,
slash: { name: "redo" }, 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", title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
@@ -848,6 +876,11 @@ export function Session() {
/> />
)} )}
</For> </For>
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messages().filter((message) => message.id > session()!.revert!.messageID).length}
/>
</Show>
</scrollbox> </scrollbox>
<box flexShrink={0}> <box flexShrink={0}>
<Show when={permissions().length > 0}> <Show when={permissions().length > 0}>
@@ -1136,9 +1169,10 @@ function CompactionMessage() {
function RevertMessage(props: { count: number }) { function RevertMessage(props: { count: number }) {
const { theme } = useTheme() const { theme } = useTheme()
const dialog = useDialog() const route = useRouteData("session")
const renderer = useRenderer() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false) const [hover, setHover] = createSignal(false)
return ( return (
<box <box
@@ -1146,8 +1180,10 @@ function RevertMessage(props: { count: number }) {
onMouseOut={() => setHover(false)} onMouseOut={() => setHover(false)}
onMouseUp={() => { onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return if (renderer.getSelection()?.getSelectedText()) return
toast.show({ message: "Redo is not implemented for V2 sessions yet", variant: "error", duration: 5000 }) void (async () => {
dialog.clear() 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} flexShrink={0}
border={["left"]} border={["left"]}
@@ -1156,7 +1192,7 @@ function RevertMessage(props: { count: number }) {
> >
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}> <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}>{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>
</box> </box>
) )
+16 -2
View File
@@ -23,6 +23,13 @@ export type SessionRow =
export function createSessionRows(sessionID: Accessor<string>) { export function createSessionRows(sessionID: Accessor<string>) {
const data = useData() const data = useData()
const [rows, setRows] = createStore<SessionRow[]>([]) 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(() => { createEffect(() => {
const pending = new Set( const pending = new Set(
@@ -44,17 +51,24 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect( createEffect(
on(sessionID, (id) => { on(sessionID, (id) => {
setRows(reconcile(reduceSessionRows(data.session.message.list(id)))) setRows(reconcile(reduce()))
void data.session.message.refresh(id).then( void data.session.message.refresh(id).then(
() => { () => {
if (sessionID() !== id) return if (sessionID() !== id) return
setRows(reconcile(reduceSessionRows(data.session.message.list(id)))) setRows(reconcile(reduce()))
}, },
() => undefined, () => undefined,
) )
}), }),
) )
// Re-reduce when the revert boundary changes (stage/clear/commit).
createEffect(
on(revertBoundary, () => {
setRows(reconcile(reduce()))
}),
)
const appendMessage = (messageID: string) => const appendMessage = (messageID: string) =>
setRows( setRows(
produce((draft) => { produce((draft) => {