diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 172eb4ca228..e7ba635894f 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -89,15 +89,25 @@ const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Inp Effect.mapError(mapClientError), ) -type Endpoint0_6Request = Parameters[0] +type Endpoint0_6Request = Parameters[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[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[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[0] +type Endpoint0_8Request = Parameters[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[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[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[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[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[0] +type Endpoint0_11Request = Parameters[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[0] +type Endpoint0_12Request = Parameters[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[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[0] -type Endpoint0_13Input = { - readonly sessionID: Endpoint0_13Request["params"]["sessionID"] - readonly after?: Endpoint0_13Request["query"]["after"] +type Endpoint0_14Request = Parameters[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[0] -type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] } -const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => +type Endpoint0_15Request = Parameters[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[0] -type Endpoint0_15Input = { - readonly sessionID: Endpoint0_15Request["params"]["sessionID"] - readonly messageID: Endpoint0_15Request["params"]["messageID"] +type Endpoint0_16Request = Parameters[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"]) }) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 2d3cd92ee07..e1484e43dc8 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -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( + { + 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, diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 654712ebb67..2b4ebd048b2 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -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 } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 9140980361d..e4978ff320f 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -102,6 +102,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass()("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 + readonly rename: (input: { + sessionID: SessionSchema.ID + title: string + }) => Effect.Effect 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 - readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect - readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect + }) => Effect.Effect + readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect + readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect } } @@ -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)) }), }, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 46118a89fe4..3269aa1d728 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -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({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 4d8b9a05b4c..09e0f0c8187 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -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") diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index a8f6f8d1c85..6509b067b90 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -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", diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 3b1eced63a2..b374546e3ae 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -25,6 +25,15 @@ export class ConflictError extends Schema.TaggedErrorClass()( { httpApiStatus: 409 }, ) {} +export class SessionBusyError extends Schema.TaggedErrorClass()( + "SessionBusyError", + { + sessionID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 409 }, +) {} + export class ServiceUnavailableError extends Schema.TaggedErrorClass()( "ServiceUnavailableError", { diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 057e08d7c7b..ef27f541128 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -13,6 +13,7 @@ import { InvalidRequestError, MessageNotFoundError, ServiceUnavailableError, + SessionBusyError, SessionNotFoundError, UnknownError, } from "../errors" @@ -187,6 +188,22 @@ export const makeSessionGroup = (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 = (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 = (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 = (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( diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index f58c3dcce02..9bde206efbe 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -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, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 73fb6622716..84df93f05f7 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( + parameters: { + sessionID: string + title?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "title" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/rename", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send message * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ac3abe63895..b009c794928 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 = { + /** + * + */ + 204: void +} + +export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses] + export type V2SessionPromptData = { body: { id?: string diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 602673e1ff7..ea667e7cc2b 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -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() }), diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 398f4bf560f..771ab0eca56 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -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()} diff --git a/packages/tui/src/component/dialog-session-rename.tsx b/packages/tui/src/component/dialog-session-rename.tsx index 9fd8049e1e4..72dfb8b11d6 100644 --- a/packages/tui/src/component/dialog-session-rename.tsx +++ b/packages/tui/src/component/dialog-session-rename.tsx @@ -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 ( { - 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(() => ) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 4fb2677e166..48e51aface6 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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: { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 9e92eb7a89e..7b1c6a974e1 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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": diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index 2f5e4c4ee23..c7c1a7c3733 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -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() }, }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d6f1e96884d..f4d570d443a 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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() { /> )} + + message.id > session()!.revert!.messageID).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 ( 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 }) { > {props.count} message{props.count === 1 ? "" : "s"} reverted - Redo is not implemented for V2 sessions yet + Click to redo ) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index e10a84f9d70..26756a7fcd1 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -23,6 +23,13 @@ export type SessionRow = export function createSessionRows(sessionID: Accessor) { const data = useData() const [rows, setRows] = createStore([]) + 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) { 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) => {