diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 69e45c0ee5e..c83b2ff4b83 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -31,6 +31,7 @@ const clientSettings: ClientSettings = { glassOpacity: 80, phaseGroupedSidebarEnabled: true, planModeEnabled: false, + nativePlanReviewEnabled: true, providerModelPreferences: {}, providerRateLimitsEnabled: true, resourceMonitorEnabled: false, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 0296f4a2321..f042914262f 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -65,6 +65,9 @@ import { } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; +// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summaries. +import { WorkSummaryReactor } from "../src/orchestration/Services/WorkSummaryReactor.ts"; +// T3-CUSTOM(expbkt3): END import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { RuntimeReceiptBus, @@ -377,6 +380,15 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), Layer.provideMerge(catchupSummaryReactorLayer), + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summaries are exercised by + // their own reactor test; this harness only needs the dependency satisfied. + Layer.provideMerge( + Layer.succeed(WorkSummaryReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), + // T3-CUSTOM(expbkt3): END Layer.provideMerge( Layer.succeed(ThreadDeletionReactor, { start: () => Effect.void, diff --git a/apps/server/src/auth/rpcForkScopes.ts b/apps/server/src/auth/rpcForkScopes.ts index f294f3196b8..15655032734 100644 --- a/apps/server/src/auth/rpcForkScopes.ts +++ b/apps/server/src/auth/rpcForkScopes.ts @@ -35,4 +35,18 @@ export const FORK_RPC_REQUIRED_SCOPES = { [WS_FORK_METHODS.usersRevokeSessions]: AuthOrchestrationOperateScope, [WS_FORK_METHODS.usersSourceControlProfileSet]: AuthOrchestrationOperateScope, [WS_FORK_METHODS.linearIssuesResolve]: AuthOrchestrationReadScope, + [WS_FORK_METHODS.planReviewGet]: AuthOrchestrationReadScope, + [WS_FORK_METHODS.planReviewList]: AuthOrchestrationReadScope, + [WS_FORK_METHODS.planReviewVersionDiff]: AuthOrchestrationReadScope, + [WS_FORK_METHODS.subscribePlanReview]: AuthOrchestrationReadScope, + [WS_FORK_METHODS.planReviewSaveDraft]: AuthOrchestrationOperateScope, + [WS_FORK_METHODS.planReviewCutVersion]: AuthOrchestrationOperateScope, + [WS_FORK_METHODS.planReviewUpsertDiscussion]: AuthOrchestrationOperateScope, + [WS_FORK_METHODS.planReviewResolveDiscussion]: AuthOrchestrationOperateScope, + [WS_FORK_METHODS.planReviewSubmit]: AuthOrchestrationOperateScope, + // T3-CUSTOM(expbkt3): archived-session worktree reclaim. Scanning is a read; + // exporting writes files and reclaiming deletes them, so both need operate. + [WS_FORK_METHODS.sessionArchiveScan]: AuthOrchestrationReadScope, + [WS_FORK_METHODS.sessionArchiveExport]: AuthOrchestrationOperateScope, + [WS_FORK_METHODS.sessionArchiveReclaim]: AuthOrchestrationOperateScope, } as const; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 102cc40ac96..abb19234fd0 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -300,6 +300,8 @@ function createTextGeneration( Effect.die("updateRollingSummary is not exercised by GitManager tests"), generateCatchupSummary: () => Effect.die("generateCatchupSummary is not exercised by GitManager tests"), + generateWorkSummary: () => + Effect.die("generateWorkSummary is not exercised by GitManager tests"), ...overrides, }; @@ -353,6 +355,7 @@ function createTextGeneration( Effect.die("updateRollingSummary is not used by git text generation"), generateCatchupSummary: () => Effect.die("generateCatchupSummary is not used by git text generation"), + generateWorkSummary: () => Effect.die("generateWorkSummary is not used by git text generation"), }; } diff --git a/apps/server/src/mcp/toolkits/control/handlers.lineage.test.ts b/apps/server/src/mcp/toolkits/control/handlers.lineage.test.ts new file mode 100644 index 00000000000..9a1ce645b4c --- /dev/null +++ b/apps/server/src/mcp/toolkits/control/handlers.lineage.test.ts @@ -0,0 +1,138 @@ +// T3-CUSTOM(expbkt3): session lineage on t3_create_session. +// +// Covers the full createAsChild matrix: nesting is the agent's call, and the +// implicit default must never fire for a conductor-scoped external-user token. +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, UserId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { OrchestrationAccessControl } from "../../../orchestration/Services/AccessControl.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { T3ControlToolError } from "./tools.ts"; +import { __testing } from "./handlers.ts"; + +const actorUserId = UserId.make("user-lineage"); +const callerThreadId = ThreadId.make("thread-caller"); +const openProjectId = ProjectId.make("project-open"); +const closedProjectId = ProjectId.make("project-closed"); +const openParentId = ThreadId.make("thread-open-parent"); +const closedParentId = ThreadId.make("thread-closed-parent"); + +const threads = [ + { id: callerThreadId, projectId: openProjectId }, + { id: openParentId, projectId: openProjectId }, + { id: closedParentId, projectId: closedProjectId }, +]; + +function makeScope( + overrides: Partial = {}, +): McpInvocationContext.McpInvocationScope { + return { + principal: "provider-session", + actorUserId, + environmentId: EnvironmentId.make("environment-lineage"), + threadId: callerThreadId, + providerSessionId: "provider-session-lineage", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["t3.read", "t3.control", "t3.session.create"]), + issuedAt: 1, + ...overrides, + }; +} + +const accessControl = OrchestrationAccessControl.of({ + actorFor: () => Option.some(actorUserId), + canAccessThread: () => Effect.succeed(true), + canAccessProject: (_userId, projectId) => Effect.succeed(projectId === openProjectId), + canTransferThreadOwnership: () => Effect.succeed(false), + canTransferProjectOwnership: () => Effect.succeed(false), +}); + +const resolve = (input: { + readonly scope?: McpInvocationContext.McpInvocationScope; + readonly createAsChild?: boolean; + readonly parentSessionId?: string; +}) => + __testing + .resolveCreatedSessionParent({ + operation: "create-session", + scope: input.scope ?? makeScope(), + threads, + createAsChild: input.createAsChild, + parentSessionId: input.parentSessionId, + }) + .pipe(Effect.provideService(OrchestrationAccessControl, accessControl)); + +it.effect("nests under the calling session by default", () => + Effect.gen(function* () { + expect(yield* resolve({})).toBe(callerThreadId); + }), +); + +it.effect("nests when createAsChild is explicitly true", () => + Effect.gen(function* () { + expect(yield* resolve({ createAsChild: true })).toBe(callerThreadId); + }), +); + +it.effect("creates a top-level session when createAsChild is false", () => + Effect.gen(function* () { + expect(yield* resolve({ createAsChild: false })).toBe(null); + }), +); + +it.effect("never parents an external-user token to its conductor thread", () => + Effect.gen(function* () { + // scope.threadId here is the user's conductor thread, not a session they + // are working in — defaulting to it would build one giant bogus tree. + const scope = makeScope({ principal: "external-user" }); + expect(yield* resolve({ scope })).toBe(null); + }), +); + +it.effect("never parents an external operator implicitly", () => + Effect.gen(function* () { + const scope = makeScope({ principal: "external-operator" }); + expect(yield* resolve({ scope })).toBe(null); + }), +); + +it.effect("honours an explicit accessible parent", () => + Effect.gen(function* () { + expect(yield* resolve({ parentSessionId: openParentId })).toBe(openParentId); + }), +); + +it.effect("hides a parent whose project the caller cannot access", () => + Effect.gen(function* () { + const error = yield* resolve({ parentSessionId: closedParentId }).pipe(Effect.flip); + expect(error).toBeInstanceOf(T3ControlToolError); + expect(error.message).toBe(`T3 session ${closedParentId} was not found.`); + }), +); + +it.effect("reports an unknown parent as absent", () => + Effect.gen(function* () { + const error = yield* resolve({ parentSessionId: "thread-does-not-exist" }).pipe(Effect.flip); + expect(error).toBeInstanceOf(T3ControlToolError); + }), +); + +it.effect("refuses a contradictory createAsChild:false plus parentSessionId", () => + Effect.gen(function* () { + const error = yield* resolve({ + createAsChild: false, + parentSessionId: openParentId, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(T3ControlToolError); + expect(error.message).toContain("cannot be combined with parentSessionId"); + }), +); + +it.effect("lets an external operator name a parent without an access check", () => + Effect.gen(function* () { + const scope = makeScope({ principal: "external-operator" }); + expect(yield* resolve({ scope, parentSessionId: closedParentId })).toBe(closedParentId); + }), +); diff --git a/apps/server/src/mcp/toolkits/control/handlers.ts b/apps/server/src/mcp/toolkits/control/handlers.ts index ac672b16d44..2d292d1fb2c 100644 --- a/apps/server/src/mcp/toolkits/control/handlers.ts +++ b/apps/server/src/mcp/toolkits/control/handlers.ts @@ -119,6 +119,70 @@ const hasUserWideScope = (scope: McpInvocationContext.McpInvocationScope): boole scope.principal === "external-user" || scope.capabilities.has("t3.session.create"); +/** + * T3-CUSTOM(expbkt3): decide which session a newly created session is filed + * under. + * + * Nesting is the agent's call. A session fanning out cross-repo work wants its + * children visible as a subtree; a session that incidentally files unrelated + * work does not, and burying that session inside an unrelated tree is worse + * than leaving it flat. + * + * createAsChild === false → root session + * parentSessionId given → that session, after existence + access + * caller is a provider session → the calling session + * otherwise → root session + * + * The principal check is load-bearing independently of the flag: an + * external-user token's threadId is that user's *conductor* thread, not a + * session they are working in, so defaulting to it would file every session the + * user creates under one synthetic root. + */ +const resolveCreatedSessionParent = Effect.fn("T3ControlToolkit.resolveCreatedSessionParent")( + function* (input: { + readonly operation: string; + readonly scope: McpInvocationContext.McpInvocationScope; + readonly threads: ReadonlyArray<{ readonly id: ThreadId; readonly projectId: ProjectId }>; + readonly createAsChild: boolean | undefined; + readonly parentSessionId: string | undefined; + }) { + const { operation, scope } = input; + // Two ways to say different things about the same field: refuse rather + // than letting one silently win. + if (input.parentSessionId !== undefined && input.createAsChild === false) { + return yield* new T3ControlToolError({ + operation, + message: + "createAsChild: false cannot be combined with parentSessionId. Omit parentSessionId to create a top-level session.", + }); + } + if (input.createAsChild === false) return null; + + if (input.parentSessionId !== undefined) { + const requestedParentId = ThreadId.make(input.parentSessionId); + const parent = input.threads.find((candidate) => candidate.id === requestedParentId); + // A parent the caller cannot see is reported as absent rather than + // forbidden, matching how this handler already treats projects. + const visible = + parent !== undefined && + (McpInvocationContext.isExternalMcpOperator(scope) || + scope.actorUserId === null || + (yield* (yield* OrchestrationAccessControl) + .canAccessProject(scope.actorUserId, parent.projectId) + .pipe(mapControlError(operation)))); + if (!visible) { + return yield* new T3ControlToolError({ + operation, + message: `T3 session ${requestedParentId} was not found.`, + }); + } + return requestedParentId; + } + + return scope.principal === "provider-session" ? scope.threadId : null; + }, +); + const resolveConfiguredOwnerUserId = Effect.fn("T3ControlToolkit.resolveConfiguredOwnerUserId")( function* (operation: string) { const config = yield* ServerConfig; @@ -237,6 +301,9 @@ function sessionSummary( snoozedUntil: thread.snoozedUntil ?? null, // T3-CUSTOM(expbkt3): session priority (0 = P0 highest, null = unset). priority: thread.priority ?? null, + // T3-CUSTOM(expbkt3): session lineage. Lets an agent inspect its own + // subtree instead of re-spawning work it already delegated. + parentSessionId: thread.parentThreadId ?? null, session: thread.session, execution, needsHumanAttention: reasons.length > 0, @@ -399,6 +466,10 @@ const handlers = { }, execution, humanAttentionReasons: attentionReasons(thread, execution), + // T3-CUSTOM(expbkt3): bulk session manager work summary, hoisted to the + // top level so an agent reading a session does not have to know it lives + // on the thread object. Null when one was never requested. + workSummary: thread.workSummary ?? null, }; }), @@ -681,6 +752,17 @@ const handlers = { }; break; } + // T3-CUSTOM(expbkt3): bulk session manager work summary. Unlike catch-up, + // this summarizes the whole session, so it needs no turn to anchor to and + // works on a session that has never completed a turn. + case "request-work-summary": + command = { + type: "thread.work-summary.request", + commandId, + threadId: sessionId, + createdAt, + }; + break; } const result = yield* dispatcher.dispatch(command).pipe(mapControlError(operation)); return { accepted: true, action: input.action, sessionId, sequence: result.sequence }; @@ -846,6 +928,61 @@ const handlers = { }; }), + // T3-CUSTOM(expbkt3): BEGIN — session lineage, editable after creation so an + // agent can reorganise a workspace it did not lay out itself. + t3_link_session: Effect.fn("T3ControlToolkit.linkSession")(function* (input) { + const operation = "link-session"; + const sessionId = yield* resolveSessionId( + operation, + ThreadId.make(input.sessionId), + "t3.control", + ); + // Read access is the right bar for the parent: the caller is not changing + // it, only pointing at it. resolveSessionId reports an inaccessible session + // as absent, so this leaks nothing. + const parentSessionId = yield* resolveSessionId( + operation, + ThreadId.make(input.parentSessionId), + "t3.read", + ); + const dispatcher = yield* OrchestrationCommandDispatcher; + const crypto = yield* Crypto.Crypto; + const commandId = yield* makeCommandId(crypto, operation); + // The decider owns the tree invariant, so a cycle fails here with its + // message rather than being re-derived (and drifting) in this handler. + const result = yield* dispatcher + .dispatch({ + type: "thread.meta.update", + commandId, + threadId: sessionId, + parentThreadId: parentSessionId, + }) + .pipe(mapControlError(operation)); + return { linked: true, sessionId, parentSessionId, sequence: result.sequence }; + }), + + t3_unlink_session: Effect.fn("T3ControlToolkit.unlinkSession")(function* (input) { + const operation = "unlink-session"; + const sessionId = yield* resolveSessionId( + operation, + ThreadId.make(input.sessionId), + "t3.control", + ); + const dispatcher = yield* OrchestrationCommandDispatcher; + const crypto = yield* Crypto.Crypto; + const commandId = yield* makeCommandId(crypto, operation); + const result = yield* dispatcher + .dispatch({ + type: "thread.meta.update", + commandId, + threadId: sessionId, + parentThreadId: null, + }) + .pipe(mapControlError(operation)); + return { unlinked: true, sessionId, parentSessionId: null, sequence: result.sequence }; + }), + // T3-CUSTOM(expbkt3): END + t3_create_session: Effect.fn("T3ControlToolkit.createSession")(function* (input) { const operation = "create-session"; const scope = yield* requireSessionCreator(operation); @@ -873,6 +1010,14 @@ const handlers = { }); } } + // T3-CUSTOM(expbkt3): session lineage. + const parentThreadId = yield* resolveCreatedSessionParent({ + operation, + scope, + threads: shell.threads, + createAsChild: input.createAsChild, + parentSessionId: input.parentSessionId, + }); const uuid = yield* crypto.randomUUIDv4.pipe(mapControlError(operation)); const sessionId = ThreadId.make(`mcp:${uuid}`); const ownerUserId = @@ -925,6 +1070,8 @@ const handlers = { : {}), sourceControlProfileId: null, priority: input.priority ?? null, + // T3-CUSTOM(expbkt3): session lineage. + parentThreadId, ...(ownerUserId ? { ownerUserId } : {}), createdAt, } as const; @@ -956,6 +1103,8 @@ const handlers = { ...(bootstrapRequest.overrides ? { overrides: bootstrapRequest.overrides } : {}), sourceControlProfileId: null, priority: bootstrapRequest.priority, + // T3-CUSTOM(expbkt3): session lineage. + parentThreadId: bootstrapRequest.parentThreadId, ...(ownerUserId ? { ownerUserId } : {}), createdAt, }, @@ -1118,4 +1267,6 @@ export const __testing = { resolveSessionId, // T3-CUSTOM(expbkt3): caller-seam regression for bounded session list reads. listSessions: handlers.t3_list_sessions, + // T3-CUSTOM(expbkt3): session lineage resolution for created sessions. + resolveCreatedSessionParent, }; diff --git a/apps/server/src/mcp/toolkits/control/tools.lineage.test.ts b/apps/server/src/mcp/toolkits/control/tools.lineage.test.ts new file mode 100644 index 00000000000..42af0551220 --- /dev/null +++ b/apps/server/src/mcp/toolkits/control/tools.lineage.test.ts @@ -0,0 +1,73 @@ +// T3-CUSTOM(expbkt3): session lineage tool surface. +// +// These assertions guard the agent-facing contract rather than the plumbing: +// the two verbs must exist, take the ids they claim to, and — because a tool +// description is the only guidance a calling model gets — actually say when to +// reach for them. +import { expect, it } from "@effect/vitest"; +import { Tool } from "effect/unstable/ai"; + +import { T3ControlToolkit } from "./tools.ts"; + +const jsonSchema = (name: "t3_link_session" | "t3_unlink_session" | "t3_create_session") => + Tool.getJsonSchema(T3ControlToolkit.tools[name]) as { + readonly properties?: Readonly>; + readonly required?: ReadonlyArray; + }; + +/** + * An optional field is emitted as `anyOf: [{...description}, {type: "null"}]`, + * so the description a model actually reads is not at the top level. + */ +const describedText = (schema: unknown): string => { + if (!schema || typeof schema !== "object") return ""; + const record = schema as Record; + const own = typeof record.description === "string" ? record.description : ""; + const nested = [record.anyOf, record.oneOf, record.allOf] + .filter(Array.isArray) + .flatMap((members) => members.map(describedText)); + return [own, ...nested].filter(Boolean).join(" "); +}; + +it("exposes link and unlink as separate verbs", () => { + // Deliberately two tools rather than one nullable field: an agent + // reorganising a workspace must be able to reach "detach" without emitting a + // literal null. + expect(T3ControlToolkit.tools.t3_link_session).toBeDefined(); + expect(T3ControlToolkit.tools.t3_unlink_session).toBeDefined(); +}); + +it("requires both ids to link and only the subject to unlink", () => { + const link = jsonSchema("t3_link_session"); + expect(Object.keys(link.properties ?? {}).toSorted()).toEqual(["parentSessionId", "sessionId"]); + expect(link.required?.toSorted()).toEqual(["parentSessionId", "sessionId"]); + + const unlink = jsonSchema("t3_unlink_session"); + expect(Object.keys(unlink.properties ?? {})).toEqual(["sessionId"]); + expect(unlink.required).toEqual(["sessionId"]); +}); + +it("warns the caller that lineage must stay a tree", () => { + expect(T3ControlToolkit.tools.t3_link_session.description).toMatch(/descendant/i); +}); + +it("tells the caller that unlinking keeps the subtree intact", () => { + // Otherwise an agent may assume detaching a parent orphans its children. + expect(T3ControlToolkit.tools.t3_unlink_session.description).toMatch(/child/i); +}); + +it("offers createAsChild and parentSessionId when creating a session", () => { + const create = jsonSchema("t3_create_session"); + const properties = create.properties ?? {}; + expect(properties.createAsChild).toBeDefined(); + expect(properties.parentSessionId).toBeDefined(); + // Nesting is the default, so neither may be required. + expect(create.required ?? []).not.toContain("createAsChild"); + expect(create.required ?? []).not.toContain("parentSessionId"); +}); + +it("states the default and the reason to override it on createAsChild", () => { + const description = describedText(jsonSchema("t3_create_session").properties?.createAsChild); + expect(description).toMatch(/default/i); + expect(description).toMatch(/false/); +}); diff --git a/apps/server/src/mcp/toolkits/control/tools.ts b/apps/server/src/mcp/toolkits/control/tools.ts index 3bbadbbad3a..931c79871a3 100644 --- a/apps/server/src/mcp/toolkits/control/tools.ts +++ b/apps/server/src/mcp/toolkits/control/tools.ts @@ -235,7 +235,7 @@ export const T3UpdateSessionTool = mutatingTool( export const T3SessionActionTool = mutatingTool( Tool.make("t3_session_action", { description: - "Perform a lifecycle action on a T3 session: interrupt the active turn, stop/restart its provider, archive/unarchive, settle/activate, snooze/unsnooze, delete, or request a fresh catch-up summary.", + "Perform a lifecycle action on a T3 session: interrupt the active turn, stop/restart its provider, archive/unarchive, settle/activate, snooze/unsnooze, delete, request a fresh catch-up summary, or request a fresh work summary and progress estimate.", parameters: Schema.Struct({ ...optionalSessionId, action: described( @@ -251,6 +251,7 @@ export const T3SessionActionTool = mutatingTool( "unsnooze", "delete", "request-catchup", + "request-work-summary", ]), "Lifecycle action to perform. delete is irreversible; snooze additionally requires snoozedUntil.", ), @@ -445,6 +446,22 @@ export const T3CreateSessionTool = mutatingTool( "Optional session priority: 0 (P0, highest) through 4 (P4, lowest). Omit to leave the session unprioritised.", ), ), + // T3-CUSTOM(expbkt3): BEGIN — session lineage. The description is + // load-bearing: it is the only guidance the calling model receives about + // when nesting is the wrong choice. + createAsChild: Schema.optional( + described( + Schema.Boolean, + "Whether this session is filed under yours in the sidebar. Defaults to true, so work you fan out stays visible as your subtree. Pass false when the new session is independent work that should stand on its own at the top level.", + ), + ), + parentSessionId: Schema.optional( + described( + Schema.String, + "Optional explicit parent session ID, for building a tree you are not the root of. Defaults to the calling session. Cannot be combined with createAsChild: false.", + ), + ), + // T3-CUSTOM(expbkt3): END }), success: Schema.Unknown, failure: T3ControlToolError, @@ -544,6 +561,41 @@ export const T3UpdateServerSettingsTool = mutatingTool( }).annotate(Tool.Title, "Update T3 server settings"), ); +// T3-CUSTOM(expbkt3): BEGIN — session lineage as an explicit pair of verbs. +// One tri-state field would be terser, but an agent reorganising a workspace +// has to be able to reach "detach" without emitting a literal null, and a tool +// named for what it does is far likelier to be picked correctly. +export const T3LinkSessionTool = mutatingTool( + Tool.make("t3_link_session", { + description: + "File one T3 session under another, so it renders nested beneath its parent in the sidebar. Use this to organise related work — for example, grouping sessions you fanned out across repositories under the session coordinating them. The session's own children move with it. Lineage must stay a tree: a session cannot be filed under itself or under any of its own descendants.", + parameters: Schema.Struct({ + sessionId: described(Schema.String, "Session to move."), + parentSessionId: described( + Schema.String, + "Session it should be filed under. Must not be the session itself or one of its descendants.", + ), + }), + success: Schema.Unknown, + failure: T3ControlToolError, + dependencies, + }).annotate(Tool.Title, "Link T3 session to a parent"), +); + +export const T3UnlinkSessionTool = mutatingTool( + Tool.make("t3_unlink_session", { + description: + "Detach a T3 session from its parent so it returns to the top level of the sidebar. Its own child sessions stay attached to it and move with it. Safe to call on a session that already has no parent.", + parameters: Schema.Struct({ + sessionId: described(Schema.String, "Session to detach from its parent."), + }), + success: Schema.Unknown, + failure: T3ControlToolError, + dependencies, + }).annotate(Tool.Title, "Unlink T3 session from its parent"), +); +// T3-CUSTOM(expbkt3): END + export const T3ControlToolkit = Toolkit.make( T3ListSessionsTool, T3GetSessionTool, @@ -558,6 +610,9 @@ export const T3ControlToolkit = Toolkit.make( T3CreateProjectTool, T3UpdateProjectTool, T3CreateSessionTool, + // T3-CUSTOM(expbkt3): session lineage. + T3LinkSessionTool, + T3UnlinkSessionTool, T3SubmitPlanTool, T3ListPlannotatorReviewsTool, T3DispatchCommandTool, diff --git a/apps/server/src/mcp/toolkits/webUi/catalog.test.ts b/apps/server/src/mcp/toolkits/webUi/catalog.test.ts index 2dd6271706a..76c456356d0 100644 --- a/apps/server/src/mcp/toolkits/webUi/catalog.test.ts +++ b/apps/server/src/mcp/toolkits/webUi/catalog.test.ts @@ -37,8 +37,8 @@ const invocation = ( }); it("generates one unique virtual tool and complete schemas for every web RPC", () => { - expect(WEB_UI_VIRTUAL_TOOL_COUNT).toBe(102); - expect(WEB_UI_STREAM_TOOL_COUNT).toBe(19); + expect(WEB_UI_VIRTUAL_TOOL_COUNT).toBe(114); + expect(WEB_UI_STREAM_TOOL_COUNT).toBe(20); expect(WEB_UI_VIRTUAL_TOOL_COUNT).toBe(WsRpcGroup.requests.size); expect(new Set(WEB_UI_VIRTUAL_TOOLS.map((tool) => tool.name)).size).toBe( WEB_UI_VIRTUAL_TOOL_COUNT, diff --git a/apps/server/src/mcp/toolkits/webUi/registration.test.ts b/apps/server/src/mcp/toolkits/webUi/registration.test.ts index c775068c4d6..e87eb56edfa 100644 --- a/apps/server/src/mcp/toolkits/webUi/registration.test.ts +++ b/apps/server/src/mcp/toolkits/webUi/registration.test.ts @@ -66,9 +66,9 @@ it.effect("registers four compact tools while listing the complete virtual surfa expect(listed.isError).toBe(false); expect(listed.structuredContent).toMatchObject({ ok: true, - rpcCount: 102, - streamCount: 19, - matchedCount: 102, + rpcCount: 114, + streamCount: 20, + matchedCount: 114, }); const schema = yield* withInvocation( diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 527241758c5..952bd1c1363 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { CatchupSummaryReactor } from "../Services/CatchupSummaryReactor.ts"; +import { WorkSummaryReactor } from "../Services/WorkSummaryReactor.ts"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -24,7 +25,7 @@ describe("OrchestrationReactor", () => { runtime = null; }); - it("starts provider ingestion, provider command, checkpoint, catch-up summary, and thread deletion reactors", async () => { + it("starts provider ingestion, provider command, checkpoint, catch-up summary, work summary, and thread deletion reactors", async () => { const started: string[] = []; runtime = ManagedRuntime.make( @@ -65,6 +66,15 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(WorkSummaryReactor, { + start: () => { + started.push("work-summary-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(ThreadDeletionReactor, { start: () => { @@ -95,6 +105,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "catchup-summary-reactor", + "work-summary-reactor", "thread-deletion-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index c3b736b4c56..05229c6aa02 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -6,6 +6,9 @@ import { type OrchestrationReactorShape, } from "../Services/OrchestrationReactor.ts"; import { CatchupSummaryReactor } from "../Services/CatchupSummaryReactor.ts"; +// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summaries. +import { WorkSummaryReactor } from "../Services/WorkSummaryReactor.ts"; +// T3-CUSTOM(expbkt3): END import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -17,6 +20,9 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const catchupSummaryReactor = yield* CatchupSummaryReactor; + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summaries. + const workSummaryReactor = yield* WorkSummaryReactor; + // T3-CUSTOM(expbkt3): END const threadDeletionReactor = yield* ThreadDeletionReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; @@ -25,6 +31,9 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* catchupSummaryReactor.start(); + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summaries. + yield* workSummaryReactor.start(); + // T3-CUSTOM(expbkt3): END yield* threadDeletionReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7e3bb3822b8..8246e0a82f3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,9 +1,15 @@ import { ApprovalRequestId, type ChatAttachment, + // T3-CUSTOM(expbkt3): BEGIN — work summary supersede support. + type CommandId, + // T3-CUSTOM(expbkt3): END type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, + // T3-CUSTOM(expbkt3): BEGIN — durable bulk-session-manager work summary. + ThreadWorkSummary, + // T3-CUSTOM(expbkt3): END } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -11,6 +17,9 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; +// T3-CUSTOM(expbkt3): BEGIN — encodes the durable work-summary blob. +import * as Schema from "effect/Schema"; +// T3-CUSTOM(expbkt3): END import * as SqlClient from "effect/unstable/sql/SqlClient"; import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; @@ -87,6 +96,33 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { type ProjectorName = (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES]; +/** + * T3-CUSTOM(expbkt3): BEGIN — work summary supersede support. + * + * Reads just the `requestId` out of a stored work-summary blob. A row written + * by an older build, or corrupted by hand, must not wedge the feature: an + * unreadable value reports "no owning request", which lets the next result + * through instead of rejecting every one of them forever. + */ +const encodeWorkSummary = Schema.encodeSync(Schema.fromJsonString(ThreadWorkSummary)); + +function parseWorkSummaryRequestId(raw: string | null | undefined): CommandId | null { + if (raw === null || raw === undefined || raw.length === 0) { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) { + return null; + } + const requestId = (parsed as { readonly requestId?: unknown }).requestId; + return typeof requestId === "string" ? (requestId as CommandId) : null; + } catch { + return null; + } +} +// T3-CUSTOM(expbkt3): END + /** * Turn state to settle still-running turns with when their session leaves the * "running" status, or null while the session is (re)starting or running and @@ -712,6 +748,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti priority: event.payload.priority ?? null, // T3-CUSTOM(expbkt3): no manual Linear tag at thread creation. linearIssueUrl: null, + // T3-CUSTOM(expbkt3): session lineage stamped at creation. + parentThreadId: event.payload.parentThreadId ?? null, + // T3-CUSTOM(expbkt3): BEGIN — no work summary until one is requested. + workSummary: null, + // T3-CUSTOM(expbkt3): END pinnedAt: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, @@ -939,6 +980,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.linearIssueUrl !== undefined ? { linearIssueUrl: event.payload.linearIssueUrl } : {}), + // T3-CUSTOM(expbkt3): session lineage re-parent / detach. + ...(event.payload.parentThreadId !== undefined + ? { parentThreadId: event.payload.parentThreadId } + : {}), updatedAt: event.payload.updatedAt, }); return; @@ -1082,6 +1127,58 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + // T3-CUSTOM(expbkt3): BEGIN — durable bulk-session-manager work summary. + // + // The request event installs the pending record so a reconnecting table + // still shows the spinner; the update event replaces it with the + // result. Both are stored as one JSON blob in `work_summary` rather + // than a column per field, because the whole record is written and read + // atomically and never queried field-wise. + case "thread.work-summary-requested": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + workSummary: encodeWorkSummary({ + status: "pending", + summary: null, + stage: null, + remaining: null, + percent: null, + error: null, + requestId: event.payload.requestId, + updatedAt: event.payload.requestedAt, + }), + }); + return; + } + + case "thread.work-summary-updated": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + // Supersede rule: only the request that currently owns the row may + // write its result. A re-request installs a new pending requestId, so + // the older generation's late answer is dropped here. + const currentRequestId = parseWorkSummaryRequestId(existingRow.value.workSummary); + if (currentRequestId !== null && currentRequestId !== event.payload.requestId) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + workSummary: encodeWorkSummary(event.payload.workSummary), + }); + return; + } + // T3-CUSTOM(expbkt3): END + case "thread.reverted": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.workSummary.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.workSummary.test.ts new file mode 100644 index 00000000000..fd6cb65336e --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.workSummary.test.ts @@ -0,0 +1,290 @@ +// T3-CUSTOM(expbkt3): bulk session manager work summary projection coverage. +// +// Covers both halves of the durable path in one harness, because they are only +// meaningful together: the pipeline writes `projection_threads.work_summary` +// and `ProjectionSnapshotQuery` is the only thing that reads it back into a +// shell. Testing either alone would pass while the pair was broken. +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type ThreadWorkSummary, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ServerConfig } from "../../config.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; +import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; + +const CREATED_AT = "2026-02-01T00:00:00.000Z"; + +// The suite shares one in-memory database and the engine dedupes by command id, +// so every case works on its own thread with its own command ids. Reusing them +// would silently replay an earlier case's receipts instead of failing. +const projectIdFor = (suffix: string) => ProjectId.make(`project-work-summary-${suffix}`); +const threadIdFor = (suffix: string) => ThreadId.make(`thread-work-summary-${suffix}`); +const requestIdFor = (suffix: string, index: number) => + CommandId.make(`cmd-work-summary-request-${suffix}-${index}`); + +function readySummary(requestId: CommandId, summary: string): ThreadWorkSummary { + return { + status: "ready", + summary, + stage: "implementing", + remaining: "Finish the projector", + percent: 60, + error: null, + requestId, + updatedAt: "2026-02-01T00:05:00.000Z", + }; +} + +const seed = Effect.fn("seedWorkSummaryThread")(function* (suffix: string) { + const engine = yield* OrchestrationEngineService; + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`cmd-work-summary-project-${suffix}`), + projectId: projectIdFor(suffix), + title: "Work summary project", + workspaceRoot: `/tmp/project-work-summary-${suffix}`, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt: CREATED_AT, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-work-summary-thread-${suffix}`), + threadId: threadIdFor(suffix), + projectId: projectIdFor(suffix), + title: "Work summary thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + sourceControlProfileId: null, + createdAt: CREATED_AT, + }); +}); + +const readStoredColumn = (threadId: ThreadId) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly work_summary: string | null }>` + SELECT work_summary FROM projection_threads WHERE thread_id = ${threadId} + `; + return rows[0]?.work_summary ?? null; + }); + +const readShell = (threadId: ThreadId) => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const shell = yield* snapshotQuery.getThreadShellById(threadId); + return Option.getOrNull(shell); + }); + +const layer = it.layer( + OrchestrationEngineLive.pipe( + Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolver.layer), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-work-summary-projection-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), +); + +layer("ProjectionPipeline work summary", (it) => { + it.effect("leaves a fresh thread with no work summary at all", () => + Effect.gen(function* () { + const threadId = threadIdFor("fresh"); + yield* seed("fresh"); + + assert.strictEqual(yield* readStoredColumn(threadId), null); + // Distinguishable from "generated and empty": the table renders a dash. + assert.strictEqual((yield* readShell(threadId))?.workSummary ?? null, null); + }), + ); + + it.effect("installs a pending record from the request event alone", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const threadId = threadIdFor("pending"); + const requestId = requestIdFor("pending", 1); + yield* seed("pending"); + + yield* engine.dispatch({ + type: "thread.work-summary.request", + commandId: requestId, + threadId, + createdAt: CREATED_AT, + }); + + // The reactor has not run yet; the spinner must already be visible. + const shell = yield* readShell(threadId); + assert.strictEqual(shell?.workSummary?.status, "pending"); + assert.strictEqual(shell?.workSummary?.requestId, requestId); + assert.strictEqual(shell?.workSummary?.summary, null); + assert.strictEqual(shell?.workSummary?.percent, null); + }), + ); + + it.effect("persists a ready result and exposes it on every shell read", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const snapshotQuery = yield* ProjectionSnapshotQuery; + const threadId = threadIdFor("ready"); + const requestId = requestIdFor("ready", 1); + yield* seed("ready"); + + yield* engine.dispatch({ + type: "thread.work-summary.request", + commandId: requestId, + threadId, + createdAt: CREATED_AT, + }); + yield* engine.dispatch({ + type: "thread.work-summary.update", + commandId: CommandId.make("cmd-work-summary-update-ready"), + threadId, + requestId, + workSummary: readySummary(requestId, "Built the projection path."), + createdAt: "2026-02-01T00:05:00.000Z", + }); + + assert.isNotNull(yield* readStoredColumn(threadId)); + + const shell = yield* readShell(threadId); + assert.strictEqual(shell?.workSummary?.status, "ready"); + assert.strictEqual(shell?.workSummary?.summary, "Built the projection path."); + assert.strictEqual(shell?.workSummary?.stage, "implementing"); + assert.strictEqual(shell?.workSummary?.remaining, "Finish the projector"); + assert.strictEqual(shell?.workSummary?.percent, 60); + + // The bulk table reads the list snapshot, not the single-thread read. + const snapshot = yield* snapshotQuery.getShellSnapshot(); + const listed = snapshot.threads.find((thread) => thread.id === threadId); + assert.strictEqual(listed?.workSummary?.summary, "Built the projection path."); + + // Thread detail carries the same record for the session view and MCP. + const detail = yield* snapshotQuery.getThreadDetailById(threadId); + assert.strictEqual( + Option.getOrNull(detail)?.workSummary?.summary, + "Built the projection path.", + ); + }), + ); + + it.effect("drops a result whose request was superseded by a newer one", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const threadId = threadIdFor("supersede"); + const firstRequest = requestIdFor("supersede", 1); + const secondRequest = requestIdFor("supersede", 2); + yield* seed("supersede"); + + yield* engine.dispatch({ + type: "thread.work-summary.request", + commandId: firstRequest, + threadId, + createdAt: CREATED_AT, + }); + // Operator re-selects the row while the first generation is still running. + yield* engine.dispatch({ + type: "thread.work-summary.request", + commandId: secondRequest, + threadId, + createdAt: "2026-02-01T00:01:00.000Z", + }); + // The first generation finishes late. + yield* engine.dispatch({ + type: "thread.work-summary.update", + commandId: CommandId.make("cmd-work-summary-update-stale"), + threadId, + requestId: firstRequest, + workSummary: readySummary(firstRequest, "Stale answer."), + createdAt: "2026-02-01T00:05:00.000Z", + }); + + const superseded = yield* readShell(threadId); + assert.strictEqual(superseded?.workSummary?.status, "pending"); + assert.strictEqual(superseded?.workSummary?.requestId, secondRequest); + + // The owning request still lands. + yield* engine.dispatch({ + type: "thread.work-summary.update", + commandId: CommandId.make("cmd-work-summary-update-current"), + threadId, + requestId: secondRequest, + workSummary: readySummary(secondRequest, "Current answer."), + createdAt: "2026-02-01T00:06:00.000Z", + }); + const current = yield* readShell(threadId); + assert.strictEqual(current?.workSummary?.status, "ready"); + assert.strictEqual(current?.workSummary?.summary, "Current answer."); + }), + ); + + it.effect("persists an error result so a reconnecting table stops spinning", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const threadId = threadIdFor("error"); + const requestId = requestIdFor("error", 1); + yield* seed("error"); + + yield* engine.dispatch({ + type: "thread.work-summary.request", + commandId: requestId, + threadId, + createdAt: CREATED_AT, + }); + yield* engine.dispatch({ + type: "thread.work-summary.update", + commandId: CommandId.make("cmd-work-summary-update-error"), + threadId, + requestId, + workSummary: { + status: "error", + summary: null, + stage: null, + remaining: null, + percent: null, + error: "summarizer unavailable in test", + requestId, + updatedAt: "2026-02-01T00:05:00.000Z", + }, + createdAt: "2026-02-01T00:05:00.000Z", + }); + + const shell = yield* readShell(threadId); + assert.strictEqual(shell?.workSummary?.status, "error"); + assert.strictEqual(shell?.workSummary?.error, "summarizer unavailable in test"); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d6ba6670496..3c38f454e6b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -334,6 +334,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { priority: null, // T3-CUSTOM(expbkt3): no manual Linear tag on this fixture. linearIssueUrl: null, + // T3-CUSTOM(expbkt3): session lineage. + parentThreadId: null, + // T3-CUSTOM(expbkt3): no work summary was ever requested here. + workSummary: null, pinnedAt: null, titleRegeneration: null, deletedAt: null, @@ -470,6 +474,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { priority: null, // T3-CUSTOM(expbkt3): no manual Linear tag on this fixture. linearIssueUrl: null, + // T3-CUSTOM(expbkt3): session lineage. + parentThreadId: null, + // T3-CUSTOM(expbkt3): no work summary was ever requested here. + workSummary: null, pinnedAt: null, titleRegeneration: null, session: { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index cfb8c69c36b..0647e783509 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -15,6 +15,9 @@ import { ProjectThreadCreationDefaults, ResolvedThreadBootstrapRequest, ThreadBootstrapProgress, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + ThreadWorkSummary, + // T3-CUSTOM(expbkt3): END ProjectScript, TurnId, type OrchestrationCheckpointSummary, @@ -339,6 +342,27 @@ function mapTitleRegeneration(row: Schema.Schema.Type, ): OrchestrationSession { @@ -556,6 +580,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + parent_thread_id AS "parentThreadId", + -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob). + work_summary AS "workSummary", + -- T3-CUSTOM(expbkt3): END pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", @@ -635,6 +663,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + parent_thread_id AS "parentThreadId", + -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob). + work_summary AS "workSummary", + -- T3-CUSTOM(expbkt3): END pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", @@ -677,6 +709,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + parent_thread_id AS "parentThreadId", + -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob). + work_summary AS "workSummary", + -- T3-CUSTOM(expbkt3): END pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", @@ -1124,6 +1160,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + parent_thread_id AS "parentThreadId", + -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob). + work_summary AS "workSummary", + -- T3-CUSTOM(expbkt3): END pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", @@ -1187,6 +1227,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + parent_thread_id AS "parentThreadId", + -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob). + work_summary AS "workSummary", + -- T3-CUSTOM(expbkt3): END pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", @@ -2016,6 +2060,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: row.snoozedAt, priority: row.priority, linearIssueUrl: row.linearIssueUrl ?? null, + parentThreadId: row.parentThreadId ?? null, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + workSummary: mapWorkSummary(row.workSummary), + // T3-CUSTOM(expbkt3): END pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, @@ -2323,6 +2371,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: row.snoozedAt, priority: row.priority, linearIssueUrl: row.linearIssueUrl ?? null, + parentThreadId: row.parentThreadId ?? null, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + workSummary: mapWorkSummary(row.workSummary), + // T3-CUSTOM(expbkt3): END pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, @@ -2497,6 +2549,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: row.snoozedAt, priority: row.priority, linearIssueUrl: row.linearIssueUrl ?? null, + parentThreadId: row.parentThreadId ?? null, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + workSummary: mapWorkSummary(row.workSummary), + // T3-CUSTOM(expbkt3): END pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, @@ -2677,6 +2733,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: row.snoozedAt, priority: row.priority, linearIssueUrl: row.linearIssueUrl ?? null, + parentThreadId: row.parentThreadId ?? null, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + workSummary: mapWorkSummary(row.workSummary), + // T3-CUSTOM(expbkt3): END pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, @@ -2997,6 +3057,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: threadRow.value.snoozedAt, priority: threadRow.value.priority, linearIssueUrl: threadRow.value.linearIssueUrl ?? null, + parentThreadId: threadRow.value.parentThreadId ?? null, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + workSummary: mapWorkSummary(threadRow.value.workSummary), + // T3-CUSTOM(expbkt3): END pinnedAt: threadRow.value.pinnedAt, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, @@ -3201,6 +3265,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: threadRow.value.snoozedAt, priority: threadRow.value.priority, linearIssueUrl: threadRow.value.linearIssueUrl ?? null, + parentThreadId: threadRow.value.parentThreadId ?? null, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + workSummary: mapWorkSummary(threadRow.value.workSummary), + // T3-CUSTOM(expbkt3): END pinnedAt: threadRow.value.pinnedAt, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, diff --git a/apps/server/src/orchestration/Layers/WorkSummaryReactor.test.ts b/apps/server/src/orchestration/Layers/WorkSummaryReactor.test.ts new file mode 100644 index 00000000000..b8138eba553 --- /dev/null +++ b/apps/server/src/orchestration/Layers/WorkSummaryReactor.test.ts @@ -0,0 +1,366 @@ +// T3-CUSTOM(expbkt3): bulk session manager work summary reactor coverage. +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + MessageId, + ProjectId, + ProviderInstanceId, + TextGenerationError, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { ServerConfig } from "../../config.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { WorkSummaryReactor } from "../Services/WorkSummaryReactor.ts"; +import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; +import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import { WorkSummaryReactorLive } from "./WorkSummaryReactor.ts"; + +const PROJECT_ID = ProjectId.make("project-work-summary-reactor"); +const THREAD_ID = ThreadId.make("thread-work-summary-reactor"); +const ARCHIVED_THREAD_ID = ThreadId.make("thread-work-summary-archived"); +const TURN_ID = TurnId.make("turn-work-summary-reactor"); +const USER_MESSAGE_ID = MessageId.make("message-user-work-summary"); +const ASSISTANT_MESSAGE_ID = MessageId.make("message-assistant-work-summary"); +const CREATED_AT = "2026-03-01T00:00:00.000Z"; + +const GENERATED = { + summary: "Added the projection column and wired the reactor.", + stage: "implementing" as const, + remaining: "Ship the client table", + percent: 55, +}; + +const makeHarness = (options: { + readonly enabled?: boolean; + readonly fail?: boolean; + /** Completed by the stub when the first generation starts. */ + readonly firstCallStarted?: Deferred.Deferred; + /** Awaited by the first generation, so the test controls the queue window. */ + readonly releaseFirstCall?: Deferred.Deferred; +}) => + Effect.gen(function* () { + const generateCalls = yield* Ref.make(0); + + const textGeneration = { + generateWorkSummary: () => + Ref.update(generateCalls, (count) => count + 1).pipe( + Effect.andThen( + Effect.gen(function* () { + if (options.firstCallStarted === undefined) { + return; + } + const alreadyStarted = yield* Deferred.isDone(options.firstCallStarted); + if (alreadyStarted) { + return; + } + yield* Deferred.done(options.firstCallStarted, Exit.void); + if (options.releaseFirstCall !== undefined) { + yield* Deferred.await(options.releaseFirstCall); + } + }), + ), + Effect.andThen( + options.fail + ? Effect.fail( + new TextGenerationError({ + operation: "generateWorkSummary", + detail: "summarizer unavailable in test", + }), + ) + : Effect.succeed(GENERATED), + ), + ), + } as unknown as TextGeneration["Service"]; + + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolver.layer), + ); + + const reactorLayer = WorkSummaryReactorLive.pipe( + Layer.provideMerge(orchestrationLayer), + Layer.provide(Layer.succeed(TextGeneration, textGeneration)), + Layer.provide( + ServerSettingsService.layerTest({ + experimental: { + sessionWorkSummary: { + enabled: options.enabled ?? true, + }, + }, + }), + ), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-work-summary-reactor-test-" }), + ), + Layer.provide(NodeServices.layer), + ); + + return { reactorLayer, generateCalls }; + }); + +const seedThread = Effect.fn("seedThread")(function* () { + const engine = yield* OrchestrationEngineService; + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project-create-work-summary"), + projectId: PROJECT_ID, + title: "Work summary project", + workspaceRoot: process.cwd(), + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt: CREATED_AT, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-work-summary"), + threadId: THREAD_ID, + projectId: PROJECT_ID, + title: "Work summary thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: process.cwd(), + sourceControlProfileId: null, + createdAt: CREATED_AT, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-work-summary"), + threadId: THREAD_ID, + message: { + messageId: USER_MESSAGE_ID, + role: "user", + text: "Build the bulk session manager", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: CREATED_AT, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-assistant-delta-work-summary"), + threadId: THREAD_ID, + messageId: ASSISTANT_MESSAGE_ID, + delta: "Added the projection column.", + turnId: TURN_ID, + createdAt: CREATED_AT, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-assistant-complete-work-summary"), + threadId: THREAD_ID, + messageId: ASSISTANT_MESSAGE_ID, + turnId: TURN_ID, + createdAt: CREATED_AT, + }); +}); + +const requestWorkSummary = (commandId: string) => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.work-summary.request", + commandId: CommandId.make(commandId), + threadId: THREAD_ID, + createdAt: CREATED_AT, + }); + }); + +const readWorkSummary = Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const snapshot = yield* snapshotQuery.getSnapshot(); + return snapshot.threads.find((thread) => thread.id === THREAD_ID)?.workSummary ?? null; +}); + +const layer = it.layer(SqlitePersistenceMemory); + +layer("WorkSummaryReactor", (it) => { + it.effect("writes a ready work summary with the assigned progress", () => + Effect.gen(function* () { + const harness = yield* makeHarness({}); + + yield* Effect.gen(function* () { + const reactor = yield* WorkSummaryReactor; + yield* reactor.start(); + yield* seedThread(); + yield* requestWorkSummary("cmd-work-summary-ready"); + yield* reactor.drain; + + assert.strictEqual(yield* Ref.get(harness.generateCalls), 1); + const workSummary = yield* readWorkSummary; + assert.strictEqual(workSummary?.status, "ready"); + assert.strictEqual(workSummary?.summary, GENERATED.summary); + assert.strictEqual(workSummary?.stage, GENERATED.stage); + assert.strictEqual(workSummary?.remaining, GENERATED.remaining); + assert.strictEqual(workSummary?.percent, GENERATED.percent); + assert.strictEqual(workSummary?.error, null); + }).pipe(Effect.provide(harness.reactorLayer), Effect.scoped); + }), + ); + + it.effect("replaces the spinner with an error when generation fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ fail: true }); + + yield* Effect.gen(function* () { + const reactor = yield* WorkSummaryReactor; + yield* reactor.start(); + yield* seedThread(); + yield* requestWorkSummary("cmd-work-summary-failure"); + yield* reactor.drain; + + const workSummary = yield* readWorkSummary; + assert.strictEqual(workSummary?.status, "error"); + assert.strictEqual(workSummary?.error, "summarizer unavailable in test"); + assert.strictEqual(workSummary?.summary, null); + }).pipe(Effect.provide(harness.reactorLayer), Effect.scoped); + }), + ); + + it.effect("reports the disabled feature instead of leaving the row spinning", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ enabled: false }); + + yield* Effect.gen(function* () { + const reactor = yield* WorkSummaryReactor; + yield* reactor.start(); + yield* seedThread(); + yield* requestWorkSummary("cmd-work-summary-disabled"); + yield* reactor.drain; + + // No tokens spent, but the pending marker must still be resolved. + assert.strictEqual(yield* Ref.get(harness.generateCalls), 0); + const workSummary = yield* readWorkSummary; + assert.strictEqual(workSummary?.status, "error"); + assert.include(workSummary?.error ?? "", "turned off"); + }).pipe(Effect.provide(harness.reactorLayer), Effect.scoped); + }), + ); + + // Regression: on expbkt3 an archived session left the table spinning on + // "Summarizing…" forever. Archived threads are absent from the detail read + // model, and the reactor returned quietly after the projector had already + // written the pending marker. + // + // Uses its own thread: every test in this layer shares one in-memory database, + // so archiving THREAD_ID here would strand the later concurrency test. + it.effect("reports an archived session instead of leaving the row spinning", () => + Effect.gen(function* () { + const harness = yield* makeHarness({}); + + yield* Effect.gen(function* () { + const reactor = yield* WorkSummaryReactor; + yield* reactor.start(); + yield* seedThread(); + + const engine = yield* OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-work-summary-archived"), + threadId: ARCHIVED_THREAD_ID, + projectId: PROJECT_ID, + title: "Archived work summary thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: process.cwd(), + sourceControlProfileId: null, + createdAt: CREATED_AT, + }); + yield* engine.dispatch({ + type: "thread.archive", + commandId: CommandId.make("cmd-archive-work-summary"), + threadId: ARCHIVED_THREAD_ID, + }); + + yield* engine.dispatch({ + type: "thread.work-summary.request", + commandId: CommandId.make("cmd-work-summary-archived"), + threadId: ARCHIVED_THREAD_ID, + createdAt: CREATED_AT, + }); + yield* reactor.drain; + + // No tokens spent, but the spinner must still be replaced. + assert.strictEqual(yield* Ref.get(harness.generateCalls), 0); + // Archived threads only appear in the archived snapshot — which is + // exactly the list the manager page reads when "Archived" is toggled on. + const snapshotQuery = yield* ProjectionSnapshotQuery; + const archived = yield* snapshotQuery.getArchivedShellSnapshot(); + const workSummary = + archived.threads.find((thread) => thread.id === ARCHIVED_THREAD_ID)?.workSummary ?? null; + assert.strictEqual(workSummary?.status, "error"); + assert.include(workSummary?.error ?? "", "archived"); + }).pipe(Effect.provide(harness.reactorLayer), Effect.scoped); + }), + ); + + it.effect("skips queued duplicates and keeps only the newest request's result", () => + Effect.gen(function* () { + const firstCallStarted = yield* Deferred.make(); + const releaseFirstCall = yield* Deferred.make(); + const harness = yield* makeHarness({ firstCallStarted, releaseFirstCall }); + + yield* Effect.gen(function* () { + const reactor = yield* WorkSummaryReactor; + yield* reactor.start(); + yield* seedThread(); + + // Hold the first generation open so the next two requests are certainly + // queued behind it — the shape a bulk selection produces in production. + yield* requestWorkSummary("cmd-work-summary-dup-1"); + yield* Deferred.await(firstCallStarted); + yield* requestWorkSummary("cmd-work-summary-dup-2"); + yield* requestWorkSummary("cmd-work-summary-dup-3"); + yield* Deferred.done(releaseFirstCall, Exit.void); + yield* reactor.drain; + + // Three requests, two model calls: the queued middle request is skipped + // because the row already belonged to a newer one by the time the + // worker reached it. The first was already running and cannot be taken + // back, but its result is discarded below. + assert.strictEqual(yield* Ref.get(harness.generateCalls), 2); + + const workSummary = yield* readWorkSummary; + assert.strictEqual(workSummary?.status, "ready"); + assert.strictEqual(workSummary?.requestId, "cmd-work-summary-dup-3"); + }).pipe(Effect.provide(harness.reactorLayer), Effect.scoped); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/WorkSummaryReactor.ts b/apps/server/src/orchestration/Layers/WorkSummaryReactor.ts new file mode 100644 index 00000000000..efb01fca1d7 --- /dev/null +++ b/apps/server/src/orchestration/Layers/WorkSummaryReactor.ts @@ -0,0 +1,334 @@ +/** + * T3-CUSTOM(expbkt3): Bulk session manager work summary reactor. + * + * Consumes `thread.work-summary-requested`, renders the session's context, + * asks the configured model for a work summary plus an assigned progress, and + * dispatches the result back as `thread.work-summary.update`. + * + * Every failure path still dispatches a terminal update. A row in the bulk + * table shows a spinner from the moment the request is projected, so a request + * that silently gives up is indistinguishable from one still running — the + * operator would wait forever on a session that will never answer. + */ +import { + CommandId, + TextGenerationError, + type OrchestrationEvent, + type OrchestrationThread, + type SessionWorkSummarySettings, + type ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import * as TextGeneration from "../../textGeneration/TextGeneration.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { + WorkSummaryReactor, + type WorkSummaryReactorShape, +} from "../Services/WorkSummaryReactor.ts"; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +/** A bulk run must not be held up indefinitely by one wedged provider call. */ +const WORK_SUMMARY_TIMEOUT = "120 seconds"; + +const WORK_SUMMARY_ERROR_FALLBACK = + "The summarizer did not return a result. Check the selected model and try again."; +const WORK_SUMMARY_DISABLED_MESSAGE = + "Session work summaries are turned off in Settings → Experiments."; +const WORK_SUMMARY_EMPTY_SESSION_MESSAGE = "This session has no conversation to summarize yet."; +/** + * An archived session is not in the detail read model and its worktree may be + * reclaimed, so there is nothing to summarize. Say so instead of returning + * quietly: the projector already wrote a `pending` marker, and only a terminal + * update clears the table's "Summarizing…" spinner. + */ +const WORK_SUMMARY_NO_CONTEXT_MESSAGE = + "This session is archived, so it is no longer available to summarize."; +const MAX_WORK_SUMMARY_ERROR_CHARS = 500; + +const isTextGenerationError = Schema.is(TextGenerationError); + +/** Leaves room for the prompt's own rules inside the configured budget. */ +const CONTEXT_HEADROOM_CHARS = 2_000; + +/** + * Keep provider failures useful in the table cell without forwarding defects, + * stack traces, or arbitrarily large CLI output to the browser. + */ +export function workSummaryFailureMessage(cause: Cause.Cause): string { + const failure = Cause.findErrorOption(cause); + const detail = + Option.isSome(failure) && isTextGenerationError(failure.value) + ? failure.value.detail.trim() + : ""; + return detail.length > 0 + ? detail.slice(0, MAX_WORK_SUMMARY_ERROR_CHARS) + : WORK_SUMMARY_ERROR_FALLBACK; +} + +/** + * Render the whole session as a plain transcript for the summarizer. + * + * The rolling catch-up summary is included when the catch-up pipeline happens + * to have produced one — it is a cheap, already-condensed record of the early + * turns — but nothing here depends on it: a session with catch-up summaries + * disabled still summarizes correctly from its transcript alone. + * + * The tail is kept rather than the head. What a session most recently did + * decides its stage and percentage; how it opened is usually restated in the + * title anyway. + */ +export function buildSessionContext(thread: OrchestrationThread, dataLimitChars: number): string { + const transcriptBudget = Math.max(1_000, dataLimitChars - CONTEXT_HEADROOM_CHARS); + + const transcript = thread.messages + .filter((message) => message.text.trim().length > 0) + .map((message) => `${message.role}: ${message.text}`) + .join("\n\n"); + + const boundedTranscript = + transcript.length <= transcriptBudget + ? transcript + : `[earlier turns truncated]\n\n${transcript.slice(transcript.length - transcriptBudget)}`; + + return [ + `Session title: ${thread.title}`, + `Session state: ${thread.archivedAt !== null ? "archived" : "active"}; latest turn ${ + thread.latestTurn?.state ?? "none" + }`, + "", + ...(thread.rollingSummary !== null && thread.rollingSummary.trim().length > 0 + ? ["Condensed history of earlier turns:", thread.rollingSummary.trim(), ""] + : []), + "Transcript:", + boundedTranscript.length > 0 ? boundedTranscript : "(no messages yet)", + ].join("\n"); +} + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const serverCommandId = (tag: string) => + crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const serverSettingsService = yield* ServerSettingsService; + const textGeneration = yield* TextGeneration.TextGeneration; + + /** + * Threads whose generation is currently running. A duplicate request for a + * thread already in flight is dropped rather than queued: the worker is + * serial, so a bulk double-selection reaches this guard only when the same + * thread is already being answered, and the queued newer request resolves the + * row when the worker reaches it. + */ + const inFlightThreadIds = new Set(); + + const dispatchUpdate = Effect.fn("dispatchWorkSummaryUpdate")(function* (input: { + readonly threadId: ThreadId; + readonly requestId: CommandId; + readonly result: + | { + readonly status: "ready"; + readonly summary: string; + readonly stage: "planning" | "implementing" | "blocked" | "awaiting-review" | "done"; + readonly remaining: string; + readonly percent: number; + } + | { readonly status: "error"; readonly error: string }; + }) { + const updatedAt = yield* nowIso; + yield* orchestrationEngine.dispatch({ + type: "thread.work-summary.update", + commandId: yield* serverCommandId("work-summary"), + threadId: input.threadId, + requestId: input.requestId, + workSummary: + input.result.status === "ready" + ? { + status: "ready", + summary: input.result.summary, + stage: input.result.stage, + remaining: input.result.remaining, + percent: input.result.percent, + error: null, + requestId: input.requestId, + updatedAt, + } + : { + status: "error", + summary: null, + stage: null, + remaining: null, + percent: null, + error: input.result.error, + requestId: input.requestId, + updatedAt, + }, + createdAt: updatedAt, + }); + }); + + const summarizeThread = Effect.fn("summarizeThreadWork")(function* (input: { + readonly threadId: ThreadId; + readonly requestId: CommandId; + }) { + const settings = yield* serverSettingsService.getSettings; + const workSummary: SessionWorkSummarySettings = settings.experimental.sessionWorkSummary; + + // Report the disabled state instead of returning quietly: the request event + // already put a spinner on the row, and only a terminal update clears it. + if (!workSummary.enabled) { + yield* dispatchUpdate({ + threadId: input.threadId, + requestId: input.requestId, + result: { status: "error", error: WORK_SUMMARY_DISABLED_MESSAGE }, + }); + return; + } + + // Archived threads are excluded from the detail read, so this is the path a + // request against an archived session takes. The projector has already put a + // spinner on the row, so resolve it: returning quietly here left archived + // rows spinning on "Summarizing…" forever on expbkt3. + const threadOption = yield* projectionSnapshotQuery.getThreadDetailById(input.threadId); + if (Option.isNone(threadOption)) { + yield* dispatchUpdate({ + threadId: input.threadId, + requestId: input.requestId, + result: { status: "error", error: WORK_SUMMARY_NO_CONTEXT_MESSAGE }, + }); + return; + } + const thread = threadOption.value; + + // Collapse a bulk double-selection. The projector installs the newest + // request id as the pending marker the moment its event lands, so by the + // time the queue reaches an older request the row already belongs to a + // newer one. Generating anyway would spend a second model call on the same + // session state and produce a result the projector would then discard. + const currentRequestId = thread.workSummary?.requestId ?? null; + if (currentRequestId !== null && currentRequestId !== input.requestId) { + return; + } + + const contextOption = yield* projectionSnapshotQuery.getThreadCheckpointContext(input.threadId); + if (Option.isNone(contextOption)) { + yield* dispatchUpdate({ + threadId: input.threadId, + requestId: input.requestId, + result: { status: "error", error: WORK_SUMMARY_NO_CONTEXT_MESSAGE }, + }); + return; + } + const cwd = contextOption.value.worktreePath ?? contextOption.value.workspaceRoot; + + const context = buildSessionContext(thread, workSummary.dataLimitChars); + if (thread.messages.every((message) => message.text.trim().length === 0)) { + yield* dispatchUpdate({ + threadId: input.threadId, + requestId: input.requestId, + result: { status: "error", error: WORK_SUMMARY_EMPTY_SESSION_MESSAGE }, + }); + return; + } + + yield* Effect.gen(function* () { + const generated = yield* textGeneration + .generateWorkSummary({ + cwd, + context, + modelSelection: workSummary.modelSelection, + ...(workSummary.promptInstructions.trim().length > 0 + ? { promptInstructions: workSummary.promptInstructions } + : {}), + }) + .pipe(Effect.timeout(WORK_SUMMARY_TIMEOUT)); + + const summary = generated.summary.trim(); + yield* dispatchUpdate({ + threadId: input.threadId, + requestId: input.requestId, + result: + summary.length > 0 + ? { + status: "ready", + summary, + stage: generated.stage, + remaining: generated.remaining, + percent: generated.percent, + } + : { status: "error", error: WORK_SUMMARY_ERROR_FALLBACK }, + }); + }).pipe( + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : // Replace the spinner, then let the worker log the failure. + dispatchUpdate({ + threadId: input.threadId, + requestId: input.requestId, + result: { status: "error", error: workSummaryFailureMessage(cause) }, + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const processRequest = Effect.fn("processWorkSummaryRequest")(function* ( + event: Extract, + ) { + const threadId = event.payload.threadId; + if (inFlightThreadIds.has(threadId)) { + return; + } + inFlightThreadIds.add(threadId); + yield* summarizeThread({ threadId, requestId: event.payload.requestId }).pipe( + // A finalizer, not a trailing statement: an interrupted or failed run must + // still release the thread or it could never be summarized again. + Effect.ensuring(Effect.sync(() => inFlightThreadIds.delete(threadId))), + ); + }); + + const processRequestSafely = ( + event: Extract, + ) => + processRequest(event).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + // Summaries are an operator convenience: never disturb the session. + return Effect.logWarning("work summary reactor failed to process request", { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }); + }), + ); + + const worker = yield* makeDrainableWorker(processRequestSafely); + + const start: WorkSummaryReactorShape["start"] = Effect.fn("start")(function* () { + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => + event.type === "thread.work-summary-requested" ? worker.enqueue(event) : Effect.void, + ), + ); + }); + + return { + start, + drain: worker.drain, + } satisfies WorkSummaryReactorShape; +}); + +export const WorkSummaryReactorLive = Layer.effect(WorkSummaryReactor, make); diff --git a/apps/server/src/orchestration/Services/WorkSummaryReactor.ts b/apps/server/src/orchestration/Services/WorkSummaryReactor.ts new file mode 100644 index 00000000000..0099ec610d0 --- /dev/null +++ b/apps/server/src/orchestration/Services/WorkSummaryReactor.ts @@ -0,0 +1,48 @@ +/** + * T3-CUSTOM(expbkt3): WorkSummaryReactor - bulk session manager summaries. + * + * Owns the background worker that answers `thread.work-summary-requested` by + * asking the configured model what a session has achieved and how far along it + * is, then writing that back as the thread's durable work summary. + * + * It exists next to `CatchupSummaryReactor` rather than inside it because the + * two answer different questions for different readers, and the operator + * configures them independently: disabling catch-up notes must not silence the + * session manager's columns, and vice versa. + * + * The worker is deliberately serial. A bulk selection of fifty sessions arrives + * as fifty commands within a second; fanning those out concurrently would + * launch fifty provider CLIs at once on the same host. + * + * @module WorkSummaryReactor + */ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +/** + * WorkSummaryReactorShape - Service API for work summary reactor lifecycle. + */ +export interface WorkSummaryReactorShape { + /** + * Start the work summary reactor. + * + * The returned effect must be run in a scope so the subscription and worker + * fibers are finalized on shutdown. + */ + readonly start: () => Effect.Effect; + + /** + * Resolves when the internal processing queue is empty and idle. + * Intended for test use to replace timing-sensitive sleeps. + */ + readonly drain: Effect.Effect; +} + +/** + * WorkSummaryReactor - Service tag for work summary reactor workers. + */ +export class WorkSummaryReactor extends Context.Service< + WorkSummaryReactor, + WorkSummaryReactorShape +>()("t3/orchestration/Services/WorkSummaryReactor") {} diff --git a/apps/server/src/orchestration/commandAccess.ts b/apps/server/src/orchestration/commandAccess.ts index e1794ee26e3..3beab8b0d1f 100644 --- a/apps/server/src/orchestration/commandAccess.ts +++ b/apps/server/src/orchestration/commandAccess.ts @@ -80,6 +80,11 @@ export const checkCommandAccess = ( case "thread.user-input.respond": case "thread.checkpoint.revert": case "thread.catchup-summary.request": + // The bulk session manager dispatches one of these per selected session, so + // it is gated exactly like every other user-triggered thread command. + // `thread.work-summary.update` is reactor-issued and falls through to the + // internal-command default below. + case "thread.work-summary.request": case "thread.session.stop": case "thread.session.restart": return accessControl.canAccessThread(actorUserId, command.threadId); diff --git a/apps/server/src/orchestration/decider.lineage.test.ts b/apps/server/src/orchestration/decider.lineage.test.ts new file mode 100644 index 00000000000..4af8cf49892 --- /dev/null +++ b/apps/server/src/orchestration/decider.lineage.test.ts @@ -0,0 +1,216 @@ +// T3-CUSTOM(expbkt3): session lineage decider coverage. +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +function makeThread(id: string, parentThreadId: string | null) { + return { + id: ThreadId.make(id), + projectId: ProjectId.make("project-1"), + title: `Thread ${id}`, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + sourceControlProfileId: null, + latestTurn: null, + ownerUserId: null, + memberUserIds: [], + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + priority: null, + parentThreadId: parentThreadId === null ? null : ThreadId.make(parentThreadId), + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + rollingSummary: null, + turnSummaries: [], + session: null, + }; +} + +function makeReadModel(): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [ + { + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/tmp/project-1", + defaultModelSelection: null, + scripts: [], + ownerUserId: null, + memberUserIds: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + }, + ], + threads: [ + makeThread("parent", null), + makeThread("child", "parent"), + makeThread("unrelated", null), + ], + updatedAt: NOW, + } as unknown as OrchestrationReadModel; +} + +const createCommandBase = { + type: "thread.create" as const, + projectId: ProjectId.make("project-1"), + title: "Spawned session", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + sourceControlProfileId: null, + createdAt: NOW, +}; + +it.layer(NodeServices.layer)("thread lineage decider", (it) => { + it.effect("carries a parent through thread.create", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + ...createCommandBase, + commandId: CommandId.make("cmd-create-child"), + threadId: ThreadId.make("spawned"), + parentThreadId: ThreadId.make("parent"), + }, + readModel: makeReadModel(), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.created"); + if (events[0]?.type === "thread.created") { + expect(events[0].payload.parentThreadId).toBe("parent"); + } + }), + ); + + it.effect("defaults an omitted parent to null, so a human-started session is a root", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + ...createCommandBase, + commandId: CommandId.make("cmd-create-root"), + threadId: ThreadId.make("standalone"), + }, + readModel: makeReadModel(), + }); + const events = Array.isArray(event) ? event : [event]; + if (events[0]?.type === "thread.created") { + expect(events[0].payload.parentThreadId).toBe(null); + } + }), + ); + + it.effect("re-parents through thread.meta.update", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-move"), + threadId: ThreadId.make("child"), + parentThreadId: ThreadId.make("unrelated"), + }, + readModel: makeReadModel(), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.meta-updated"); + if (events[0]?.type === "thread.meta-updated") { + expect(events[0].payload.parentThreadId).toBe("unrelated"); + } + }), + ); + + it.effect("detaches when the command sends null", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-detach"), + threadId: ThreadId.make("child"), + parentThreadId: null, + }, + readModel: makeReadModel(), + }); + const events = Array.isArray(event) ? event : [event]; + if (events[0]?.type === "thread.meta-updated") { + expect(events[0].payload.parentThreadId).toBe(null); + } + }), + ); + + it.effect("leaves lineage untouched when the command omits it", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-rename-only"), + threadId: ThreadId.make("child"), + title: "Renamed", + }, + readModel: makeReadModel(), + }); + const events = Array.isArray(event) ? event : [event]; + if (events[0]?.type === "thread.meta-updated") { + // undefined, not null: an omitted field must not detach the thread. + expect(events[0].payload.parentThreadId).toBe(undefined); + } + }), + ); + + it.effect("rejects parenting a thread under its own descendant", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-cycle"), + threadId: ThreadId.make("parent"), + parentThreadId: ThreadId.make("child"), + }, + readModel: makeReadModel(), + }), + ); + expect(exit._tag).toBe("Failure"); + }), + ); + + it.effect("rejects a thread parenting itself", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-self"), + threadId: ThreadId.make("child"), + parentThreadId: ThreadId.make("child"), + }, + readModel: makeReadModel(), + }), + ); + expect(exit._tag).toBe("Failure"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 2d4c30d9629..45094b73089 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -21,6 +21,8 @@ import { requireThreadAbsent, requireThreadNotArchived, } from "./commandInvariants.ts"; +// T3-CUSTOM(expbkt3): session lineage must stay acyclic. +import { requireThreadLineageAcyclic } from "./threadLineage.ts"; // T3-CUSTOM(expbkt3): fork command decisions import { decideForkOrchestrationCommand, isForkOrchestrationCommand } from "./deciderForkCases.ts"; import { projectEvent } from "./projector.ts"; @@ -413,6 +415,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" updatedAt: command.createdAt, // T3-CUSTOM(expbkt3): session priority. priority: command.priority ?? null, + // T3-CUSTOM(expbkt3): session lineage. No cycle check is needed on + // create: the thread does not exist yet, so it cannot be an ancestor + // of anything. + parentThreadId: command.parentThreadId ?? null, }, }; } @@ -796,6 +802,16 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.branch !== command.expectedBranch ? thread.branch : command.branch; + // T3-CUSTOM(expbkt3): re-parenting must not close a lineage loop. + // Detaching (null) is always safe and skips the walk. + if (command.parentThreadId != null) { + yield* requireThreadLineageAcyclic({ + readModel, + command, + threadId: command.threadId, + parentThreadId: command.parentThreadId, + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -832,6 +848,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.linearIssueUrl !== undefined ? { linearIssueUrl: command.linearIssueUrl } : {}), + // T3-CUSTOM(expbkt3): undefined leaves lineage unchanged; null detaches. + ...(command.parentThreadId !== undefined + ? { parentThreadId: command.parentThreadId } + : {}), updatedAt: occurredAt, }, }; @@ -1046,6 +1066,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: bootstrapCreate.createdAt, updatedAt: bootstrapCreate.createdAt, priority: bootstrapCreate.priority ?? null, + // T3-CUSTOM(expbkt3): session lineage carried through bootstrap. + parentThreadId: bootstrapCreate.parentThreadId ?? null, }, } : null; diff --git a/apps/server/src/orchestration/decider.workSummary.test.ts b/apps/server/src/orchestration/decider.workSummary.test.ts new file mode 100644 index 00000000000..1c60c288148 --- /dev/null +++ b/apps/server/src/orchestration/decider.workSummary.test.ts @@ -0,0 +1,171 @@ +// T3-CUSTOM(expbkt3): bulk session manager work summary decider coverage. +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type ThreadWorkSummary, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-1"); + +function makeReadModel(workSummary: ThreadWorkSummary | null = null): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [ + { + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/tmp/project-1", + defaultModelSelection: null, + scripts: [], + ownerUserId: null, + memberUserIds: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + sourceControlProfileId: null, + latestTurn: null, + ownerUserId: null, + memberUserIds: [], + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + priority: null, + workSummary, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + rollingSummary: null, + turnSummaries: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const READY_SUMMARY: ThreadWorkSummary = { + status: "ready", + summary: "Wired the bulk session manager server path end to end.", + stage: "awaiting-review", + remaining: "Land the client table", + percent: 80, + error: null, + requestId: CommandId.make("cmd-work-summary-request"), + updatedAt: NOW, +}; + +it.layer(NodeServices.layer)("thread work summary decider", (it) => { + it.effect("reuses the request command id as the request id", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.work-summary.request", + commandId: CommandId.make("cmd-work-summary-request"), + threadId: THREAD_ID, + createdAt: NOW, + }, + readModel: makeReadModel(), + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events[0]?.type).toBe("thread.work-summary-requested"); + if (events[0]?.type === "thread.work-summary-requested") { + // One id ties the pending marker, the reactor dispatch, and the + // projector's supersede check together without a second round trip. + expect(events[0].payload.requestId).toBe("cmd-work-summary-request"); + expect(events[0].payload.requestedAt).toBe(NOW); + expect(events[0].payload.threadId).toBe(THREAD_ID); + } + }), + ); + + it.effect("rejects a request for a thread that does not exist", () => + Effect.gen(function* () { + const outcome = yield* decideOrchestrationCommand({ + command: { + type: "thread.work-summary.request", + commandId: CommandId.make("cmd-work-summary-missing"), + threadId: ThreadId.make("thread-missing"), + createdAt: NOW, + }, + readModel: makeReadModel(), + }).pipe(Effect.result); + expect(outcome._tag).toBe("Failure"); + }), + ); + + it.effect("carries the reactor's result through work-summary.update", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.work-summary.update", + commandId: CommandId.make("cmd-work-summary-update"), + threadId: THREAD_ID, + requestId: CommandId.make("cmd-work-summary-request"), + workSummary: READY_SUMMARY, + createdAt: NOW, + }, + readModel: makeReadModel(), + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events[0]?.type).toBe("thread.work-summary-updated"); + if (events[0]?.type === "thread.work-summary-updated") { + expect(events[0].payload.requestId).toBe("cmd-work-summary-request"); + expect(events[0].payload.workSummary).toEqual(READY_SUMMARY); + } + }), + ); + + it.effect("emits a superseded result too, leaving the drop to the projector", () => + Effect.gen(function* () { + // The decider has no opinion on staleness: it records what the reactor + // produced. Dropping it belongs where the current request id is stored. + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.work-summary.update", + commandId: CommandId.make("cmd-work-summary-update-stale"), + threadId: THREAD_ID, + requestId: CommandId.make("cmd-work-summary-old"), + workSummary: { ...READY_SUMMARY, requestId: CommandId.make("cmd-work-summary-old") }, + createdAt: NOW, + }, + readModel: makeReadModel({ + ...READY_SUMMARY, + status: "pending", + requestId: CommandId.make("cmd-work-summary-new"), + }), + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events[0]?.type).toBe("thread.work-summary-updated"); + if (events[0]?.type === "thread.work-summary-updated") { + expect(events[0].payload.requestId).toBe("cmd-work-summary-old"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/deciderForkCases.ts b/apps/server/src/orchestration/deciderForkCases.ts index 68e9544bf7d..d87985b49da 100644 --- a/apps/server/src/orchestration/deciderForkCases.ts +++ b/apps/server/src/orchestration/deciderForkCases.ts @@ -2,7 +2,8 @@ * T3-CUSTOM(expbkt3): Fork orchestration command decisions. * * Membership/ownership transfer, thread source-control identity, session - * restart and catch-up summaries. Upstream's `decideOrchestrationCommand` + * restart, catch-up summaries and bulk-session-manager work summaries. + * Upstream's `decideOrchestrationCommand` * delegates here through a single type-narrowing guard, so the upstream switch * keeps its exhaustive `command satisfies never` default. */ @@ -39,6 +40,8 @@ const FORK_COMMAND_TYPES = [ "thread.session.restart", "thread.catchup-summary.request", "thread.catchup-summary.update", + "thread.work-summary.request", + "thread.work-summary.update", ] as const; export type ForkOrchestrationCommand = Extract< @@ -369,6 +372,54 @@ export const decideForkOrchestrationCommand = Effect.fn("decideForkOrchestration }, }; } + /** + * The request's own command id doubles as the request id, mirroring title + * regeneration. That keeps the pending marker, the reactor's dispatch and + * the projector's supersede check all keyed on one value without an extra + * round trip to mint one. + */ + case "thread.work-summary.request": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.work-summary-requested", + payload: { + threadId: command.threadId, + requestId: command.commandId, + requestedAt: command.createdAt, + }, + }; + } + case "thread.work-summary.update": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.work-summary-updated", + payload: { + threadId: command.threadId, + requestId: command.requestId, + workSummary: command.workSummary, + }, + }; + } default: { command satisfies never; diff --git a/apps/server/src/orchestration/dispatchCommand.ts b/apps/server/src/orchestration/dispatchCommand.ts index af249b5f686..45d5fb00352 100644 --- a/apps/server/src/orchestration/dispatchCommand.ts +++ b/apps/server/src/orchestration/dispatchCommand.ts @@ -364,6 +364,8 @@ export const make = Effect.gen(function* () { createdAt: bootstrap.createThread.createdAt, // T3-CUSTOM(expbkt3): session priority. priority: bootstrap.createThread.priority ?? null, + // T3-CUSTOM(expbkt3): session lineage. + parentThreadId: bootstrap.createThread.parentThreadId ?? null, }, dispatchOptions, ); @@ -649,6 +651,12 @@ export const make = Effect.gen(function* () { ? { sourceControlProfileId: request.sourceControlProfileId } : {}), ...(request.priority !== undefined ? { priority: request.priority } : {}), + // T3-CUSTOM(expbkt3): session lineage. This rebuild is the only + // carrier for a prompt-bearing t3_create_session, so dropping the + // field here silently orphans every agent-spawned session. + ...(request.parentThreadId !== undefined + ? { parentThreadId: request.parentThreadId } + : {}), ...(request.ownerUserId ? { ownerUserId: request.ownerUserId } : {}), createdAt: request.createdAt, }, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index b338b9632c6..81ef8254957 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -103,6 +103,8 @@ describe("orchestration projector", () => { priority: null, // T3-CUSTOM(expbkt3): no manual Linear tag on a new thread. linearIssueUrl: null, + // T3-CUSTOM(expbkt3): session lineage. + parentThreadId: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 4d471b997e5..80f6a866947 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -335,6 +335,8 @@ export function projectEvent( priority: payload.priority ?? null, // T3-CUSTOM(expbkt3): no manual Linear tag at thread creation. linearIssueUrl: null, + // T3-CUSTOM(expbkt3): session lineage stamped at creation. + parentThreadId: payload.parentThreadId ?? null, deletedAt: null, messages: [], activities: [], @@ -478,6 +480,10 @@ export function projectEvent( ...(payload.linearIssueUrl !== undefined ? { linearIssueUrl: payload.linearIssueUrl } : {}), + // T3-CUSTOM(expbkt3): session lineage re-parent / detach. + ...(payload.parentThreadId !== undefined + ? { parentThreadId: payload.parentThreadId } + : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/orchestration/projectorForkCases.ts b/apps/server/src/orchestration/projectorForkCases.ts index 5014280eb4e..fe7e88366b6 100644 --- a/apps/server/src/orchestration/projectorForkCases.ts +++ b/apps/server/src/orchestration/projectorForkCases.ts @@ -1,7 +1,8 @@ /** * T3-CUSTOM(expbkt3): Fork orchestration event projections. * - * Membership/ownership, thread source-control identity and catch-up summaries. + * Membership/ownership, thread source-control identity, catch-up summaries and + * bulk-session-manager work summaries. * Upstream's `projectEvent` delegates here through a single type-narrowing * guard. `decodeForEvent` and `updateThread` are passed in rather than imported * so this module stays below `projector.ts` in the import graph. @@ -15,6 +16,8 @@ import { ThreadMemberRemovedPayload, ThreadOwnerTransferredPayload, ThreadSourceControlProfileSetPayload, + ThreadWorkSummaryRequestedPayload, + ThreadWorkSummaryUpdatedPayload, OrchestrationTurnCatchupSummary, type OrchestrationEvent, type OrchestrationReadModel, @@ -38,6 +41,8 @@ const FORK_EVENT_TYPES = [ "project.owner-transferred", "thread.source-control-profile-set", "thread.catchup-summary-updated", + "thread.work-summary-requested", + "thread.work-summary-updated", ] as const; export type ForkOrchestrationEvent = Extract< @@ -285,5 +290,69 @@ export function projectForkEvent( }), }; }); + + /** + * The request event itself installs the pending record, so the bulk table + * can show a spinner on the row the moment the command is accepted rather + * than waiting for the reactor to pick the job off its queue. + */ + case "thread.work-summary-requested": + return Effect.gen(function* () { + const payload = yield* decodeForEvent( + ThreadWorkSummaryRequestedPayload, + event.payload, + event.type, + "payload", + ); + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + workSummary: { + status: "pending", + summary: null, + stage: null, + remaining: null, + percent: null, + error: null, + requestId: payload.requestId, + updatedAt: payload.requestedAt, + }, + }), + }; + }); + + /** + * Supersede rule: a result may only overwrite the record it was requested + * for. Re-requesting a session while its first generation is still in + * flight installs a new pending requestId, and the stale result that lands + * afterwards is dropped instead of clobbering the newer run. + */ + case "thread.work-summary-updated": + return Effect.gen(function* () { + const payload = yield* decodeForEvent( + ThreadWorkSummaryUpdatedPayload, + event.payload, + event.type, + "payload", + ); + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + const currentRequestId = thread.workSummary?.requestId ?? null; + if (currentRequestId !== null && currentRequestId !== payload.requestId) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + workSummary: payload.workSummary, + }), + }; + }); } } diff --git a/apps/server/src/orchestration/threadLineage.test.ts b/apps/server/src/orchestration/threadLineage.test.ts new file mode 100644 index 00000000000..d99a3034cc0 --- /dev/null +++ b/apps/server/src/orchestration/threadLineage.test.ts @@ -0,0 +1,181 @@ +// T3-CUSTOM(expbkt3): session lineage invariant coverage. +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { expect as effectExpect, it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + collectThreadDescendants, + requireThreadLineageAcyclic, + threadLineageWouldCycle, +} from "./threadLineage.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +function makeThread(id: string, parentThreadId: string | null): OrchestrationThread { + return { + id: ThreadId.make(id), + projectId: ProjectId.make("project-1"), + title: `Thread ${id}`, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + sourceControlProfileId: null, + latestTurn: null, + ownerUserId: null, + memberUserIds: [], + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + priority: null, + parentThreadId: parentThreadId === null ? null : ThreadId.make(parentThreadId), + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + rollingSummary: null, + turnSummaries: [], + session: null, + } as unknown as OrchestrationThread; +} + +// root → child → grandchild, plus an unrelated sibling tree. +const threads = [ + makeThread("root", null), + makeThread("child", "root"), + makeThread("grandchild", "child"), + makeThread("other", null), +]; + +const readModel = { + snapshotSequence: 0, + projects: [], + threads, + updatedAt: NOW, +} as unknown as OrchestrationReadModel; + +const command = { + type: "thread.meta.update", + commandId: CommandId.make("cmd-1"), + threadId: ThreadId.make("root"), +} as unknown as OrchestrationCommand; + +describe("threadLineageWouldCycle", () => { + it("rejects a thread parenting itself", () => { + expect( + threadLineageWouldCycle({ + threads, + threadId: ThreadId.make("root"), + parentThreadId: ThreadId.make("root"), + }), + ).toBe(true); + }); + + it("rejects adopting a direct child", () => { + expect( + threadLineageWouldCycle({ + threads, + threadId: ThreadId.make("root"), + parentThreadId: ThreadId.make("child"), + }), + ).toBe(true); + }); + + it("rejects adopting a deeper descendant", () => { + expect( + threadLineageWouldCycle({ + threads, + threadId: ThreadId.make("root"), + parentThreadId: ThreadId.make("grandchild"), + }), + ).toBe(true); + }); + + it("allows moving a subtree under an unrelated thread", () => { + expect( + threadLineageWouldCycle({ + threads, + threadId: ThreadId.make("root"), + parentThreadId: ThreadId.make("other"), + }), + ).toBe(false); + }); + + it("allows a child to re-parent onto a thread outside its own subtree", () => { + expect( + threadLineageWouldCycle({ + threads, + threadId: ThreadId.make("child"), + parentThreadId: ThreadId.make("other"), + }), + ).toBe(false); + }); + + it("reports a pre-existing cycle instead of walking forever", () => { + const corrupt = [makeThread("a", "b"), makeThread("b", "a")]; + expect( + threadLineageWouldCycle({ + threads: corrupt, + threadId: ThreadId.make("c"), + parentThreadId: ThreadId.make("a"), + }), + ).toBe(true); + }); +}); + +describe("collectThreadDescendants", () => { + it("returns the whole subtree and never the thread itself", () => { + const descendants = collectThreadDescendants(threads, ThreadId.make("root")); + + expect([...descendants].toSorted()).toEqual(["child", "grandchild"]); + }); + + it("returns nothing for a leaf", () => { + expect(collectThreadDescendants(threads, ThreadId.make("grandchild")).size).toBe(0); + }); +}); + +effectIt.effect("requireThreadLineageAcyclic passes an acyclic parent", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + requireThreadLineageAcyclic({ + readModel, + command, + threadId: ThreadId.make("root"), + parentThreadId: ThreadId.make("other"), + }), + ); + + effectExpect(exit._tag).toBe("Success"); + }), +); + +effectIt.effect("requireThreadLineageAcyclic rejects adopting a descendant", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + requireThreadLineageAcyclic({ + readModel, + command, + threadId: ThreadId.make("root"), + parentThreadId: ThreadId.make("child"), + }), + ); + + effectExpect(exit._tag).toBe("Failure"); + }), +); diff --git a/apps/server/src/orchestration/threadLineage.ts b/apps/server/src/orchestration/threadLineage.ts new file mode 100644 index 00000000000..046a73b2f28 --- /dev/null +++ b/apps/server/src/orchestration/threadLineage.ts @@ -0,0 +1,104 @@ +// T3-CUSTOM(expbkt3): session lineage invariants. +// +// Lineage must stay a FOREST. Every writer of `parentThreadId` — the decider's +// thread.meta.update case and the MCP create handler — resolves ancestry +// through this module so the rule has exactly one definition and one error +// message. Without it, `A.parent = B; B.parent = A` is two individually valid +// commands that together strand both threads: neither can ever reach a root, +// so neither can ever render. +import type { + OrchestrationCommand, + OrchestrationReadModel, + OrchestrationThread, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { OrchestrationCommandInvariantError } from "./Errors.ts"; + +/** + * Backstop for a projection that is already corrupt. A pre-existing cycle + * would otherwise spin the ancestor walk forever; hitting the cap is reported + * as a cycle rather than silently accepted. + */ +export const THREAD_LINEAGE_MAX_DEPTH = 32; + +function parentOf( + threads: ReadonlyArray, + threadId: ThreadId, +): ThreadId | null { + return threads.find((thread) => thread.id === threadId)?.parentThreadId ?? null; +} + +/** + * Walk from `parentThreadId` towards its root, looking for `threadId`. A hit + * means the proposed parent is the thread itself or one of its descendants, + * so accepting the link would close a loop. + */ +export function threadLineageWouldCycle(input: { + readonly threads: ReadonlyArray; + readonly threadId: ThreadId; + readonly parentThreadId: ThreadId; +}): boolean { + if (input.threadId === input.parentThreadId) return true; + + const seen = new Set([input.threadId]); + let cursor: ThreadId | null = input.parentThreadId; + for (let depth = 0; cursor !== null && depth < THREAD_LINEAGE_MAX_DEPTH; depth += 1) { + if (seen.has(cursor)) return true; + seen.add(cursor); + cursor = parentOf(input.threads, cursor); + } + return cursor !== null; +} + +/** + * Every thread reachable downwards from `threadId`, excluding itself. The + * "move under session" picker uses this to hide the options the decider would + * reject, so an invalid parent is never offered in the first place. + */ +export function collectThreadDescendants( + threads: ReadonlyArray, + threadId: ThreadId, +): ReadonlySet { + const descendants = new Set(); + const queue: ThreadId[] = [threadId]; + // Bounded by the thread count: each id is enqueued at most once, so a + // corrupt cycle cannot make this loop forever. + while (queue.length > 0) { + const current = queue.pop() as ThreadId; + for (const thread of threads) { + if (thread.parentThreadId !== current) continue; + if (thread.id === threadId || descendants.has(thread.id)) continue; + descendants.add(thread.id); + queue.push(thread.id); + } + } + return descendants; +} + +export function requireThreadLineageAcyclic(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly threadId: ThreadId; + readonly parentThreadId: ThreadId; +}): Effect.Effect { + if ( + !threadLineageWouldCycle({ + threads: input.readModel.threads, + threadId: input.threadId, + parentThreadId: input.parentThreadId, + }) + ) { + return Effect.void; + } + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: input.command.type, + detail: + input.threadId === input.parentThreadId + ? `Thread '${input.threadId}' cannot be its own parent.` + : `Thread '${input.parentThreadId}' is a descendant of '${input.threadId}', so parenting would create a cycle.`, + }), + ); +} diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index cca301b7043..c334feb8dab 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -51,6 +51,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at, priority, linear_issue_url, + parent_thread_id, + -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON). + work_summary, + -- T3-CUSTOM(expbkt3): END pinned_at, title_regeneration_request_id, title_regeneration_started_at, @@ -82,6 +86,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedAt}, ${row.priority}, ${row.linearIssueUrl ?? null}, + ${row.parentThreadId ?? null}, + -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON). + ${row.workSummary ?? null}, + -- T3-CUSTOM(expbkt3): END ${row.pinnedAt}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, @@ -113,6 +121,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at = excluded.snoozed_at, priority = excluded.priority, linear_issue_url = excluded.linear_issue_url, + parent_thread_id = excluded.parent_thread_id, + -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON). + work_summary = excluded.work_summary, + -- T3-CUSTOM(expbkt3): END pinned_at = excluded.pinned_at, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, @@ -151,6 +163,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + parent_thread_id AS "parentThreadId", + -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON). + work_summary AS "workSummary", + -- T3-CUSTOM(expbkt3): END pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", @@ -191,6 +207,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + parent_thread_id AS "parentThreadId", + -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON). + work_summary AS "workSummary", + -- T3-CUSTOM(expbkt3): END pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 5b6a1e4608f..54de45f7824 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -81,6 +81,19 @@ import Migration1007 from "./Migrations/1007_OwnershipBackfillFastPath.ts"; // block already occupies (ThreadExecutions). It registers at the next free ID in // the 1000+ lane instead; the file keeps its upstream name. import Migration1008 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; +// T3-CUSTOM(expbkt3): native plan review documents, versions and discussions. +import Migration1009 from "./Migrations/1009_PlanReviewDocuments.ts"; + +// T3-CUSTOM(expbkt3): session lineage column for the experimental sidebar tree. +import Migration1010 from "./Migrations/1010_ProjectionThreadsParentThread.ts"; +// T3-CUSTOM(expbkt3): plan documents record their renderer (markdown or HTML). +import Migration1011 from "./Migrations/1011_PlanDocumentFormat.ts"; +// T3-CUSTOM(expbkt3): BEGIN — durable bulk-session-manager work summaries. +// Allocated at 1012, above the highest applied id: Effect only runs migrations +// newer than the newest row in effect_sql_migrations, so a lower id would never +// execute on a database that already applied 1011. +import Migration1012 from "./Migrations/1012_ProjectionThreadsWorkSummary.ts"; +// T3-CUSTOM(expbkt3): END /** * Migration loader with all migrations defined inline. @@ -168,6 +181,13 @@ const migrationEntries = [ [1006, "AuthSessionClientVersion", Migration1006], [1007, "OwnershipBackfillFastPath", Migration1007], [1008, "ProjectionTurnsKeysetIndex", Migration1008], + [1009, "PlanReviewDocuments", Migration1009], + // T3-CUSTOM(expbkt3): session lineage. + [1010, "ProjectionThreadsParentThread", Migration1010], + [1011, "PlanDocumentFormat", Migration1011], + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summaries. + [1012, "ProjectionThreadsWorkSummary", Migration1012], + // T3-CUSTOM(expbkt3): END ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/1009_PlanReviewDocuments.ts b/apps/server/src/persistence/Migrations/1009_PlanReviewDocuments.ts new file mode 100644 index 00000000000..6a52093a271 --- /dev/null +++ b/apps/server/src/persistence/Migrations/1009_PlanReviewDocuments.ts @@ -0,0 +1,110 @@ +// T3-CUSTOM(expbkt3): native plan review — versioned, attributed plan documents. +// +// A plan document is the durable lineage behind one proposed plan: version 1 is +// whatever the agent produced, every later revision (agent or human) appends a +// new immutable row. Versions are never mutated, so `revision` doubles as the +// anchor key for comments the way `checkpoint_diff_blobs` keys on turn counts. +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS plan_documents ( + document_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + current_revision INTEGER NOT NULL, + status TEXT NOT NULL, + created_by_user_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_plan_documents_thread + ON plan_documents(thread_id, created_at) + `; + + // Append-only. Nothing in the application ever updates or deletes these rows. + yield* sql` + CREATE TABLE IF NOT EXISTS plan_document_versions ( + version_id TEXT PRIMARY KEY, + document_id TEXT NOT NULL, + revision INTEGER NOT NULL, + author_kind TEXT NOT NULL, + author_user_id TEXT, + origin TEXT NOT NULL, + content_markdown TEXT NOT NULL, + content_value_json TEXT, + source_plan_id TEXT, + summary TEXT, + created_at TEXT NOT NULL, + UNIQUE (document_id, revision) + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_plan_document_versions_document + ON plan_document_versions(document_id, revision) + `; + + // Source plan ids arrive from projection_thread_proposed_plans; the lookup is + // how the ingest listener decides "already captured" without a table scan. + yield* sql` + CREATE INDEX IF NOT EXISTS idx_plan_document_versions_source_plan + ON plan_document_versions(source_plan_id) + `; + + // The live working copy: exactly one per document, holding pending Plate + // suggestions inline. `revision_token` is the optimistic-concurrency guard. + yield* sql` + CREATE TABLE IF NOT EXISTS plan_document_drafts ( + document_id TEXT PRIMARY KEY, + base_version_id TEXT NOT NULL, + content_value_json TEXT NOT NULL, + updated_by_user_id TEXT, + updated_at TEXT NOT NULL, + revision_token TEXT NOT NULL + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS plan_discussions ( + discussion_id TEXT PRIMARY KEY, + document_id TEXT NOT NULL, + anchor_version_id TEXT NOT NULL, + quoted_text TEXT NOT NULL, + is_resolved INTEGER NOT NULL DEFAULT 0, + resolved_by_user_id TEXT, + resolved_at TEXT, + created_by_user_id TEXT, + created_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_plan_discussions_document + ON plan_discussions(document_id, created_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS plan_discussion_comments ( + comment_id TEXT PRIMARY KEY, + discussion_id TEXT NOT NULL, + author_user_id TEXT, + body_markdown TEXT NOT NULL, + is_edited INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_plan_discussion_comments_discussion + ON plan_discussion_comments(discussion_id, created_at) + `; +}); diff --git a/apps/server/src/persistence/Migrations/1010_ProjectionThreadsParentThread.test.ts b/apps/server/src/persistence/Migrations/1010_ProjectionThreadsParentThread.test.ts new file mode 100644 index 00000000000..9852b855353 --- /dev/null +++ b/apps/server/src/persistence/Migrations/1010_ProjectionThreadsParentThread.test.ts @@ -0,0 +1,42 @@ +// T3-CUSTOM(expbkt3): session lineage migration coverage. +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()))( + "1010_ProjectionThreadsParentThread", + (it) => { + it.effect("adds the nullable parent thread column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 1010 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_threads) + `; + const parentColumn = columns.find((column) => column.name === "parent_thread_id"); + assert.isDefined(parentColumn); + // Existing rows predate lineage, so the column must accept NULL. + assert.strictEqual(parentColumn?.notnull, 0); + }), + ); + + it.effect("indexes children so the cycle guard never scans the projection", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 1010 }); + + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_threads) + `; + assert.isTrue( + indexes.some((index) => index.name === "idx_projection_threads_parent_thread_id"), + ); + }), + ); + }, +); diff --git a/apps/server/src/persistence/Migrations/1010_ProjectionThreadsParentThread.ts b/apps/server/src/persistence/Migrations/1010_ProjectionThreadsParentThread.ts new file mode 100644 index 00000000000..a7bd13eaecb --- /dev/null +++ b/apps/server/src/persistence/Migrations/1010_ProjectionThreadsParentThread.ts @@ -0,0 +1,32 @@ +// T3-CUSTOM(expbkt3): session lineage. A thread spawned by another session +// (today via the t3_create_session MCP tool) records its parent so the +// experimental sidebar can file it under that session instead of stranding it +// as an unrelated top-level row. NULL means "root session". +// +// The column is intentionally not a foreign key: a parent may be hard-deleted +// while its children live on, and readers already treat an unresolvable parent +// as "render me at the top level". +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "parent_thread_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN parent_thread_id TEXT + `; + } + + // Children-of lookups drive the cycle guard on every re-parent, so they must + // not degrade into a full scan of the thread projection. + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_parent_thread_id + ON projection_threads (parent_thread_id) + WHERE parent_thread_id IS NOT NULL + `; +}); diff --git a/apps/server/src/persistence/Migrations/1011_PlanDocumentFormat.ts b/apps/server/src/persistence/Migrations/1011_PlanDocumentFormat.ts new file mode 100644 index 00000000000..e183b6ba917 --- /dev/null +++ b/apps/server/src/persistence/Migrations/1011_PlanDocumentFormat.ts @@ -0,0 +1,22 @@ +// T3-CUSTOM(expbkt3): plan documents remember whether they are markdown or HTML. +// +// Providers put the whole plan in `planMarkdown` even when its value is an HTML +// document, so the renderer has to be recorded rather than re-sniffed on every +// read. Existing rows are markdown — the HTML path did not exist before this. +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(plan_documents) + `; + + if (!columns.some((column) => column.name === "format")) { + yield* sql` + ALTER TABLE plan_documents + ADD COLUMN format TEXT NOT NULL DEFAULT 'md' + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/1012_ProjectionThreadsWorkSummary.test.ts b/apps/server/src/persistence/Migrations/1012_ProjectionThreadsWorkSummary.test.ts new file mode 100644 index 00000000000..d4cab8fc223 --- /dev/null +++ b/apps/server/src/persistence/Migrations/1012_ProjectionThreadsWorkSummary.test.ts @@ -0,0 +1,46 @@ +// T3-CUSTOM(expbkt3): bulk session manager work summary migration coverage. +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()))( + "1012_ProjectionThreadsWorkSummary", + (it) => { + it.effect("adds the nullable work summary column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 1012 }); + + const columns = yield* sql<{ + readonly name: string; + readonly type: string; + readonly notnull: number; + }>` + PRAGMA table_info(projection_threads) + `; + const column = columns.find((entry) => entry.name === "work_summary"); + assert.isDefined(column); + assert.strictEqual(column?.type, "TEXT"); + // NULL is the "never generated" state and must stay representable. + assert.strictEqual(column?.notnull, 0); + }), + ); + + it.effect("is idempotent when the column already exists", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 1012 }); + yield* runMigrations({ toMigrationInclusive: 1012 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.strictEqual(columns.filter((entry) => entry.name === "work_summary").length, 1); + }), + ); + }, +); diff --git a/apps/server/src/persistence/Migrations/1012_ProjectionThreadsWorkSummary.ts b/apps/server/src/persistence/Migrations/1012_ProjectionThreadsWorkSummary.ts new file mode 100644 index 00000000000..85053f882a5 --- /dev/null +++ b/apps/server/src/persistence/Migrations/1012_ProjectionThreadsWorkSummary.ts @@ -0,0 +1,22 @@ +/** + * T3-CUSTOM(expbkt3): durable bulk-session-manager work summary on a thread. + * + * One JSON-encoded `ThreadWorkSummary` per thread. NULL means the summary was + * never generated, which is distinct from a generated-but-empty result. + */ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "work_summary")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN work_summary TEXT + `; + } +}); diff --git a/apps/server/src/persistence/PlanReviewDocuments.ts b/apps/server/src/persistence/PlanReviewDocuments.ts new file mode 100644 index 00000000000..7c4722f9429 --- /dev/null +++ b/apps/server/src/persistence/PlanReviewDocuments.ts @@ -0,0 +1,823 @@ +/** + * T3-CUSTOM(expbkt3): persistence for native plan review. + * + * Three shapes live here: the document (one per plan lineage), its append-only + * versions, and the mutable working draft plus discussion threads. Versions are + * insert-only by contract — `appendVersion` fails on a duplicate `(documentId, + * revision)` rather than overwriting, so history can never be rewritten by a + * racing writer. + */ +import { ThreadId, UserId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + type ProjectionRepositoryError, + PersistenceDecodeError, + PersistenceSqlError, +} from "./Errors.ts"; + +export const PlanDocumentStatus = Schema.Literals([ + "open", + "approved", + "changes-requested", + "discarded", +]); +export type PlanDocumentStatus = typeof PlanDocumentStatus.Type; + +export const PlanDocumentFormat = Schema.Literals(["md", "html"]); +export type PlanDocumentFormat = typeof PlanDocumentFormat.Type; + +export const PlanVersionAuthorKind = Schema.Literals(["agent", "user"]); +export type PlanVersionAuthorKind = typeof PlanVersionAuthorKind.Type; + +export const PlanVersionOrigin = Schema.Literals([ + "agent-proposed", + "agent-revision", + "human-edit", +]); +export type PlanVersionOrigin = typeof PlanVersionOrigin.Type; + +export const PlanDocumentRecord = Schema.Struct({ + documentId: Schema.String, + threadId: ThreadId, + projectId: Schema.String, + title: Schema.String, + currentRevision: Schema.Number, + status: PlanDocumentStatus, + format: PlanDocumentFormat, + createdByUserId: Schema.NullOr(UserId), + createdAt: Schema.String, + updatedAt: Schema.String, +}); +export type PlanDocumentRecord = typeof PlanDocumentRecord.Type; + +export const PlanVersionRecord = Schema.Struct({ + versionId: Schema.String, + documentId: Schema.String, + revision: Schema.Number, + authorKind: PlanVersionAuthorKind, + authorUserId: Schema.NullOr(UserId), + origin: PlanVersionOrigin, + contentMarkdown: Schema.String, + contentValueJson: Schema.NullOr(Schema.String), + sourcePlanId: Schema.NullOr(Schema.String), + summary: Schema.NullOr(Schema.String), + createdAt: Schema.String, +}); +export type PlanVersionRecord = typeof PlanVersionRecord.Type; + +export const PlanDraftRecord = Schema.Struct({ + documentId: Schema.String, + baseVersionId: Schema.String, + contentValueJson: Schema.String, + updatedByUserId: Schema.NullOr(UserId), + updatedAt: Schema.String, + revisionToken: Schema.String, +}); +export type PlanDraftRecord = typeof PlanDraftRecord.Type; + +export const PlanDiscussionRecord = Schema.Struct({ + discussionId: Schema.String, + documentId: Schema.String, + anchorVersionId: Schema.String, + quotedText: Schema.String, + isResolved: Schema.Boolean, + resolvedByUserId: Schema.NullOr(UserId), + resolvedAt: Schema.NullOr(Schema.String), + createdByUserId: Schema.NullOr(UserId), + createdAt: Schema.String, +}); +export type PlanDiscussionRecord = typeof PlanDiscussionRecord.Type; + +export const PlanDiscussionCommentRecord = Schema.Struct({ + commentId: Schema.String, + discussionId: Schema.String, + authorUserId: Schema.NullOr(UserId), + bodyMarkdown: Schema.String, + isEdited: Schema.Boolean, + createdAt: Schema.String, + updatedAt: Schema.String, +}); +export type PlanDiscussionCommentRecord = typeof PlanDiscussionCommentRecord.Type; + +/** Raised when an append lost the race for a revision number. */ +export class PlanVersionConflictError extends Schema.TaggedErrorClass()( + "PlanVersionConflictError", + { documentId: Schema.String, revision: Schema.Number }, +) {} + +/** Raised when a draft save carried a stale `revisionToken`. */ +export class PlanDraftConflictError extends Schema.TaggedErrorClass()( + "PlanDraftConflictError", + { documentId: Schema.String }, +) {} + +export type PlanReviewRepositoryError = ProjectionRepositoryError; + +const PlanDocumentRawRow = Schema.Struct({ + documentId: Schema.Unknown, + threadId: Schema.Unknown, + projectId: Schema.Unknown, + title: Schema.Unknown, + currentRevision: Schema.Unknown, + status: Schema.Unknown, + format: Schema.Unknown, + createdByUserId: Schema.Unknown, + createdAt: Schema.Unknown, + updatedAt: Schema.Unknown, +}); + +const PlanVersionRawRow = Schema.Struct({ + versionId: Schema.Unknown, + documentId: Schema.Unknown, + revision: Schema.Unknown, + authorKind: Schema.Unknown, + authorUserId: Schema.Unknown, + origin: Schema.Unknown, + contentMarkdown: Schema.Unknown, + contentValueJson: Schema.Unknown, + sourcePlanId: Schema.Unknown, + summary: Schema.Unknown, + createdAt: Schema.Unknown, +}); + +const PlanDraftRawRow = Schema.Struct({ + documentId: Schema.Unknown, + baseVersionId: Schema.Unknown, + contentValueJson: Schema.Unknown, + updatedByUserId: Schema.Unknown, + updatedAt: Schema.Unknown, + revisionToken: Schema.Unknown, +}); + +const PlanDiscussionRawRow = Schema.Struct({ + discussionId: Schema.Unknown, + documentId: Schema.Unknown, + anchorVersionId: Schema.Unknown, + quotedText: Schema.Unknown, + isResolved: Schema.Unknown, + resolvedByUserId: Schema.Unknown, + resolvedAt: Schema.Unknown, + createdByUserId: Schema.Unknown, + createdAt: Schema.Unknown, +}); + +const PlanDiscussionCommentRawRow = Schema.Struct({ + commentId: Schema.Unknown, + discussionId: Schema.Unknown, + authorUserId: Schema.Unknown, + bodyMarkdown: Schema.Unknown, + isEdited: Schema.Unknown, + createdAt: Schema.Unknown, + updatedAt: Schema.Unknown, +}); + +export interface AppendVersionInput { + readonly versionId: string; + readonly documentId: string; + readonly revision: number; + readonly authorKind: PlanVersionAuthorKind; + readonly authorUserId: UserId | null; + readonly origin: PlanVersionOrigin; + readonly contentMarkdown: string; + readonly contentValueJson: string | null; + readonly sourcePlanId: string | null; + readonly summary: string | null; + readonly createdAt: string; +} + +export interface SaveDraftInput { + readonly documentId: string; + readonly baseVersionId: string; + readonly contentValueJson: string; + readonly updatedByUserId: UserId | null; + readonly updatedAt: string; + readonly expectedRevisionToken: string | null; + readonly nextRevisionToken: string; +} + +export interface UpsertDiscussionInput { + readonly discussionId: string; + readonly documentId: string; + readonly anchorVersionId: string; + readonly quotedText: string; + readonly createdByUserId: UserId | null; + readonly createdAt: string; +} + +export interface AddDiscussionCommentInput { + readonly commentId: string; + readonly discussionId: string; + readonly documentId: string; + readonly authorUserId: UserId | null; + readonly bodyMarkdown: string; + readonly createdAt: string; +} + +export interface ResolveDiscussionInput { + readonly discussionId: string; + readonly documentId: string; + readonly isResolved: boolean; + readonly resolvedByUserId: UserId | null; + readonly resolvedAt: string | null; +} + +export class PlanReviewRepository extends Context.Service< + PlanReviewRepository, + { + readonly upsertDocument: ( + input: PlanDocumentRecord, + ) => Effect.Effect; + readonly getDocument: ( + documentId: string, + ) => Effect.Effect, PlanReviewRepositoryError>; + readonly listDocumentsForThread: ( + threadId: ThreadId, + ) => Effect.Effect, PlanReviewRepositoryError>; + readonly findDocumentBySourcePlanId: ( + sourcePlanId: string, + ) => Effect.Effect, PlanReviewRepositoryError>; + readonly setDocumentStatus: (input: { + readonly documentId: string; + readonly status: PlanDocumentStatus; + readonly updatedAt: string; + }) => Effect.Effect; + readonly appendVersion: ( + input: AppendVersionInput, + ) => Effect.Effect; + readonly listVersions: ( + documentId: string, + ) => Effect.Effect, PlanReviewRepositoryError>; + readonly getVersion: (input: { + readonly documentId: string; + readonly versionId: string; + }) => Effect.Effect, PlanReviewRepositoryError>; + readonly getLatestVersion: ( + documentId: string, + ) => Effect.Effect, PlanReviewRepositoryError>; + readonly getDraft: ( + documentId: string, + ) => Effect.Effect, PlanReviewRepositoryError>; + readonly saveDraft: ( + input: SaveDraftInput, + ) => Effect.Effect; + readonly clearDraft: (documentId: string) => Effect.Effect; + readonly upsertDiscussion: ( + input: UpsertDiscussionInput, + ) => Effect.Effect; + readonly listDiscussions: ( + documentId: string, + ) => Effect.Effect, PlanReviewRepositoryError>; + readonly resolveDiscussion: ( + input: ResolveDiscussionInput, + ) => Effect.Effect; + readonly addDiscussionComment: ( + input: AddDiscussionCommentInput, + ) => Effect.Effect; + readonly listDiscussionComments: ( + documentId: string, + ) => Effect.Effect, PlanReviewRepositoryError>; + } +>()("t3/persistence/PlanReviewDocuments/PlanReviewRepository") {} + +function mapError(operation: string) { + return (cause: unknown): PlanReviewRepositoryError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(`${operation}:decode`, cause) + : new PersistenceSqlError({ operation: `${operation}:query`, cause }); +} + +const decodeDocument = Schema.decodeUnknownEffect(PlanDocumentRecord); +const decodeVersion = Schema.decodeUnknownEffect(PlanVersionRecord); +const decodeDraft = Schema.decodeUnknownEffect(PlanDraftRecord); +const decodeDiscussion = Schema.decodeUnknownEffect(PlanDiscussionRecord); +const decodeComment = Schema.decodeUnknownEffect(PlanDiscussionCommentRecord); + +/** SQLite stores booleans as 0/1; normalise before schema decoding. */ +function withBoolean(row: Record, key: K) { + return { ...row, [key]: row[key] === 1 || row[key] === true }; +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const documentColumns = sql` + document_id AS "documentId", + thread_id AS "threadId", + project_id AS "projectId", + title AS "title", + current_revision AS "currentRevision", + status AS "status", + format AS "format", + created_by_user_id AS "createdByUserId", + created_at AS "createdAt", + updated_at AS "updatedAt" + `; + + const versionColumns = sql` + version_id AS "versionId", + document_id AS "documentId", + revision AS "revision", + author_kind AS "authorKind", + author_user_id AS "authorUserId", + origin AS "origin", + content_markdown AS "contentMarkdown", + content_value_json AS "contentValueJson", + source_plan_id AS "sourcePlanId", + summary AS "summary", + created_at AS "createdAt" + `; + + const discussionColumns = sql` + discussion_id AS "discussionId", + document_id AS "documentId", + anchor_version_id AS "anchorVersionId", + quoted_text AS "quotedText", + is_resolved AS "isResolved", + resolved_by_user_id AS "resolvedByUserId", + resolved_at AS "resolvedAt", + created_by_user_id AS "createdByUserId", + created_at AS "createdAt" + `; + + const upsertDocumentRow = SqlSchema.void({ + Request: PlanDocumentRecord, + execute: (input) => sql` + INSERT INTO plan_documents ( + document_id, thread_id, project_id, title, current_revision, + status, format, created_by_user_id, created_at, updated_at + ) VALUES ( + ${input.documentId}, ${input.threadId}, ${input.projectId}, ${input.title}, + ${input.currentRevision}, ${input.status}, ${input.format}, ${input.createdByUserId}, + ${input.createdAt}, ${input.updatedAt} + ) + ON CONFLICT(document_id) DO UPDATE SET + title = excluded.title, + current_revision = excluded.current_revision, + status = excluded.status, + format = excluded.format, + updated_at = excluded.updated_at + `, + }); + + const getDocumentRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ documentId: Schema.String }), + Result: PlanDocumentRawRow, + execute: ({ documentId }) => sql` + SELECT ${documentColumns} FROM plan_documents WHERE document_id = ${documentId} + `, + }); + + const listDocumentsForThreadRows = SqlSchema.findAll({ + Request: Schema.Struct({ threadId: ThreadId }), + Result: PlanDocumentRawRow, + execute: ({ threadId }) => sql` + SELECT ${documentColumns} FROM plan_documents + WHERE thread_id = ${threadId} + ORDER BY created_at DESC + `, + }); + + const findDocumentBySourcePlanIdRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ sourcePlanId: Schema.String }), + Result: PlanDocumentRawRow, + execute: ({ sourcePlanId }) => sql` + SELECT ${documentColumns} FROM plan_documents + WHERE document_id = ( + SELECT document_id FROM plan_document_versions + WHERE source_plan_id = ${sourcePlanId} + ORDER BY revision DESC LIMIT 1 + ) + `, + }); + + const setDocumentStatusRow = SqlSchema.void({ + Request: Schema.Struct({ + documentId: Schema.String, + status: PlanDocumentStatus, + updatedAt: Schema.String, + }), + execute: ({ documentId, status, updatedAt }) => sql` + UPDATE plan_documents + SET status = ${status}, updated_at = ${updatedAt} + WHERE document_id = ${documentId} + `, + }); + + // INSERT ... SELECT WHERE NOT EXISTS keeps the duplicate check inside SQLite, + // so two concurrent appends cannot both believe they won the revision. + const appendVersionRow = SqlSchema.findAll({ + Request: Schema.Struct({ + versionId: Schema.String, + documentId: Schema.String, + revision: Schema.Number, + authorKind: PlanVersionAuthorKind, + authorUserId: Schema.NullOr(UserId), + origin: PlanVersionOrigin, + contentMarkdown: Schema.String, + contentValueJson: Schema.NullOr(Schema.String), + sourcePlanId: Schema.NullOr(Schema.String), + summary: Schema.NullOr(Schema.String), + createdAt: Schema.String, + }), + Result: Schema.Struct({ versionId: Schema.String }), + execute: (input) => sql` + INSERT INTO plan_document_versions ( + version_id, document_id, revision, author_kind, author_user_id, + origin, content_markdown, content_value_json, source_plan_id, summary, created_at + ) + SELECT + ${input.versionId}, ${input.documentId}, ${input.revision}, ${input.authorKind}, + ${input.authorUserId}, ${input.origin}, ${input.contentMarkdown}, + ${input.contentValueJson}, ${input.sourcePlanId}, ${input.summary}, ${input.createdAt} + WHERE NOT EXISTS ( + SELECT 1 FROM plan_document_versions + WHERE document_id = ${input.documentId} AND revision = ${input.revision} + ) + RETURNING version_id AS "versionId" + `, + }); + + const listVersionRows = SqlSchema.findAll({ + Request: Schema.Struct({ documentId: Schema.String }), + Result: PlanVersionRawRow, + execute: ({ documentId }) => sql` + SELECT ${versionColumns} FROM plan_document_versions + WHERE document_id = ${documentId} + ORDER BY revision ASC + `, + }); + + const getVersionRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ versionId: Schema.String, documentId: Schema.String }), + Result: PlanVersionRawRow, + execute: ({ versionId, documentId }) => sql` + SELECT ${versionColumns} FROM plan_document_versions + WHERE version_id = ${versionId} AND document_id = ${documentId} + `, + }); + + const getLatestVersionRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ documentId: Schema.String }), + Result: PlanVersionRawRow, + execute: ({ documentId }) => sql` + SELECT ${versionColumns} FROM plan_document_versions + WHERE document_id = ${documentId} + ORDER BY revision DESC LIMIT 1 + `, + }); + + const getDraftRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ documentId: Schema.String }), + Result: PlanDraftRawRow, + execute: ({ documentId }) => sql` + SELECT + document_id AS "documentId", + base_version_id AS "baseVersionId", + content_value_json AS "contentValueJson", + updated_by_user_id AS "updatedByUserId", + updated_at AS "updatedAt", + revision_token AS "revisionToken" + FROM plan_document_drafts WHERE document_id = ${documentId} + `, + }); + + // The WHERE clause is the concurrency guard: a save whose expected token no + // longer matches the stored one touches zero rows and surfaces as a conflict. + const saveDraftRow = SqlSchema.findAll({ + Request: Schema.Struct({ + documentId: Schema.String, + baseVersionId: Schema.String, + contentValueJson: Schema.String, + updatedByUserId: Schema.NullOr(UserId), + updatedAt: Schema.String, + expectedRevisionToken: Schema.NullOr(Schema.String), + nextRevisionToken: Schema.String, + }), + Result: Schema.Struct({ documentId: Schema.String }), + execute: (input) => sql` + INSERT INTO plan_document_drafts ( + document_id, base_version_id, content_value_json, + updated_by_user_id, updated_at, revision_token + ) + SELECT + ${input.documentId}, ${input.baseVersionId}, ${input.contentValueJson}, + ${input.updatedByUserId}, ${input.updatedAt}, ${input.nextRevisionToken} + WHERE ( + ${input.expectedRevisionToken} IS NULL + AND NOT EXISTS ( + SELECT 1 FROM plan_document_drafts WHERE document_id = ${input.documentId} + ) + ) + OR ${input.expectedRevisionToken} = ( + SELECT revision_token FROM plan_document_drafts WHERE document_id = ${input.documentId} + ) + ON CONFLICT(document_id) DO UPDATE SET + base_version_id = excluded.base_version_id, + content_value_json = excluded.content_value_json, + updated_by_user_id = excluded.updated_by_user_id, + updated_at = excluded.updated_at, + revision_token = excluded.revision_token + RETURNING document_id AS "documentId" + `, + }); + + const clearDraftRow = SqlSchema.void({ + Request: Schema.Struct({ documentId: Schema.String }), + execute: ({ documentId }) => sql` + DELETE FROM plan_document_drafts WHERE document_id = ${documentId} + `, + }); + + const upsertDiscussionRow = SqlSchema.findAll({ + Result: Schema.Struct({ discussionId: Schema.String }), + Request: Schema.Struct({ + discussionId: Schema.String, + documentId: Schema.String, + anchorVersionId: Schema.String, + quotedText: Schema.String, + createdByUserId: Schema.NullOr(UserId), + createdAt: Schema.String, + }), + execute: (input) => sql` + INSERT INTO plan_discussions ( + discussion_id, document_id, anchor_version_id, quoted_text, + is_resolved, resolved_by_user_id, resolved_at, created_by_user_id, created_at + ) VALUES ( + ${input.discussionId}, ${input.documentId}, ${input.anchorVersionId}, + ${input.quotedText}, 0, NULL, NULL, ${input.createdByUserId}, ${input.createdAt} + ) + ON CONFLICT(discussion_id) DO UPDATE SET + anchor_version_id = excluded.anchor_version_id, + quoted_text = excluded.quoted_text + WHERE plan_discussions.document_id = excluded.document_id + RETURNING discussion_id AS "discussionId" + `, + }); + + const listDiscussionRows = SqlSchema.findAll({ + Request: Schema.Struct({ documentId: Schema.String }), + Result: PlanDiscussionRawRow, + execute: ({ documentId }) => sql` + SELECT ${discussionColumns} FROM plan_discussions + WHERE document_id = ${documentId} + ORDER BY created_at ASC + `, + }); + + // Every discussion statement is scoped by document_id as well as by its own + // id. Callers authorize the document, so an id that belongs to a different + // document must not be reachable through it. + const resolveDiscussionRow = SqlSchema.findAll({ + Request: Schema.Struct({ + discussionId: Schema.String, + documentId: Schema.String, + isResolved: Schema.Boolean, + resolvedByUserId: Schema.NullOr(UserId), + resolvedAt: Schema.NullOr(Schema.String), + }), + Result: Schema.Struct({ discussionId: Schema.String }), + execute: (input) => sql` + UPDATE plan_discussions + SET is_resolved = ${input.isResolved ? 1 : 0}, + resolved_by_user_id = ${input.resolvedByUserId}, + resolved_at = ${input.resolvedAt} + WHERE discussion_id = ${input.discussionId} + AND document_id = ${input.documentId} + RETURNING discussion_id AS "discussionId" + `, + }); + + const addDiscussionCommentRow = SqlSchema.findAll({ + Request: Schema.Struct({ + commentId: Schema.String, + discussionId: Schema.String, + documentId: Schema.String, + authorUserId: Schema.NullOr(UserId), + bodyMarkdown: Schema.String, + createdAt: Schema.String, + }), + Result: Schema.Struct({ commentId: Schema.String }), + execute: (input) => sql` + INSERT INTO plan_discussion_comments ( + comment_id, discussion_id, author_user_id, body_markdown, + is_edited, created_at, updated_at + ) + SELECT + ${input.commentId}, ${input.discussionId}, ${input.authorUserId}, + ${input.bodyMarkdown}, 0, ${input.createdAt}, ${input.createdAt} + WHERE EXISTS ( + SELECT 1 FROM plan_discussions + WHERE discussion_id = ${input.discussionId} AND document_id = ${input.documentId} + ) + ON CONFLICT(comment_id) DO UPDATE SET + body_markdown = excluded.body_markdown, + is_edited = 1, + updated_at = excluded.updated_at + RETURNING comment_id AS "commentId" + `, + }); + + const listDiscussionCommentRows = SqlSchema.findAll({ + Request: Schema.Struct({ documentId: Schema.String }), + Result: PlanDiscussionCommentRawRow, + execute: ({ documentId }) => sql` + SELECT + c.comment_id AS "commentId", + c.discussion_id AS "discussionId", + c.author_user_id AS "authorUserId", + c.body_markdown AS "bodyMarkdown", + c.is_edited AS "isEdited", + c.created_at AS "createdAt", + c.updated_at AS "updatedAt" + FROM plan_discussion_comments c + JOIN plan_discussions d ON d.discussion_id = c.discussion_id + WHERE d.document_id = ${documentId} + ORDER BY c.created_at ASC + `, + }); + + const decodeMany = ( + rows: ReadonlyArray, + decode: (row: unknown) => Effect.Effect, + operation: string, + ): Effect.Effect, PlanReviewRepositoryError> => + Effect.forEach(rows, (row) => decode(row).pipe(Effect.mapError(mapError(operation)))); + + const service: PlanReviewRepository["Service"] = { + upsertDocument: (input) => + upsertDocumentRow(input).pipe(Effect.mapError(mapError("PlanReview.upsertDocument"))), + + getDocument: (documentId) => + getDocumentRow({ documentId }).pipe( + Effect.mapError(mapError("PlanReview.getDocument")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeDocument(row).pipe( + Effect.map(Option.some), + Effect.mapError(mapError("PlanReview.getDocument")), + ), + }), + ), + ), + + listDocumentsForThread: (threadId) => + listDocumentsForThreadRows({ threadId }).pipe( + Effect.mapError(mapError("PlanReview.listDocumentsForThread")), + Effect.flatMap((rows) => + decodeMany(rows, decodeDocument, "PlanReview.listDocumentsForThread"), + ), + ), + + findDocumentBySourcePlanId: (sourcePlanId) => + findDocumentBySourcePlanIdRow({ sourcePlanId }).pipe( + Effect.mapError(mapError("PlanReview.findDocumentBySourcePlanId")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeDocument(row).pipe( + Effect.map(Option.some), + Effect.mapError(mapError("PlanReview.findDocumentBySourcePlanId")), + ), + }), + ), + ), + + setDocumentStatus: (input) => + setDocumentStatusRow(input).pipe(Effect.mapError(mapError("PlanReview.setDocumentStatus"))), + + appendVersion: (input) => + appendVersionRow(input).pipe( + Effect.mapError(mapError("PlanReview.appendVersion")), + Effect.flatMap((rows) => + rows.length > 0 + ? Effect.void + : Effect.fail( + new PlanVersionConflictError({ + documentId: input.documentId, + revision: input.revision, + }), + ), + ), + ), + + listVersions: (documentId) => + listVersionRows({ documentId }).pipe( + Effect.mapError(mapError("PlanReview.listVersions")), + Effect.flatMap((rows) => decodeMany(rows, decodeVersion, "PlanReview.listVersions")), + ), + + getVersion: (input) => + getVersionRow(input).pipe( + Effect.mapError(mapError("PlanReview.getVersion")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeVersion(row).pipe( + Effect.map(Option.some), + Effect.mapError(mapError("PlanReview.getVersion")), + ), + }), + ), + ), + + getLatestVersion: (documentId) => + getLatestVersionRow({ documentId }).pipe( + Effect.mapError(mapError("PlanReview.getLatestVersion")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeVersion(row).pipe( + Effect.map(Option.some), + Effect.mapError(mapError("PlanReview.getLatestVersion")), + ), + }), + ), + ), + + getDraft: (documentId) => + getDraftRow({ documentId }).pipe( + Effect.mapError(mapError("PlanReview.getDraft")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeDraft(row).pipe( + Effect.map(Option.some), + Effect.mapError(mapError("PlanReview.getDraft")), + ), + }), + ), + ), + + saveDraft: (input) => + saveDraftRow(input).pipe( + Effect.mapError(mapError("PlanReview.saveDraft")), + Effect.flatMap((rows) => + rows.length > 0 + ? Effect.void + : Effect.fail(new PlanDraftConflictError({ documentId: input.documentId })), + ), + ), + + clearDraft: (documentId) => + clearDraftRow({ documentId }).pipe(Effect.mapError(mapError("PlanReview.clearDraft"))), + + upsertDiscussion: (input) => + upsertDiscussionRow(input).pipe( + Effect.mapError(mapError("PlanReview.upsertDiscussion")), + Effect.asVoid, + ), + + listDiscussions: (documentId) => + listDiscussionRows({ documentId }).pipe( + Effect.mapError(mapError("PlanReview.listDiscussions")), + Effect.flatMap((rows) => + decodeMany( + rows.map((row) => withBoolean(row as Record, "isResolved")), + decodeDiscussion, + "PlanReview.listDiscussions", + ), + ), + ), + + resolveDiscussion: (input) => + resolveDiscussionRow(input).pipe( + Effect.mapError(mapError("PlanReview.resolveDiscussion")), + Effect.asVoid, + ), + + addDiscussionComment: (input) => + addDiscussionCommentRow(input).pipe( + Effect.mapError(mapError("PlanReview.addDiscussionComment")), + Effect.asVoid, + ), + + listDiscussionComments: (documentId) => + listDiscussionCommentRows({ documentId }).pipe( + Effect.mapError(mapError("PlanReview.listDiscussionComments")), + Effect.flatMap((rows) => + decodeMany( + rows.map((row) => withBoolean(row as Record, "isEdited")), + decodeComment, + "PlanReview.listDiscussionComments", + ), + ), + ), + }; + + return PlanReviewRepository.of(service); +}); + +export const layer = Layer.effect(PlanReviewRepository, make); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index ee60a1f1e17..b167455e906 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -50,6 +50,14 @@ export const ProjectionThread = Schema.Struct({ priority: Schema.NullOr(ThreadPriority), // T3-CUSTOM(expbkt3): durable manual Linear issue URL. linearIssueUrl: Schema.optional(Schema.NullOr(Schema.String)), + // T3-CUSTOM(expbkt3): session lineage; null means this is a root session. + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), + // T3-CUSTOM(expbkt3): BEGIN — JSON-encoded ThreadWorkSummary for the bulk session + // manager. Kept as a raw string here so the projection repository stays a + // dumb column carrier; decoding happens in ProjectionSnapshotQuery, where a + // malformed row degrades to "no summary" instead of failing a whole snapshot. + workSummary: Schema.optional(Schema.NullOr(Schema.String)), + // T3-CUSTOM(expbkt3): END pinnedAt: Schema.NullOr(IsoDateTime), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), diff --git a/apps/server/src/planreview/PlanIngestListener.ts b/apps/server/src/planreview/PlanIngestListener.ts new file mode 100644 index 00000000000..7029bd45ff7 --- /dev/null +++ b/apps/server/src/planreview/PlanIngestListener.ts @@ -0,0 +1,120 @@ +/** + * T3-CUSTOM(expbkt3): captures agent plans as plan-review documents. + * + * Subscribes to `thread.proposed-plan-upserted` and turns every proposed plan + * into a version. Plan ids are `plan:${threadId}:${turnId}`, so each revision + * turn arrives as a new id — the service resolves lineage explicitly rather + * than guessing, and redelivery of an id already captured is a no-op. + */ +import { ThreadId, type OrchestrationProposedPlan } from "@t3tools/contracts"; +import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; +import { withoutPlannotatorPlanMarker } from "@t3tools/shared/plannotator"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { derivePlanTitle, PlanReviewService } from "./PlanReviewService.ts"; + +export interface PlanIngestInput { + readonly threadId: ThreadId; + readonly proposedPlan: OrchestrationProposedPlan; +} + +export class PlanIngestListener extends Context.Service< + PlanIngestListener, + { + /** Captures one plan now. Exposed so tests can drive ingestion directly. */ + readonly ingest: (input: PlanIngestInput) => Effect.Effect; + } +>()("t3/planreview/PlanIngestListener") {} + +/** + * Reconciles plans that landed while the server was down. Reads only the newest + * unimplemented plan per active thread rather than hydrating full history. + */ +export const reconcilePlansOnStartup = Effect.fn("PlanIngestListener.reconcileOnStartup")( + function* ( + query: Pick, + schedule: (input: PlanIngestInput) => Effect.Effect, + ) { + const candidates = yield* query.listLatestProposedPlansForActiveThreads(); + yield* Effect.forEach( + candidates, + ({ threadId, proposedPlan }) => schedule({ threadId, proposedPlan }), + { concurrency: 4, discard: true }, + ); + }, +); + +export const make = Effect.gen(function* () { + const service = yield* PlanReviewService; + const query = yield* ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngineService; + + const capture = (input: PlanIngestInput) => + Effect.gen(function* () { + // An implemented plan is history; there is nothing left to review. + if (input.proposedPlan.implementedAt !== null) return; + + const planMarkdown = withoutPlannotatorPlanMarker(input.proposedPlan.planMarkdown).trim(); + if (planMarkdown.length === 0) return; + + const threadOption = yield* query.getThreadDetailById(input.threadId); + if (Option.isNone(threadOption)) return; + + yield* service.capturePlan({ + threadId: input.threadId, + projectId: threadOption.value.projectId, + planId: input.proposedPlan.id, + planMarkdown, + title: derivePlanTitle(planMarkdown), + authorUserId: null, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("could not capture a proposed plan for review", { + threadId: input.threadId, + planId: input.proposedPlan.id, + cause: String(cause), + }), + ), + ); + + // Coalesce per plan so a burst of streaming-plan upserts captures once, with + // the newest body winning. + const worker = yield* makeKeyedCoalescingWorker({ + merge: (current, next) => + next.proposedPlan.updatedAt >= current.proposedPlan.updatedAt ? next : current, + process: (_key, value) => capture(value), + }); + + const schedule = (input: PlanIngestInput) => + worker.enqueue(`${input.threadId}:${input.proposedPlan.id}`, input); + + // Subscribe before reconciling so a plan emitted during startup cannot fall + // into the gap between the two operations. + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => + event.type === "thread.proposed-plan-upserted" + ? schedule({ + threadId: event.payload.threadId, + proposedPlan: event.payload.proposedPlan, + }) + : Effect.void, + ), + ); + + yield* reconcilePlansOnStartup(query, schedule).pipe( + Effect.catchCause((cause) => + Effect.logWarning("could not reconcile proposed plans for review", { cause: String(cause) }), + ), + ); + + return PlanIngestListener.of({ ingest: capture }); +}); + +export const layer = Layer.effect(PlanIngestListener, make); diff --git a/apps/server/src/planreview/PlanReviewContextPolicy.test.ts b/apps/server/src/planreview/PlanReviewContextPolicy.test.ts new file mode 100644 index 00000000000..9fae69da0a2 --- /dev/null +++ b/apps/server/src/planreview/PlanReviewContextPolicy.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + decidePlanResend, + shouldSendFullDocumentInsteadOfDiff, +} from "./PlanReviewContextPolicy.ts"; + +const baseSignals = { + latestCompactionAt: null, + planCreatedAt: "2026-08-07T10:00:00.000Z", + planThreadId: "thread-1", + targetThreadId: "thread-1", + providerSessionStatus: "running", +} as const; + +describe("decidePlanResend", () => { + it("keeps the prompt short when the model can still see the plan", () => { + expect(decidePlanResend(baseSignals)).toEqual({ shouldResend: false, reason: null }); + }); + + it("resends when implementation moves to another thread", () => { + const decision = decidePlanResend({ ...baseSignals, targetThreadId: "thread-2" }); + expect(decision.shouldResend).toBe(true); + expect(decision.reason).toContain("different session"); + }); + + it("resends when the thread compacted after the plan was written", () => { + const decision = decidePlanResend({ + ...baseSignals, + latestCompactionAt: "2026-08-07T11:00:00.000Z", + }); + expect(decision.shouldResend).toBe(true); + expect(decision.reason).toContain("compacted"); + }); + + it("ignores a compaction that happened before the plan", () => { + expect( + decidePlanResend({ ...baseSignals, latestCompactionAt: "2026-08-07T09:00:00.000Z" }) + .shouldResend, + ).toBe(false); + }); + + it("resends when no provider session is bound", () => { + expect(decidePlanResend({ ...baseSignals, providerSessionStatus: null }).shouldResend).toBe( + true, + ); + }); + + it("resends when the provider session has stopped", () => { + const decision = decidePlanResend({ ...baseSignals, providerSessionStatus: "stopped" }); + expect(decision.shouldResend).toBe(true); + expect(decision.reason).toContain("no longer running"); + }); + + it("prefers the cross-thread reason when several signals fire at once", () => { + const decision = decidePlanResend({ + ...baseSignals, + targetThreadId: "thread-2", + providerSessionStatus: null, + latestCompactionAt: "2026-08-07T23:00:00.000Z", + }); + expect(decision.reason).toContain("different session"); + }); +}); + +describe("shouldSendFullDocumentInsteadOfDiff", () => { + it("prefers a diff for ordinary edits", () => { + expect(shouldSendFullDocumentInsteadOfDiff(0.1)).toBe(false); + expect(shouldSendFullDocumentInsteadOfDiff(0.6)).toBe(false); + }); + + it("sends the whole document once the diff stops being smaller", () => { + expect(shouldSendFullDocumentInsteadOfDiff(0.61)).toBe(true); + expect(shouldSendFullDocumentInsteadOfDiff(1)).toBe(true); + }); +}); diff --git a/apps/server/src/planreview/PlanReviewContextPolicy.ts b/apps/server/src/planreview/PlanReviewContextPolicy.ts new file mode 100644 index 00000000000..a7e4785b7ce --- /dev/null +++ b/apps/server/src/planreview/PlanReviewContextPolicy.ts @@ -0,0 +1,74 @@ +/** + * T3-CUSTOM(expbkt3): decides whether an approval must re-send the plan body. + * + * The default answer is no — the model wrote the plan and still has it in + * context, so repeating it wastes thousands of tokens per approval. We only + * repeat it when the model demonstrably cannot see it any more. + */ + +export interface PlanResendSignals { + /** + * ISO timestamp of the most recent `context-compaction` activity on the + * thread, or null when the thread has never compacted. + */ + readonly latestCompactionAt: string | null; + /** ISO timestamp of the version the agent proposed. */ + readonly planCreatedAt: string; + /** Thread the plan was authored in. */ + readonly planThreadId: string; + /** Thread the implementation turn will start in. */ + readonly targetThreadId: string; + /** Provider session status bound to the target thread, null when unbound. */ + readonly providerSessionStatus: string | null; +} + +export interface PlanResendDecision { + readonly shouldResend: boolean; + /** + * Human-readable clause completing "The full plan is repeated because …". + * Null when nothing is resent. + */ + readonly reason: string | null; +} + +/** + * Returns whether the approval prompt must carry the whole plan. + * + * Three signals force a resend; anything else keeps the prompt to one line. + */ +export function decidePlanResend(signals: PlanResendSignals): PlanResendDecision { + if (signals.targetThreadId !== signals.planThreadId) { + return { + shouldResend: true, + reason: "it is being implemented in a different session from the one that planned it", + }; + } + + if (signals.latestCompactionAt !== null && signals.latestCompactionAt > signals.planCreatedAt) { + return { + shouldResend: true, + reason: "this session compacted its context after the plan was written", + }; + } + + // A stopped or absent provider session means the next turn boots a fresh + // process, which will not have replayed the planning turn. + if (signals.providerSessionStatus === null || signals.providerSessionStatus === "stopped") { + return { + shouldResend: true, + reason: "the planning session is no longer running", + }; + } + + return { shouldResend: false, reason: null }; +} + +/** + * Guards the feedback path: a diff that rewrites most of the document is + * neither smaller than the document nor easier to read. + */ +export const MAX_DIFF_CHANGE_RATIO = 0.6; + +export function shouldSendFullDocumentInsteadOfDiff(changeRatio: number): boolean { + return changeRatio > MAX_DIFF_CHANGE_RATIO; +} diff --git a/apps/server/src/planreview/PlanReviewService.test.ts b/apps/server/src/planreview/PlanReviewService.test.ts new file mode 100644 index 00000000000..62ebb300a5f --- /dev/null +++ b/apps/server/src/planreview/PlanReviewService.test.ts @@ -0,0 +1,841 @@ +/** + * T3-CUSTOM(expbkt3): round-trip coverage for the native plan review service. + * + * The prompts are the product here — the whole point of the feature is that an + * approval stops re-sending the plan and feedback carries anchors instead of a + * document — so every test asserts the exact text handed to the agent. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ThreadId, UserId, type OrchestrationCommand } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { OrchestrationCommandDispatcher } from "../orchestration/dispatchCommand.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as PlanReviewDocuments from "../persistence/PlanReviewDocuments.ts"; +import * as PlanReviewServiceModule from "./PlanReviewService.ts"; +import { derivePlanTitle, PlanReviewService } from "./PlanReviewService.ts"; + +const threadId = ThreadId.make("thread-plan-review"); +const otherThreadId = ThreadId.make("thread-other"); +const reviewerId = UserId.make("user_reviewer"); + +const PLAN = [ + "# Auth rewrite", + "", + "## Steps", + "", + "1. Add the migration", + "2. Backfill the rows", + "3. Flip the flag", +].join("\n"); + +interface ThreadStub { + readonly sessionStatus: string | null; + readonly compactionAt: string | null; + /** Plans already on the thread, as the projection would report them. */ + readonly proposedPlans?: ReadonlyArray<{ + readonly id: string; + readonly planMarkdown: string; + readonly implementedAt: string | null; + readonly updatedAt: string; + }>; +} + +/** + * Captures dispatched commands so a test can assert what reached the thread, + * and stubs the one projection read the service performs. + */ +const makeHarness = (thread: ThreadStub) => + Effect.gen(function* () { + const dispatched = yield* Ref.make>([]); + + const dispatcherLayer = Layer.succeed( + OrchestrationCommandDispatcher, + OrchestrationCommandDispatcher.of({ + dispatch: (command) => + Ref.update(dispatched, (current) => [...current, command]).pipe( + Effect.as({ sequence: 1 }), + ), + }), + ); + + const queryLayer = Layer.succeed( + ProjectionSnapshotQuery, + ProjectionSnapshotQuery.of({ + getThreadDetailById: () => + Effect.succeed( + Option.some({ + id: threadId, + projectId: "project-1", + modelSelection: undefined, + runtimeMode: "local", + proposedPlans: thread.proposedPlans ?? [], + session: thread.sessionStatus === null ? null : { status: thread.sessionStatus }, + activities: + thread.compactionAt === null + ? [] + : [{ kind: "context-compaction", createdAt: thread.compactionAt }], + } as never), + ), + } as never), + ); + + return { dispatched, dispatcherLayer, queryLayer }; + }); + +const runWithService = ( + thread: ThreadStub, + body: (input: { + readonly service: PlanReviewService["Service"]; + readonly dispatched: Ref.Ref>; + }) => Effect.Effect, +) => + Effect.gen(function* () { + const harness = yield* makeHarness(thread); + const layer = PlanReviewServiceModule.layer.pipe( + Layer.provide(PlanReviewDocuments.layer), + Layer.provide(harness.dispatcherLayer), + Layer.provide(harness.queryLayer), + Layer.provide(MigrationsLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provide(NodeServices.layer), + ); + + return yield* Effect.gen(function* () { + const service = yield* PlanReviewService; + return yield* body({ service, dispatched: harness.dispatched }); + }).pipe(Effect.provide(layer)); + }); + +const capturePlan = ( + service: PlanReviewService["Service"], + planId: string, + markdown = PLAN, + onThread: ThreadId = threadId, +) => + service.capturePlan({ + threadId: onThread, + projectId: "project-1", + planId: planId as never, + planMarkdown: markdown, + title: derivePlanTitle(markdown), + authorUserId: null, + }); + +const turnText = (commands: ReadonlyArray): string => { + const turn = commands.find((command) => command.type === "thread.turn.start"); + if (turn === undefined || turn.type !== "thread.turn.start") { + throw new Error("no turn was started"); + } + return turn.message.text; +}; + +describe("derivePlanTitle", () => { + it("uses the first heading", () => { + expect(derivePlanTitle(PLAN)).toBe("Auth rewrite"); + }); + + it("falls back to the first non-empty line", () => { + expect(derivePlanTitle("\n\nJust do the thing\n")).toBe("Just do the thing"); + }); + + it("falls back to a constant for an empty plan", () => { + expect(derivePlanTitle(" \n\n")).toBe("Plan"); + }); +}); + +describe("PlanReviewService capture", () => { + it.effect("captures the agent plan as version 1", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + const snapshot = yield* service.getReview(document.documentId); + + expect(snapshot.versions).toHaveLength(1); + expect(snapshot.versions[0]?.revision).toBe(1); + expect(snapshot.versions[0]?.authorKind).toBe("agent"); + expect(snapshot.versions[0]?.origin).toBe("agent-proposed"); + expect(snapshot.document.title).toBe("Auth rewrite"); + expect(snapshot.document.status).toBe("open"); + }), + ), + ); + + it.effect("treats a redelivered plan id as a no-op", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* capturePlan(service, "plan:a"); + const snapshot = yield* service.getReview(document.documentId); + + expect(snapshot.versions).toHaveLength(1); + }), + ), + ); + + it.effect("appends an agent revision to the same lineage", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* capturePlan(service, "plan:b", `${PLAN}\n4. Announce it`); + const snapshot = yield* service.getReview(document.documentId); + + expect(snapshot.versions).toHaveLength(2); + expect(snapshot.versions[1]?.origin).toBe("agent-revision"); + expect(snapshot.versions[1]?.revision).toBe(2); + expect(snapshot.document.currentRevision).toBe(2); + }), + ), + ); + + it.effect("ignores a revision whose body did not change", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* capturePlan(service, "plan:b", PLAN); + const snapshot = yield* service.getReview(document.documentId); + + expect(snapshot.versions).toHaveLength(1); + }), + ), + ); +}); + +describe("PlanReviewService HTML plans", () => { + const HTML_PLAN = [ + "Quarterly growth", + '

Growth

', + ].join(""); + + it.effect("records the renderer and titles from the document", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:html", HTML_PLAN); + expect(document.format).toBe("html"); + expect(document.title).toBe("Quarterly growth"); + }), + ), + ); + + it.effect("keeps a markdown plan on the markdown renderer", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:md"); + expect(document.format).toBe("md"); + }), + ), + ); + + it.effect("does not treat inline HTML in markdown as an HTML plan", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan( + service, + "plan:inline", + `${PLAN}\n\nUse
for the wrapper.`, + ); + expect(document.format).toBe("md"); + }), + ), + ); +}); + +describe("PlanReviewService listForThread", () => { + it.effect("captures a plan that startup reconciliation never reached", () => + runWithService( + { + sessionStatus: "running", + compactionAt: null, + proposedPlans: [ + { + id: "plan:pre-existing", + planMarkdown: PLAN, + implementedAt: null, + updatedAt: "2026-08-07T10:00:00.000Z", + }, + ], + }, + ({ service }) => + Effect.gen(function* () { + // Nothing captured this plan, so without a backfill the UI would + // offer no way into it. + const documents = yield* service.listForThread(threadId); + expect(documents).toHaveLength(1); + expect(documents[0]?.status).toBe("open"); + expect(documents[0]?.title).toBe("Auth rewrite"); + + // Idempotent: a second read must not create a second lineage. + expect(yield* service.listForThread(threadId)).toHaveLength(1); + }), + ), + ); + + it.effect("ignores an already-implemented plan", () => + runWithService( + { + sessionStatus: "running", + compactionAt: null, + proposedPlans: [ + { + id: "plan:done", + planMarkdown: PLAN, + implementedAt: "2026-08-07T11:00:00.000Z", + updatedAt: "2026-08-07T10:00:00.000Z", + }, + ], + }, + ({ service }) => + Effect.gen(function* () { + expect(yield* service.listForThread(threadId)).toHaveLength(0); + }), + ), + ); + + it.effect("does not backfill over a review that is already resolved", () => + runWithService( + { + sessionStatus: "running", + compactionAt: null, + proposedPlans: [ + { + id: "plan:a", + planMarkdown: PLAN, + implementedAt: null, + updatedAt: "2026-08-07T10:00:00.000Z", + }, + ], + }, + ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* service.submit({ + documentId: document.documentId, + decision: "approved", + globalComment: "", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + // The plan is approved; re-listing must not resurrect it as a new + // open review. + const documents = yield* service.listForThread(threadId); + expect(documents).toHaveLength(1); + expect(documents[0]?.status).toBe("approved"); + }), + ), + ); +}); + +describe("PlanReviewService approval", () => { + it.effect("sends a short ack instead of the plan body", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service, dispatched }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + const result = yield* service.submit({ + documentId: document.documentId, + decision: "approved", + globalComment: "", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + expect(result.resentPlan).toBe(false); + expect(result.prompt).toBe( + "Plan approved. Implement the plan you proposed above, exactly as written.", + ); + + const commands = yield* Ref.get(dispatched); + expect(turnText(commands)).not.toContain("Flip the flag"); + + // Only approval may leave Plan mode. + const modeCommand = commands.find( + (command) => command.type === "thread.interaction-mode.set", + ); + expect(modeCommand).toBeDefined(); + + const turn = commands.find((command) => command.type === "thread.turn.start"); + expect(turn?.type === "thread.turn.start" && turn.interactionMode).toBe("default"); + expect(turn?.type === "thread.turn.start" && turn.sourceProposedPlan?.planId).toBe( + "plan:a", + ); + }), + ), + ); + + it.effect("re-sends the plan when the thread compacted after it", () => + runWithService( + { sessionStatus: "running", compactionAt: "2099-01-01T00:00:00.000Z" }, + ({ service, dispatched }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + const result = yield* service.submit({ + documentId: document.documentId, + decision: "approved", + globalComment: "", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + expect(result.resentPlan).toBe(true); + const text = turnText(yield* Ref.get(dispatched)); + expect(text).toContain("PLEASE IMPLEMENT THIS APPROVED PLAN:"); + expect(text).toContain("3. Flip the flag"); + expect(text).toContain("compacted its context"); + }), + ), + ); + + it.effect("re-sends the plan when no provider session is bound", () => + runWithService({ sessionStatus: null, compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + const result = yield* service.submit({ + documentId: document.documentId, + decision: "approved", + globalComment: "", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + expect(result.resentPlan).toBe(true); + }), + ), + ); + + it.effect("carries reviewer edits as a diff and records a human version", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service, dispatched }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* service.submit({ + documentId: document.documentId, + decision: "approved", + globalComment: "", + editedMarkdown: PLAN.replace("Flip the flag", "Flip the flag behind a kill switch"), + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + const text = turnText(yield* Ref.get(dispatched)); + expect(text).toContain("The reviewer edited the plan before approving."); + expect(text).toContain("+3. Flip the flag behind a kill switch"); + expect(text).not.toContain("1. Add the migration\n2. Backfill"); + + const snapshot = yield* service.getReview(document.documentId); + expect(snapshot.versions).toHaveLength(2); + expect(snapshot.versions[1]?.authorKind).toBe("user"); + expect(snapshot.versions[1]?.authorUserId).toBe(reviewerId); + expect(snapshot.document.status).toBe("approved"); + }), + ), + ); +}); + +describe("PlanReviewService feedback", () => { + it.effect("sends anchored comments without the plan body", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service, dispatched }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* service.upsertDiscussion({ + documentId: document.documentId, + discussionId: "discussion-1", + quotedText: "2. Backfill the rows", + bodyMarkdown: "Split this into its own migration.", + actorUserId: reviewerId, + }); + + yield* service.submit({ + documentId: document.documentId, + decision: "changes-requested", + globalComment: "Too broad overall.", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + const commands = yield* Ref.get(dispatched); + const text = turnText(commands); + + expect(text).toContain("Revise the plan you proposed."); + expect(text).toContain("Too broad overall."); + expect(text).toContain(" command.type === "thread.interaction-mode.set")).toBe( + false, + ); + const turn = commands.find((command) => command.type === "thread.turn.start"); + expect(turn?.type === "thread.turn.start" && turn.interactionMode).toBe("plan"); + + const snapshot = yield* service.getReview(document.documentId); + expect(snapshot.document.status).toBe("changes-requested"); + }), + ), + ); + + it.effect("omits a resolved discussion from the feedback", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service, dispatched }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* service.upsertDiscussion({ + documentId: document.documentId, + discussionId: "discussion-1", + quotedText: "2. Backfill the rows", + bodyMarkdown: "Split this into its own migration.", + actorUserId: reviewerId, + }); + yield* service.resolveDiscussion({ + documentId: document.documentId, + discussionId: "discussion-1", + isResolved: true, + actorUserId: reviewerId, + }); + + yield* service.submit({ + documentId: document.documentId, + decision: "changes-requested", + globalComment: "Still too broad.", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + const text = turnText(yield* Ref.get(dispatched)); + expect(text).toContain("Still too broad."); + expect(text).not.toContain(" + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service, dispatched }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + const result = yield* service.submit({ + documentId: document.documentId, + decision: "discarded", + globalComment: "", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + expect(result.turnStarted).toBe(false); + expect(result.prompt).toBeNull(); + + const commands = yield* Ref.get(dispatched); + expect(commands.some((command) => command.type === "thread.turn.start")).toBe(false); + + const snapshot = yield* service.getReview(document.documentId); + expect(snapshot.document.status).toBe("discarded"); + }), + ), + ); +}); + +describe("PlanReviewService regressions", () => { + it.effect("keeps the lineage when the agent answers feedback", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* service.upsertDiscussion({ + documentId: document.documentId, + discussionId: "discussion-1", + quotedText: "2. Backfill the rows", + bodyMarkdown: "Split this.", + actorUserId: reviewerId, + }); + yield* service.submit({ + documentId: document.documentId, + decision: "changes-requested", + globalComment: "", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + // The agent's answer must append to the same document, not start a new + // history that orphans the comments that asked for it. + const revised = yield* capturePlan(service, "plan:b", `${PLAN}\n4. Announce it`); + expect(revised.documentId).toBe(document.documentId); + expect(revised.status).toBe("open"); + + const snapshot = yield* service.getReview(document.documentId); + expect(snapshot.versions).toHaveLength(2); + expect(snapshot.versions[1]?.origin).toBe("agent-revision"); + }), + ), + ); + + it.effect("does not re-send comments that already reached the agent", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service, dispatched }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* service.upsertDiscussion({ + documentId: document.documentId, + discussionId: "discussion-1", + quotedText: "2. Backfill the rows", + bodyMarkdown: "Split this.", + actorUserId: reviewerId, + }); + yield* service.submit({ + documentId: document.documentId, + decision: "changes-requested", + globalComment: "", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + yield* capturePlan(service, "plan:b", `${PLAN}\n4. Announce it`); + + yield* service.submit({ + documentId: document.documentId, + decision: "changes-requested", + globalComment: "Second round.", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + const turns = (yield* Ref.get(dispatched)).filter( + (command) => command.type === "thread.turn.start", + ); + expect(turns).toHaveLength(2); + const second = turns[1]; + const secondText = second?.type === "thread.turn.start" ? second.message.text : ""; + expect(secondText).toContain("Second round."); + expect(secondText).not.toContain("Split this."); + }), + ), + ); + + it.effect("refuses a second decision on the same review", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service, dispatched }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + const approve = { + documentId: document.documentId, + decision: "approved" as const, + globalComment: "", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }; + + yield* service.submit(approve); + const second = yield* service.submit(approve).pipe(Effect.exit); + expect(second._tag).toBe("Failure"); + + // The agent must not be told to implement the plan twice. + const turns = (yield* Ref.get(dispatched)).filter( + (command) => command.type === "thread.turn.start", + ); + expect(turns).toHaveLength(1); + }), + ), + ); + + it.effect("refuses to edit a review that is no longer open", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* service.submit({ + documentId: document.documentId, + decision: "discarded", + globalComment: "", + editedMarkdown: null, + actorUserId: reviewerId, + actorLabel: "Tushar", + }); + + const saved = yield* service + .saveDraft({ + documentId: document.documentId, + contentValueJson: '{"markdown":"late"}', + expectedRevisionToken: null, + actorUserId: reviewerId, + }) + .pipe(Effect.exit); + expect(saved._tag).toBe("Failure"); + }), + ), + ); + + it.effect("does not reach a discussion through a document the caller owns", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const victim = yield* capturePlan(service, "plan:victim"); + yield* service.upsertDiscussion({ + documentId: victim.documentId, + discussionId: "discussion-victim", + quotedText: "2. Backfill the rows", + bodyMarkdown: "Split this.", + actorUserId: reviewerId, + }); + const attacker = yield* capturePlan(service, "plan:attacker", PLAN, otherThreadId); + + // The caller authorizes their own document, then names a discussion id + // from a thread they cannot read. Both writes must miss. + yield* service.resolveDiscussion({ + documentId: attacker.documentId, + discussionId: "discussion-victim", + isResolved: true, + actorUserId: reviewerId, + }); + yield* service.upsertDiscussion({ + documentId: attacker.documentId, + discussionId: "discussion-victim", + quotedText: "injected quote", + bodyMarkdown: "injected body", + actorUserId: reviewerId, + }); + + const snapshot = yield* service.getReview(victim.documentId); + expect(snapshot.discussions).toHaveLength(1); + expect(snapshot.discussions[0]?.isResolved).toBe(false); + expect(snapshot.discussions[0]?.quotedText).toBe("2. Backfill the rows"); + expect(snapshot.comments.map((comment) => comment.bodyMarkdown)).toEqual(["Split this."]); + }), + ), + ); + + it.effect("does not diff versions belonging to another document", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const victim = yield* capturePlan(service, "plan:victim"); + const attacker = yield* capturePlan(service, "plan:attacker", PLAN, otherThreadId); + const victimVersions = yield* service.getReview(victim.documentId); + const versionId = victimVersions.versions[0]!.versionId; + + const result = yield* service + .getVersionDiff({ + documentId: attacker.documentId, + fromVersionId: versionId, + toVersionId: versionId, + }) + .pipe(Effect.exit); + expect(result._tag).toBe("Failure"); + }), + ), + ); +}); + +describe("PlanReviewService drafts", () => { + it.effect("rejects a save that carried a stale revision token", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + + const first = yield* service.saveDraft({ + documentId: document.documentId, + contentValueJson: '{"markdown":"one"}', + expectedRevisionToken: null, + actorUserId: reviewerId, + }); + + // A second writer who never saw `first` still holds the old token. + const conflict = yield* service + .saveDraft({ + documentId: document.documentId, + contentValueJson: '{"markdown":"two"}', + expectedRevisionToken: null, + actorUserId: reviewerId, + }) + .pipe(Effect.exit); + + expect(conflict._tag).toBe("Failure"); + + const accepted = yield* service.saveDraft({ + documentId: document.documentId, + contentValueJson: '{"markdown":"three"}', + expectedRevisionToken: first.revisionToken, + actorUserId: reviewerId, + }); + expect(accepted.revisionToken).not.toBe(first.revisionToken); + }), + ), + ); + + it.effect("rejects a stale token even after the draft was cleared", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + const first = yield* service.saveDraft({ + documentId: document.documentId, + contentValueJson: '{"markdown":"one"}', + expectedRevisionToken: null, + actorUserId: reviewerId, + }); + + // An agent revision invalidates the draft it was based on. + yield* capturePlan(service, "plan:b", `${PLAN}\n4. Announce it`); + + // Resurrecting it with the pre-revision token would record content + // against a version it was never derived from. + const stale = yield* service + .saveDraft({ + documentId: document.documentId, + contentValueJson: '{"markdown":"one"}', + expectedRevisionToken: first.revisionToken, + actorUserId: reviewerId, + }) + .pipe(Effect.exit); + expect(stale._tag).toBe("Failure"); + }), + ), + ); + + it.effect("clears the draft when an agent revision lands", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* service.saveDraft({ + documentId: document.documentId, + contentValueJson: '{"markdown":"mine"}', + expectedRevisionToken: null, + actorUserId: reviewerId, + }); + + yield* capturePlan(service, "plan:b", `${PLAN}\n4. Announce it`); + + const snapshot = yield* service.getReview(document.documentId); + expect(snapshot.draft).toBeNull(); + }), + ), + ); +}); + +describe("PlanReviewService version diff", () => { + it.effect("renders a diff between two versions", () => + runWithService({ sessionStatus: "running", compactionAt: null }, ({ service }) => + Effect.gen(function* () { + const document = yield* capturePlan(service, "plan:a"); + yield* capturePlan(service, "plan:b", `${PLAN}\n4. Announce it`); + const snapshot = yield* service.getReview(document.documentId); + + const diff = yield* service.getVersionDiff({ + documentId: document.documentId, + fromVersionId: snapshot.versions[0]!.versionId, + toVersionId: snapshot.versions[1]!.versionId, + }); + + expect(diff.diff).toContain("diff --git a/Auth rewrite.md"); + expect(diff.diff).toContain("+4. Announce it"); + }), + ), + ); +}); diff --git a/apps/server/src/planreview/PlanReviewService.ts b/apps/server/src/planreview/PlanReviewService.ts new file mode 100644 index 00000000000..758a0d8ef9e --- /dev/null +++ b/apps/server/src/planreview/PlanReviewService.ts @@ -0,0 +1,926 @@ +/** + * T3-CUSTOM(expbkt3): native plan review service. + * + * Owns the plan document lifecycle: capture the agent's plan as version 1, + * accumulate attributed human edits and discussions, cut new versions, and + * feed the outcome back into the thread as a normal turn. Nothing here is an + * orchestration aggregate — the review lives in fork-owned tables and reaches + * the thread only through the existing `thread.activity.append` and + * `thread.turn.start` commands, so upstream contracts are untouched. + */ +import { + CommandId, + EventId, + MessageId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProposedPlanId, + type UserId, +} from "@t3tools/contracts"; +import { withoutPlannotatorPlanMarker } from "@t3tools/shared/plannotator"; +import { detectPlannotatorPlanFormat } from "../plannotator/planFormat.ts"; +import { + buildPlanReviewApprovalPrompt, + buildPlanReviewFeedbackPrompt, + locateQuotedLineRange, + type PlanReviewAnchoredComment, +} from "@t3tools/shared/planReview"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { OrchestrationCommandDispatcher } from "../orchestration/dispatchCommand.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + PlanDraftConflictError, + PlanReviewRepository, + PlanVersionConflictError, + type PlanDiscussionCommentRecord, + type PlanDiscussionRecord, + type PlanDocumentRecord, + type PlanDocumentStatus, + type PlanVersionRecord, +} from "../persistence/PlanReviewDocuments.ts"; +import { buildUnifiedDiff, toRenderableFileDiff } from "./planReviewDiff.ts"; +import { + decidePlanResend, + shouldSendFullDocumentInsteadOfDiff, +} from "./PlanReviewContextPolicy.ts"; + +export class PlanReviewNotFoundError extends Schema.TaggedErrorClass()( + "PlanReviewNotFoundError", + { documentId: Schema.String }, +) {} + +export class PlanReviewInvariantError extends Schema.TaggedErrorClass()( + "PlanReviewInvariantError", + { operation: Schema.String, detail: Schema.String }, +) {} + +export type PlanReviewServiceError = + | PlanReviewNotFoundError + | PlanReviewInvariantError + | PlanDraftConflictError + | PlanVersionConflictError; + +export interface PlanReviewSnapshot { + readonly document: PlanDocumentRecord; + readonly versions: ReadonlyArray; + readonly draft: { + readonly contentValueJson: string; + readonly baseVersionId: string; + readonly revisionToken: string; + readonly updatedByUserId: UserId | null; + readonly updatedAt: string; + } | null; + readonly discussions: ReadonlyArray; + readonly comments: ReadonlyArray; +} + +export interface CapturePlanInput { + readonly threadId: ThreadId; + readonly projectId: string; + readonly planId: OrchestrationProposedPlanId; + readonly planMarkdown: string; + readonly title: string; + /** Null for agent-authored versions. */ + readonly authorUserId: UserId | null; +} + +export interface SubmitReviewInput { + readonly documentId: string; + readonly decision: "approved" | "changes-requested" | "discarded"; + readonly globalComment: string; + /** Reviewer-edited markdown, when the plan was edited. */ + readonly editedMarkdown: string | null; + readonly actorUserId: UserId | null; + readonly actorLabel: string | null; +} + +export interface SubmitReviewResult { + readonly documentId: string; + readonly status: PlanDocumentStatus; + /** The exact text handed to the agent, so tests and the UI can assert it. */ + readonly prompt: string | null; + readonly turnStarted: boolean; + readonly resentPlan: boolean; +} + +export class PlanReviewService extends Context.Service< + PlanReviewService, + { + readonly capturePlan: ( + input: CapturePlanInput, + ) => Effect.Effect; + readonly getReview: ( + documentId: string, + ) => Effect.Effect; + readonly listForThread: ( + threadId: ThreadId, + ) => Effect.Effect, PlanReviewServiceError>; + readonly saveDraft: (input: { + readonly documentId: string; + readonly contentValueJson: string; + readonly expectedRevisionToken: string | null; + readonly actorUserId: UserId | null; + }) => Effect.Effect<{ readonly revisionToken: string }, PlanReviewServiceError>; + readonly cutVersion: (input: { + readonly documentId: string; + readonly contentMarkdown: string; + readonly contentValueJson: string | null; + readonly summary: string | null; + readonly actorUserId: UserId | null; + }) => Effect.Effect; + readonly upsertDiscussion: (input: { + readonly documentId: string; + readonly discussionId: string; + readonly quotedText: string; + readonly bodyMarkdown: string; + readonly actorUserId: UserId | null; + }) => Effect.Effect; + readonly resolveDiscussion: (input: { + readonly documentId: string; + readonly discussionId: string; + readonly isResolved: boolean; + readonly actorUserId: UserId | null; + }) => Effect.Effect; + readonly getVersionDiff: (input: { + readonly documentId: string; + readonly fromVersionId: string; + readonly toVersionId: string; + }) => Effect.Effect<{ readonly diff: string }, PlanReviewServiceError>; + readonly submit: ( + input: SubmitReviewInput, + ) => Effect.Effect; + /** + * Emits a snapshot for `documentId` on subscribe and again after every + * mutation from any client, so open panels converge without polling. + */ + readonly watch: ( + documentId: string, + ) => Stream.Stream; + } +>()("t3/planreview/PlanReviewService") {} + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +/** First heading, else first non-empty line, capped so titles stay tab-sized. */ +export function derivePlanTitle(markdown: string): string { + if (detectPlannotatorPlanFormat(markdown) === "html") { + const titled = + /]*>([\s\S]*?)<\/title>/i.exec(markdown) ?? + /]*>([\s\S]*?)<\/h1>/i.exec(markdown); + const text = titled?.[1] + ?.replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (text) return text.length > 80 ? `${text.slice(0, 77)}…` : text; + return "HTML plan"; + } + + for (const rawLine of markdown.split("\n")) { + const line = rawLine.trim(); + if (line.length === 0) continue; + const heading = line.match(/^#{1,6}\s+(.*)$/); + const candidate = (heading?.[1] ?? line).trim(); + if (candidate.length === 0) continue; + return candidate.length > 80 ? `${candidate.slice(0, 77)}…` : candidate; + } + return "Plan"; +} + +export const make = Effect.gen(function* () { + const repository = yield* PlanReviewRepository; + const dispatcher = yield* OrchestrationCommandDispatcher; + const query = yield* ProjectionSnapshotQuery; + const crypto = yield* Crypto.Crypto; + + // A failing CSPRNG is a defect, not something a caller can recover from. + const uuid = crypto.randomUUIDv4.pipe(Effect.orDie); + + // Mutations announce the document they touched; `watch` re-reads from there. + const changes = yield* PubSub.unbounded(); + const announce = (documentId: string) => PubSub.publish(changes, documentId).pipe(Effect.ignore); + + /** + * Repository and platform failures are infrastructure detail the caller + * cannot act on, so they collapse into one invariant error. The two conflict + * errors are the exception: callers retry or surface them to the reviewer. + */ + const asInvariant = + (operation: string) => + ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.mapError( + (cause): PlanReviewServiceError => + cause._tag === "PlanDraftConflictError" || cause._tag === "PlanVersionConflictError" + ? (cause as unknown as PlanDraftConflictError | PlanVersionConflictError) + : new PlanReviewInvariantError({ operation, detail: cause.message }), + ), + ); + + const requireDocument = (documentId: string) => + repository.getDocument(documentId).pipe( + asInvariant("getDocument"), + Effect.flatMap( + Option.match({ + onNone: (): Effect.Effect => + Effect.fail(new PlanReviewNotFoundError({ documentId })), + onSome: Effect.succeed, + }), + ), + ); + + const appendActivity = (input: { + readonly threadId: ThreadId; + readonly summary: string; + readonly tone: "info" | "approval" | "error"; + readonly payload: unknown; + }) => + Effect.gen(function* () { + const [commandUuid, eventUuid, createdAt] = yield* Effect.all([uuid, uuid, nowIso]); + return yield* dispatcher.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(`plan-review:activity:${commandUuid}`), + threadId: input.threadId, + activity: { + id: EventId.make(`plan-review:${eventUuid}`), + tone: input.tone, + kind: "plan-review", + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt, + }, + createdAt, + }); + }).pipe(Effect.ignore); + + const capturePlan: PlanReviewService["Service"]["capturePlan"] = (input) => + Effect.gen(function* () { + // A plan id we have already captured means this is a redelivery, not a + // new revision — return the existing document untouched. + const existingForPlan = yield* repository + .findDocumentBySourcePlanId(input.planId) + .pipe(asInvariant("capturePlan.findBySourcePlan")); + if (Option.isSome(existingForPlan)) return existingForPlan.value; + + // An open document on the same thread is the lineage this plan revises. + // A lineage awaiting a revision is still the lineage this plan belongs + // to. Only approved and discarded documents are closed for good — without + // "changes-requested" here, the agent's answer to feedback would start a + // brand-new history and orphan the comments that asked for it. + const threadDocuments = yield* repository + .listDocumentsForThread(input.threadId) + .pipe(asInvariant("capturePlan.listForThread")); + const openDocument = threadDocuments.find( + (document) => document.status === "open" || document.status === "changes-requested", + ); + + const createdAt = yield* nowIso; + + if (openDocument === undefined) { + const documentUuid = yield* uuid; + const documentId = `plan-doc:${documentUuid}`; + const versionUuid = yield* uuid; + + const document: PlanDocumentRecord = { + documentId, + threadId: input.threadId, + projectId: input.projectId, + title: input.title, + currentRevision: 1, + status: "open", + // Providers put an HTML plan in the same field as markdown, so the + // renderer is decided once at capture and remembered. + format: detectPlannotatorPlanFormat(input.planMarkdown), + createdByUserId: input.authorUserId, + createdAt, + updatedAt: createdAt, + }; + + yield* repository.upsertDocument(document).pipe(asInvariant("capturePlan.upsertDocument")); + yield* repository + .appendVersion({ + versionId: `plan-ver:${versionUuid}`, + documentId, + revision: 1, + authorKind: "agent", + authorUserId: null, + origin: "agent-proposed", + contentMarkdown: input.planMarkdown, + contentValueJson: null, + sourcePlanId: input.planId, + summary: null, + createdAt, + }) + .pipe(asInvariant("capturePlan.appendVersion")); + + return document; + } + + // Revision of an existing lineage: skip when the content is unchanged so + // a redelivered projection event cannot inflate the history. + const latest = yield* repository + .getLatestVersion(openDocument.documentId) + .pipe(asInvariant("capturePlan.getLatestVersion")); + if ( + Option.isSome(latest) && + latest.value.contentMarkdown.trim() === input.planMarkdown.trim() + ) { + return openDocument; + } + + const nextRevision = openDocument.currentRevision + 1; + const versionUuid = yield* uuid; + yield* repository + .appendVersion({ + versionId: `plan-ver:${versionUuid}`, + documentId: openDocument.documentId, + revision: nextRevision, + authorKind: "agent", + authorUserId: null, + origin: "agent-revision", + contentMarkdown: input.planMarkdown, + contentValueJson: null, + sourcePlanId: input.planId, + summary: null, + createdAt, + }) + .pipe(asInvariant("capturePlan.appendRevision")); + + const updated: PlanDocumentRecord = { + ...openDocument, + title: input.title, + currentRevision: nextRevision, + format: detectPlannotatorPlanFormat(input.planMarkdown), + // The revision answers the feedback, so the review is live again. + status: "open", + updatedAt: createdAt, + }; + yield* repository.upsertDocument(updated).pipe(asInvariant("capturePlan.updateDocument")); + + // A new agent revision invalidates the human draft it was based on. + yield* repository + .clearDraft(openDocument.documentId) + .pipe(asInvariant("capturePlan.clearDraft")); + + yield* announce(openDocument.documentId); + return updated; + }); + + const getReview: PlanReviewService["Service"]["getReview"] = (documentId) => + Effect.gen(function* () { + const document = yield* requireDocument(documentId); + const [versions, draftOption, discussions, comments] = yield* Effect.all([ + repository.listVersions(documentId).pipe(asInvariant("getReview.versions")), + repository.getDraft(documentId).pipe(asInvariant("getReview.draft")), + repository.listDiscussions(documentId).pipe(asInvariant("getReview.discussions")), + repository.listDiscussionComments(documentId).pipe(asInvariant("getReview.comments")), + ]); + + return { + document, + versions, + draft: Option.isSome(draftOption) + ? { + contentValueJson: draftOption.value.contentValueJson, + baseVersionId: draftOption.value.baseVersionId, + revisionToken: draftOption.value.revisionToken, + updatedByUserId: draftOption.value.updatedByUserId, + updatedAt: draftOption.value.updatedAt, + } + : null, + discussions, + comments, + } satisfies PlanReviewSnapshot; + }); + + /** + * Lists a thread's plan documents, capturing the thread's reviewable plan + * first if nothing covers it yet. + * + * Startup reconciliation only walks the newest plan per active thread, so + * plans that predate the feature — or that it skipped — would otherwise have + * no document, and the UI would offer no way in. Capturing here means the + * entry point appears wherever a reviewable plan exists. `capturePlan` + * dedupes on the source plan id, so repeating this is a no-op. + */ + const listForThread: PlanReviewService["Service"]["listForThread"] = (threadId) => + Effect.gen(function* () { + const existing = yield* repository + .listDocumentsForThread(threadId) + .pipe(asInvariant("listForThread")); + if ( + existing.some( + (document) => document.status === "open" || document.status === "changes-requested", + ) + ) { + return existing; + } + + const threadOption = yield* query + .getThreadDetailById(threadId) + .pipe(asInvariant("listForThread.getThread")); + if (Option.isNone(threadOption)) return existing; + const thread = threadOption.value; + + const reviewable = [...thread.proposedPlans] + .filter((plan) => plan.implementedAt === null) + .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)) + .at(-1); + if (reviewable === undefined) return existing; + + const planMarkdown = withoutPlannotatorPlanMarker(reviewable.planMarkdown).trim(); + if (planMarkdown.length === 0) return existing; + + yield* capturePlan({ + threadId, + projectId: thread.projectId, + planId: reviewable.id, + planMarkdown, + title: derivePlanTitle(planMarkdown), + authorUserId: null, + }); + + return yield* repository + .listDocumentsForThread(threadId) + .pipe(asInvariant("listForThread.reload")); + }); + + const saveDraft: PlanReviewService["Service"]["saveDraft"] = (input) => + Effect.gen(function* () { + const document = yield* requireDocument(input.documentId); + if (document.status !== "open") { + return yield* new PlanReviewInvariantError({ + operation: "saveDraft", + detail: `This review is ${document.status} and can no longer be edited.`, + }); + } + const latest = yield* repository + .getLatestVersion(document.documentId) + .pipe(asInvariant("saveDraft.getLatestVersion")); + if (Option.isNone(latest)) { + return yield* new PlanReviewInvariantError({ + operation: "saveDraft", + detail: "The plan has no versions yet.", + }); + } + + const [tokenUuid, updatedAt] = yield* Effect.all([uuid, nowIso]); + const nextRevisionToken = `draft:${tokenUuid}`; + + yield* repository + .saveDraft({ + documentId: document.documentId, + baseVersionId: latest.value.versionId, + contentValueJson: input.contentValueJson, + updatedByUserId: input.actorUserId, + updatedAt, + expectedRevisionToken: input.expectedRevisionToken, + nextRevisionToken, + }) + .pipe(asInvariant("saveDraft")); + + // Deliberately not announced: a draft is one reviewer's working copy, and + // broadcasting it would push the whole version history on every keystroke. + return { revisionToken: nextRevisionToken }; + }); + + const cutVersion: PlanReviewService["Service"]["cutVersion"] = (input) => + Effect.gen(function* () { + const document = yield* requireDocument(input.documentId); + const latest = yield* repository + .getLatestVersion(document.documentId) + .pipe(asInvariant("cutVersion.getLatestVersion")); + + if ( + Option.isSome(latest) && + latest.value.contentMarkdown.trim() === input.contentMarkdown.trim() + ) { + return latest.value; + } + + const [versionUuid, createdAt] = yield* Effect.all([uuid, nowIso]); + const revision = document.currentRevision + 1; + const version: PlanVersionRecord = { + versionId: `plan-ver:${versionUuid}`, + documentId: document.documentId, + revision, + authorKind: "user", + authorUserId: input.actorUserId, + origin: "human-edit", + contentMarkdown: input.contentMarkdown, + contentValueJson: input.contentValueJson, + sourcePlanId: null, + summary: input.summary, + createdAt, + }; + + yield* repository.appendVersion(version).pipe(asInvariant("cutVersion.appendVersion")); + yield* repository + .upsertDocument({ ...document, currentRevision: revision, updatedAt: createdAt }) + .pipe(asInvariant("cutVersion.updateDocument")); + + yield* announce(document.documentId); + return version; + }); + + const upsertDiscussion: PlanReviewService["Service"]["upsertDiscussion"] = (input) => + Effect.gen(function* () { + const document = yield* requireDocument(input.documentId); + const latest = yield* repository + .getLatestVersion(document.documentId) + .pipe(asInvariant("upsertDiscussion.getLatestVersion")); + if (Option.isNone(latest)) { + return yield* new PlanReviewInvariantError({ + operation: "upsertDiscussion", + detail: "The plan has no versions yet.", + }); + } + + const [commentUuid, createdAt] = yield* Effect.all([uuid, nowIso]); + yield* repository + .upsertDiscussion({ + discussionId: input.discussionId, + documentId: document.documentId, + anchorVersionId: latest.value.versionId, + quotedText: input.quotedText, + createdByUserId: input.actorUserId, + createdAt, + }) + .pipe(asInvariant("upsertDiscussion")); + + yield* repository + .addDiscussionComment({ + commentId: `plan-comment:${commentUuid}`, + discussionId: input.discussionId, + documentId: document.documentId, + authorUserId: input.actorUserId, + bodyMarkdown: input.bodyMarkdown, + createdAt, + }) + .pipe(asInvariant("upsertDiscussion.addComment")); + + yield* announce(document.documentId); + }); + + const resolveDiscussion: PlanReviewService["Service"]["resolveDiscussion"] = (input) => + Effect.gen(function* () { + yield* requireDocument(input.documentId); + const resolvedAt = yield* nowIso; + yield* repository + .resolveDiscussion({ + discussionId: input.discussionId, + documentId: input.documentId, + isResolved: input.isResolved, + resolvedByUserId: input.isResolved ? input.actorUserId : null, + resolvedAt: input.isResolved ? resolvedAt : null, + }) + .pipe(asInvariant("resolveDiscussion")); + + yield* announce(input.documentId); + }); + + const getVersionDiff: PlanReviewService["Service"]["getVersionDiff"] = (input) => + Effect.gen(function* () { + const document = yield* requireDocument(input.documentId); + const [fromOption, toOption] = yield* Effect.all([ + repository + .getVersion({ documentId: document.documentId, versionId: input.fromVersionId }) + .pipe(asInvariant("getVersionDiff.from")), + repository + .getVersion({ documentId: document.documentId, versionId: input.toVersionId }) + .pipe(asInvariant("getVersionDiff.to")), + ]); + if (Option.isNone(fromOption) || Option.isNone(toOption)) { + return yield* new PlanReviewInvariantError({ + operation: "getVersionDiff", + detail: "One of the requested versions does not exist.", + }); + } + + const { diff } = buildUnifiedDiff( + fromOption.value.contentMarkdown, + toOption.value.contentMarkdown, + ); + return { diff: toRenderableFileDiff(`${document.title}.md`, diff) }; + }); + + /** Builds the anchored comment payloads the prompt embeds. */ + const buildAnchoredComments = ( + baseMarkdown: string, + discussions: ReadonlyArray, + comments: ReadonlyArray, + resolveLabel: (userId: UserId | null) => string | null, + ): { + readonly comments: ReadonlyArray; + readonly discussionIds: ReadonlyArray; + } => { + const byDiscussion = new Map(); + for (const comment of comments) { + const bucket = byDiscussion.get(comment.discussionId); + if (bucket) bucket.push(comment); + else byDiscussion.set(comment.discussionId, [comment]); + } + + const anchored: PlanReviewAnchoredComment[] = []; + const discussionIds: string[] = []; + for (const discussion of discussions) { + if (discussion.isResolved) continue; + const bucket = byDiscussion.get(discussion.discussionId) ?? []; + const body = bucket.map((comment) => comment.bodyMarkdown.trim()).join("\n\n"); + if (body.length === 0) continue; + + // A quote we cannot find still carries its text, just without a range. + const located = locateQuotedLineRange(baseMarkdown, discussion.quotedText); + anchored.push({ + startIndex: located?.startIndex ?? null, + endIndex: located?.endIndex ?? null, + quotedText: discussion.quotedText, + body, + authorLabel: resolveLabel(bucket[0]?.authorUserId ?? discussion.createdByUserId), + }); + discussionIds.push(discussion.discussionId); + } + return { comments: anchored, discussionIds }; + }; + + const submit: PlanReviewService["Service"]["submit"] = (input) => + Effect.gen(function* () { + const snapshot = yield* getReview(input.documentId); + const document = snapshot.document; + + // Two tabs, or a replayed request, must not start implementation twice. + if (document.status !== "open") { + return yield* new PlanReviewInvariantError({ + operation: "submit", + detail: `This review was already ${document.status}.`, + }); + } + + const latestVersion = snapshot.versions.at(-1); + if (latestVersion === undefined) { + return yield* new PlanReviewInvariantError({ + operation: "submit", + detail: "The plan has no versions yet.", + }); + } + + const resolveLabel = (userId: UserId | null) => + userId === null ? input.actorLabel : userId === input.actorUserId ? input.actorLabel : null; + + // Reviewer edits become a real version before anything is sent, so the + // history always explains what the agent was told. + const agentBaseline = + snapshot.versions.toReversed().find((version) => version.authorKind === "agent") ?? + latestVersion; + + let approvedVersion = latestVersion; + if ( + input.editedMarkdown !== null && + input.editedMarkdown.trim() !== latestVersion.contentMarkdown.trim() + ) { + approvedVersion = yield* cutVersion({ + documentId: document.documentId, + contentMarkdown: input.editedMarkdown, + contentValueJson: null, + summary: input.decision === "approved" ? "Edited before approval" : "Reviewer edit", + actorUserId: input.actorUserId, + }); + } + + const editResult = buildUnifiedDiff( + agentBaseline.contentMarkdown, + approvedVersion.contentMarkdown, + ); + + if (input.decision === "discarded") { + const discardedAt = yield* nowIso; + yield* repository + .setDocumentStatus({ + documentId: document.documentId, + status: "discarded", + updatedAt: discardedAt, + }) + .pipe(asInvariant("submit.discard")); + yield* appendActivity({ + threadId: document.threadId, + summary: "Plan review was discarded.", + tone: "error", + payload: { documentId: document.documentId, decision: "discarded" }, + }); + yield* announce(document.documentId); + return { + documentId: document.documentId, + status: "discarded", + prompt: null, + turnStarted: false, + resentPlan: false, + } satisfies SubmitReviewResult; + } + + const threadOption = yield* query + .getThreadDetailById(document.threadId) + .pipe(asInvariant("submit.getThread")); + if (Option.isNone(threadOption)) { + return yield* new PlanReviewInvariantError({ + operation: "submit", + detail: `Thread ${document.threadId} was not found.`, + }); + } + const thread = threadOption.value; + + let prompt: string; + let resentPlan = false; + let sentDiscussionIds: ReadonlyArray = []; + + if (input.decision === "approved") { + const latestCompactionAt = + [...thread.activities] + .filter((activity) => activity.kind === "context-compaction") + .map((activity) => activity.createdAt) + .sort() + .at(-1) ?? null; + + const resend = decidePlanResend({ + latestCompactionAt, + planCreatedAt: agentBaseline.createdAt, + planThreadId: document.threadId, + targetThreadId: document.threadId, + providerSessionStatus: thread.session?.status ?? null, + }); + resentPlan = resend.shouldResend; + + prompt = buildPlanReviewApprovalPrompt({ + notes: input.globalComment, + resendPlanMarkdown: resend.shouldResend ? approvedVersion.contentMarkdown : null, + resendReason: resend.reason, + approvedEditDiff: editResult.diff, + }); + } else { + const anchored = buildAnchoredComments( + agentBaseline.contentMarkdown, + snapshot.discussions, + snapshot.comments, + resolveLabel, + ); + sentDiscussionIds = anchored.discussionIds; + const sendFullDocument = shouldSendFullDocumentInsteadOfDiff(editResult.stats.changeRatio); + + prompt = buildPlanReviewFeedbackPrompt({ + documentId: document.documentId, + planTitle: document.title, + globalComment: input.globalComment, + comments: anchored.comments, + editDiff: editResult.diff, + fromRevision: agentBaseline.revision, + toRevision: approvedVersion.revision, + editAuthorLabel: input.actorLabel, + fullDocument: + sendFullDocument && editResult.diff.trim().length > 0 + ? approvedVersion.contentMarkdown + : null, + }); + } + + const [commandUuid, messageUuid, modeUuid, createdAt] = yield* Effect.all([ + uuid, + uuid, + uuid, + nowIso, + ]); + + // Only approval may leave Plan mode; feedback keeps the thread planning. + if (input.decision === "approved") { + const modeCommand: OrchestrationCommand = { + type: "thread.interaction-mode.set", + commandId: CommandId.make(`plan-review:mode:${modeUuid}`), + threadId: document.threadId, + interactionMode: "default", + createdAt, + }; + yield* dispatcher.dispatch(modeCommand).pipe(asInvariant("submit.setMode")); + } + + yield* dispatcher + .dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`plan-review:turn:${commandUuid}`), + threadId: document.threadId, + message: { + messageId: MessageId.make(`plan-review:${messageUuid}`), + role: "user", + text: prompt, + attachments: [], + }, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: input.decision === "approved" ? "default" : "plan", + ...(input.decision === "approved" && approvedVersion.sourcePlanId !== null + ? { + sourceProposedPlan: { + threadId: document.threadId, + planId: approvedVersion.sourcePlanId as OrchestrationProposedPlanId, + }, + } + : input.decision === "approved" && agentBaseline.sourcePlanId !== null + ? { + sourceProposedPlan: { + threadId: document.threadId, + planId: agentBaseline.sourcePlanId as OrchestrationProposedPlanId, + }, + } + : {}), + createdAt, + }) + .pipe(asInvariant("submit.startTurn")); + + const status: PlanDocumentStatus = + input.decision === "approved" ? "approved" : "changes-requested"; + yield* repository + .setDocumentStatus({ + documentId: document.documentId, + status, + updatedAt: createdAt, + }) + .pipe(asInvariant("submit.setStatus")); + yield* repository.clearDraft(document.documentId).pipe(asInvariant("submit.clearDraft")); + + // Anything already handed to the agent is spent. Leaving it open would + // re-send the same comments on every later round. + if (input.decision === "changes-requested") { + yield* Effect.forEach( + sentDiscussionIds, + (discussionId) => + repository + .resolveDiscussion({ + discussionId, + documentId: document.documentId, + isResolved: true, + resolvedByUserId: input.actorUserId, + resolvedAt: createdAt, + }) + .pipe(asInvariant("submit.consumeDiscussions")), + { discard: true }, + ); + } + + yield* announce(document.documentId); + + yield* appendActivity({ + threadId: document.threadId, + summary: + input.decision === "approved" + ? "Plan approved; implementation was started." + : "Plan feedback was sent to the planning agent.", + tone: input.decision === "approved" ? "approval" : "info", + payload: { + documentId: document.documentId, + decision: input.decision, + revision: approvedVersion.revision, + resentPlan, + }, + }); + + return { + documentId: document.documentId, + status, + prompt, + turnStarted: true, + resentPlan, + } satisfies SubmitReviewResult; + }); + + const watch: PlanReviewService["Service"]["watch"] = (documentId) => + Stream.concat( + Stream.fromEffect(getReview(documentId)), + Stream.fromPubSub(changes).pipe( + Stream.filter((changed) => changed === documentId), + Stream.mapEffect(() => getReview(documentId)), + ), + ); + + return PlanReviewService.of({ + capturePlan, + getReview, + listForThread, + saveDraft, + cutVersion, + upsertDiscussion, + resolveDiscussion, + getVersionDiff, + submit, + watch, + }); +}); + +export const layer = Layer.effect(PlanReviewService, make); diff --git a/apps/server/src/planreview/planReviewDiff.test.ts b/apps/server/src/planreview/planReviewDiff.test.ts new file mode 100644 index 00000000000..75082148a9d --- /dev/null +++ b/apps/server/src/planreview/planReviewDiff.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildUnifiedDiff, toRenderableFileDiff } from "./planReviewDiff.ts"; + +describe("buildUnifiedDiff", () => { + it("returns an empty diff for identical documents", () => { + const result = buildUnifiedDiff("# Plan\n\nStep one.\n", "# Plan\n\nStep one.\n"); + expect(result.diff).toBe(""); + expect(result.stats).toEqual({ added: 0, removed: 0, changeRatio: 0 }); + }); + + it("ignores a trailing-newline-only difference", () => { + expect(buildUnifiedDiff("a\nb\n", "a\nb").diff).toBe(""); + }); + + it("emits a hunk with surrounding context for a single changed line", () => { + const before = ["one", "two", "three", "four", "five"].join("\n"); + const after = ["one", "two", "THREE", "four", "five"].join("\n"); + const result = buildUnifiedDiff(before, after); + + expect(result.diff).toBe( + ["@@ -1,5 +1,5 @@", " one", " two", "-three", "+THREE", " four", " five"].join("\n"), + ); + expect(result.stats.added).toBe(1); + expect(result.stats.removed).toBe(1); + }); + + it("counts pure additions without removals", () => { + const result = buildUnifiedDiff("one\ntwo", "one\ntwo\nthree"); + expect(result.stats.added).toBe(1); + expect(result.stats.removed).toBe(0); + expect(result.diff).toContain("+three"); + }); + + it("splits distant changes into separate hunks", () => { + const before = Array.from({ length: 40 }, (_, index) => `line ${index}`).join("\n"); + const after = before.replace("line 2", "CHANGED 2").replace("line 35", "CHANGED 35"); + const result = buildUnifiedDiff(before, after); + + expect(result.diff.match(/^@@ /gm)).toHaveLength(2); + }); + + it("counts a modified line once, not as an add plus a remove", () => { + const before = ["a", "b", "c", "d"].join("\n"); + const after = ["a", "B", "C", "D"].join("\n"); + // 3 of 4 lines changed — not 6 of 4. + expect(buildUnifiedDiff(before, after).stats.changeRatio).toBeCloseTo(0.75); + + const light = buildUnifiedDiff( + Array.from({ length: 20 }, (_, index) => `line ${index}`).join("\n"), + Array.from({ length: 20 }, (_, index) => (index === 5 ? "changed" : `line ${index}`)).join( + "\n", + ), + ); + expect(light.stats.changeRatio).toBeCloseTo(0.05); + }); + + it("keeps a half-rewritten document under the full-document threshold", () => { + const before = Array.from({ length: 40 }, (_, index) => `line ${index}`).join("\n"); + const after = Array.from({ length: 40 }, (_, index) => + index < 15 ? `rewritten ${index}` : `line ${index}`, + ).join("\n"); + // 15 of 40 lines reworded is a diff worth sending, not a rewrite. + expect(buildUnifiedDiff(before, after).stats.changeRatio).toBeCloseTo(0.375); + }); + + it("handles an empty document on either side", () => { + expect(buildUnifiedDiff("", "new line").stats.added).toBe(1); + expect(buildUnifiedDiff("old line", "").stats.removed).toBe(1); + }); + + it("normalizes CRLF so a line-ending change is not a diff", () => { + expect(buildUnifiedDiff("a\r\nb", "a\nb").diff).toBe(""); + }); +}); + +describe("toRenderableFileDiff", () => { + it("wraps a diff in git headers the diff viewer understands", () => { + const wrapped = toRenderableFileDiff("Auth rewrite.md", "@@ -1,1 +1,1 @@\n-a\n+b"); + expect(wrapped.split("\n").slice(0, 3)).toEqual([ + "diff --git a/Auth rewrite.md b/Auth rewrite.md", + "--- a/Auth rewrite.md", + "+++ b/Auth rewrite.md", + ]); + }); + + it("returns an empty string when there is nothing to render", () => { + expect(toRenderableFileDiff("Plan.md", "")).toBe(""); + }); +}); diff --git a/apps/server/src/planreview/planReviewDiff.ts b/apps/server/src/planreview/planReviewDiff.ts new file mode 100644 index 00000000000..100cc03d270 --- /dev/null +++ b/apps/server/src/planreview/planReviewDiff.ts @@ -0,0 +1,190 @@ +/** + * T3-CUSTOM(expbkt3): line diff for plan versions. + * + * Deliberately dependency-free. jsdiff is BSD-3 and `@pierre/diffs` parses + * diffs rather than producing them, so the ~80 lines of LCS here buy us a + * unified diff the existing diff viewer can render without a new licence. + */ + +export interface UnifiedDiffStats { + readonly added: number; + readonly removed: number; + /** Changed lines as a fraction of the larger side, 0–1. */ + readonly changeRatio: number; +} + +export interface UnifiedDiffResult { + readonly diff: string; + readonly stats: UnifiedDiffStats; +} + +type Op = { readonly kind: "context" | "add" | "remove"; readonly line: string }; + +function splitLines(value: string): ReadonlyArray { + const normalized = value.replaceAll("\r\n", "\n"); + const lines = normalized.split("\n"); + // A trailing newline yields a final empty element that is not a real line. + return lines.length > 1 && lines.at(-1) === "" ? lines.slice(0, -1) : lines; +} + +/** + * Longest common subsequence over whole lines. Plans are at most a few hundred + * lines, so the O(n*m) table is fine and keeps the implementation obvious. + */ +function diffOps(before: ReadonlyArray, after: ReadonlyArray): ReadonlyArray { + const rows = before.length; + const cols = after.length; + const table: number[][] = Array.from({ length: rows + 1 }, () => + Array.from({ length: cols + 1 }).fill(0), + ); + + for (let i = rows - 1; i >= 0; i -= 1) { + for (let j = cols - 1; j >= 0; j -= 1) { + table[i]![j] = + before[i] === after[j] + ? table[i + 1]![j + 1]! + 1 + : Math.max(table[i + 1]![j]!, table[i]![j + 1]!); + } + } + + const ops: Op[] = []; + let i = 0; + let j = 0; + while (i < rows && j < cols) { + if (before[i] === after[j]) { + ops.push({ kind: "context", line: before[i]! }); + i += 1; + j += 1; + } else if (table[i + 1]![j]! >= table[i]![j + 1]!) { + ops.push({ kind: "remove", line: before[i]! }); + i += 1; + } else { + ops.push({ kind: "add", line: after[j]! }); + j += 1; + } + } + while (i < rows) { + ops.push({ kind: "remove", line: before[i]! }); + i += 1; + } + while (j < cols) { + ops.push({ kind: "add", line: after[j]! }); + j += 1; + } + return ops; +} + +interface Hunk { + readonly beforeStart: number; + readonly beforeCount: number; + readonly afterStart: number; + readonly afterCount: number; + readonly lines: ReadonlyArray; +} + +const CONTEXT_LINES = 3; + +function buildHunks(ops: ReadonlyArray): ReadonlyArray { + const changedIndices = ops.flatMap((op, index) => (op.kind === "context" ? [] : [index])); + if (changedIndices.length === 0) return []; + + // Group changes that sit within 2*CONTEXT_LINES of each other into one hunk. + const groups: Array<{ start: number; end: number }> = []; + for (const index of changedIndices) { + const last = groups.at(-1); + if (last && index - last.end <= CONTEXT_LINES * 2) { + last.end = index; + continue; + } + groups.push({ start: index, end: index }); + } + + const hunks: Hunk[] = []; + for (const group of groups) { + const from = Math.max(0, group.start - CONTEXT_LINES); + const to = Math.min(ops.length - 1, group.end + CONTEXT_LINES); + + let beforeLine = 1; + let afterLine = 1; + for (let index = 0; index < from; index += 1) { + const op = ops[index]!; + if (op.kind !== "add") beforeLine += 1; + if (op.kind !== "remove") afterLine += 1; + } + + let beforeCount = 0; + let afterCount = 0; + const lines: string[] = []; + for (let index = from; index <= to; index += 1) { + const op = ops[index]!; + if (op.kind === "context") { + beforeCount += 1; + afterCount += 1; + lines.push(` ${op.line}`); + } else if (op.kind === "remove") { + beforeCount += 1; + lines.push(`-${op.line}`); + } else { + afterCount += 1; + lines.push(`+${op.line}`); + } + } + + hunks.push({ + beforeStart: beforeLine, + beforeCount, + afterStart: afterLine, + afterCount, + lines, + }); + } + return hunks; +} + +/** Builds a unified diff between two markdown documents. Empty when identical. */ +export function buildUnifiedDiff(before: string, after: string): UnifiedDiffResult { + const beforeLines = splitLines(before); + const afterLines = splitLines(after); + const ops = diffOps(beforeLines, afterLines); + + const added = ops.filter((op) => op.kind === "add").length; + const removed = ops.filter((op) => op.kind === "remove").length; + const denominator = Math.max(beforeLines.length, afterLines.length, 1); + const stats: UnifiedDiffStats = { + added, + removed, + // A modified line shows up as one add and one remove, so summing them would + // report twice the fraction of the document that actually moved — and fire + // the "send the whole document" guard at half its stated threshold. + changeRatio: Math.min(1, Math.max(added, removed) / denominator), + }; + + const hunks = buildHunks(ops); + if (hunks.length === 0) return { diff: "", stats }; + + const body = hunks + .map((hunk) => + [ + `@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`, + ...hunk.lines, + ].join("\n"), + ) + .join("\n"); + + return { diff: body, stats }; +} + +/** + * Wraps a unified diff in git headers so `@pierre/diffs` can render it as a + * file diff. `fileName` is cosmetic — plans have no path on disk. + */ +export function toRenderableFileDiff(fileName: string, diff: string): string { + if (diff.trim().length === 0) return ""; + const safeName = fileName.replaceAll("\\", "/"); + return [ + `diff --git a/${safeName} b/${safeName}`, + `--- a/${safeName}`, + `+++ b/${safeName}`, + diff, + ].join("\n"); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 00d1f5da7e9..c6910a0df64 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -141,6 +141,9 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as PlannotatorManager from "./plannotator/PlannotatorManager.ts"; +// T3-CUSTOM(expbkt3): native plan review. +import * as PlanReviewDocuments from "./persistence/PlanReviewDocuments.ts"; +import * as PlanReviewServiceLayer from "./planreview/PlanReviewService.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; @@ -162,6 +165,8 @@ import { ProviderSessionDirectory } from "./provider/Services/ProviderSessionDir import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +// T3-CUSTOM(expbkt3): archived-session worktree reclaim +import * as SessionArchiveService from "./sessionArchive/SessionArchiveService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -690,6 +695,12 @@ const buildAppUnderTest = (options?: { }, ).pipe( Layer.provide(PlannotatorManager.layer), + // T3-CUSTOM(expbkt3): native plan review service for the fork RPC handlers. + Layer.provide( + PlanReviewServiceLayer.layer.pipe( + Layer.provide(PlanReviewDocuments.layer.pipe(Layer.provide(SqlitePersistenceMemory))), + ), + ), Layer.provide( OrchestrationCommandDispatcher.layerWithBootstrapRepository.pipe( Layer.provide( @@ -852,7 +863,21 @@ const buildAppUnderTest = (options?: { ), Layer.provide(gitManagerLayer), Layer.provide(gitVcsDriverLayer), - Layer.provide(gitWorkflowLayer), + // T3-CUSTOM(expbkt3): the session archive is stubbed rather than wired — + // these routes never exercise it, and a real one would walk the + // filesystem during unit tests. Merged into this provision rather than + // added as its own step: the chain is at TypeScript's `.pipe` ceiling. + Layer.provide( + Layer.mergeAll( + gitWorkflowLayer, + Layer.mock(SessionArchiveService.SessionArchiveService)({ + scan: () => Effect.die("session archive not stubbed"), + exportHistory: () => Effect.die("session archive not stubbed"), + reclaim: () => Effect.die("session archive not stubbed"), + sweep: () => Effect.die("session archive not stubbed"), + }), + ), + ), Layer.provide(reviewLayer), Layer.provide(vcsProvisioningLayer), Layer.provide( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9b37d2ce23d..07ec422372f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,6 +50,10 @@ import { mcpUpstreamProxyRouteLayer } from "./mcp/McpUpstreamProxy.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; // T3-CUSTOM(expbkt3): BEGIN — experimental native-plan review runtime. import * as PlannotatorManager from "./plannotator/PlannotatorManager.ts"; +// T3-CUSTOM(expbkt3): native plan review. +import * as PlanIngestListener from "./planreview/PlanIngestListener.ts"; +import * as PlanReviewServiceLayer from "./planreview/PlanReviewService.ts"; +import * as PlanReviewDocuments from "./persistence/PlanReviewDocuments.ts"; import { plannotatorProxyRouteLayer } from "./plannotator/http.ts"; // T3-CUSTOM(expbkt3): END import * as PreviewManager from "./preview/Manager.ts"; @@ -63,6 +67,9 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus. import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CatchupSummaryReactorLive } from "./orchestration/Layers/CatchupSummaryReactor.ts"; +// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summaries. +import { WorkSummaryReactorLive } from "./orchestration/Layers/WorkSummaryReactor.ts"; +// T3-CUSTOM(expbkt3): END import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; @@ -119,6 +126,10 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +// T3-CUSTOM(expbkt3): archived-session worktree reclaim +import * as SessionArchiveService from "./sessionArchive/SessionArchiveService.ts"; +import * as SessionArchiveSweeper from "./sessionArchive/SessionArchiveSweeper.ts"; +import { ProjectionThreadMessageRepositoryLive } from "./persistence/Layers/ProjectionThreadMessages.ts"; import * as OrchestrationCommandDispatcher from "./orchestration/dispatchCommand.ts"; import { ThreadExecutionSupervisorLive } from "./execution/ThreadExecutionSupervisorLive.ts"; // T3-CUSTOM(expbkt3): durable execution state machine repository. @@ -258,6 +269,9 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(CatchupSummaryReactorLive), + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summaries. + Layer.provideMerge(WorkSummaryReactorLive), + // T3-CUSTOM(expbkt3): END Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), @@ -441,6 +455,23 @@ const ProviderExecutionRuntimeLayerLive = Layer.mergeAll( const ProviderRateLimitsLayerLive = ProviderRateLimits.layer.pipe(Layer.provide(ProviderLayerLive)); +// T3-CUSTOM(expbkt3): archived-session worktree reclaim. Reads the projection +// for archived threads and their messages, and removes worktrees through the +// same git workflow service the delete path uses. +const SessionArchiveLayerLive = SessionArchiveService.layer.pipe( + Layer.provide(ServerSettingsLayerLive), + Layer.provide(OrchestrationLayerLive), + Layer.provide(ProjectionThreadMessageRepositoryLive), + Layer.provide(GitWorkflowLayerLive), + Layer.provide(PersistenceLayerLive), +); + +// The sweeper only puts the service on a timer, so it composes on top of it. +const SessionArchiveSweeperLayerLive = SessionArchiveSweeper.layer.pipe( + Layer.provide(SessionArchiveLayerLive), + Layer.provide(ServerSettingsLayerLive), +); + const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), @@ -453,7 +484,12 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ), ), Layer.provideMerge(GitLayerLive), - Layer.provideMerge(VcsLayerLive), + // T3-CUSTOM(expbkt3): the session archive is merged into the VCS group rather + // than added as its own `pipe` step — the chain is already at TypeScript's + // 20-overload ceiling for `.pipe`. + Layer.provideMerge( + Layer.mergeAll(VcsLayerLive, SessionArchiveLayerLive, SessionArchiveSweeperLayerLive), + ), Layer.provideMerge(ProviderExecutionRuntimeLayerLive), Layer.provideMerge( Layer.mergeAll(ProviderRateLimitsLayerLive, TerminalLayerLive, PreviewLayerLive), @@ -768,9 +804,17 @@ export const makeServerLayer = Layer.unwrap( runtimeBaseServicesLive, OrchestrationCommandDispatcher.layer.pipe(Layer.provide(runtimeBaseServicesLive)), ); - const runtimeServicesLive = PlannotatorManager.layer.pipe( + // T3-CUSTOM(expbkt3): native plan review sits beside Plannotator; both read + // the same proposed-plan events and neither depends on the other. + const planReviewServicesLive = PlanReviewServiceLayer.layer.pipe( + Layer.provide(PlanReviewDocuments.layer), Layer.provideMerge(runtimeServicesWithoutPlannotatorLive), ); + const runtimeServicesLive = Layer.mergeAll( + PlannotatorManager.layer.pipe(Layer.provideMerge(runtimeServicesWithoutPlannotatorLive)), + PlanIngestListener.layer.pipe(Layer.provideMerge(planReviewServicesLive)), + planReviewServicesLive, + ); const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), { disableLogger: !config.logWebSocketEvents, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index a2b1ec0b790..8cdc3f93ae1 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -37,6 +37,8 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; +// T3-CUSTOM(expbkt3): archived-session worktree reclaim +import * as SessionArchiveSweeper from "./sessionArchive/SessionArchiveSweeper.ts"; // T3-CUSTOM(expbkt3): automatic session recovery. import { SessionRecovery } from "./recovery/SessionRecovery.ts"; import { forkParked } from "./serverActivation.ts"; @@ -308,6 +310,9 @@ export const make = (options?: StartupOptions) => const keybindings = yield* Keybindings.Keybindings; const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper; + // T3-CUSTOM(expbkt3): archived-session worktree reclaim; no-ops unless the + // operator has switched the automatic sweep on. + const sessionArchiveSweeper = yield* SessionArchiveSweeper.SessionArchiveSweeper; // T3-CUSTOM(expbkt3): v1 remains wired for rollback compatibility but its // sweep is disabled while the durable coordinator owns recovery. yield* SessionRecovery; @@ -360,6 +365,8 @@ export const make = (options?: StartupOptions) => Effect.gen(function* () { yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + // T3-CUSTOM(expbkt3): archived-session worktree reclaim. + yield* sessionArchiveSweeper.start().pipe(Scope.provide(reactorScope)); }), ); diff --git a/apps/server/src/sessionArchive/SessionArchiveService.ts b/apps/server/src/sessionArchive/SessionArchiveService.ts new file mode 100644 index 00000000000..e73e73a2000 --- /dev/null +++ b/apps/server/src/sessionArchive/SessionArchiveService.ts @@ -0,0 +1,799 @@ +/** + * T3-CUSTOM(expbkt3): Reclaim archived sessions' worktrees, keeping their history. + * + * Upstream removes a worktree only when a thread is *deleted*, so the only way + * to reclaim disk is to destroy the record of the work. This service adds the + * middle path: write the session's history somewhere durable outside the + * worktree, verify that write landed, and only then give the space back. + * + * The ordering is the whole safety argument. Export before delete, always, and + * a failed export aborts that session's reclaim rather than proceeding. + */ +import { + SessionArchiveError, + type ProjectId, + type SessionArchiveEntry, + type SessionArchiveExportResult, + type SessionArchiveExportedFile, + type SessionArchiveOrphanedWorktree, + type SessionArchiveReclaimInput, + type SessionArchiveReclaimMode, + type SessionArchiveReclaimOutcome, + type SessionArchiveReclaimResult, + type SessionArchiveScanResult, + type OrchestrationThreadShell, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { ServerConfig } from "../config.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionThreadMessageRepository } from "../persistence/Services/ProjectionThreadMessages.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { + DEFAULT_SESSION_HISTORY_DIRNAME, + SESSION_HISTORY_README_FILENAME, + sessionHistoryPaths, +} from "./archivePaths.ts"; +import { readWorktreeGitFacts, UNKNOWN_GIT_FACTS } from "./gitFacts.ts"; +import { + renderSessionHistoryDigest, + renderSessionHistoryIndex, + renderSessionHistoryReadme, + toOneLineSummary, + type SessionHistoryIndexEntry, +} from "./historyMarkdown.ts"; +import { collectWorktreeUsage, serverOwnedWorktrees } from "./liveWorktrees.ts"; +import { describeBlockedReason, evaluateReclaimEligibility } from "./reclaimEligibility.ts"; +import { scanWorktree, slimWorktree } from "./worktreeScan.ts"; + +/** + * Worktrees sized per scan before the rest report a null size. + * + * The panel is worth nothing without sizes, and sizing everything is worth a + * multi-minute IO storm on a shared box. Bounding it keeps the common case — + * an operator looking at their largest archived sessions — fast, and the + * `sizingIncomplete` flag keeps the result honest about what was skipped. + */ +const SIZED_ENTRY_LIMIT = 60; + +/** Worktrees sized concurrently. Deliberately small: this host shares its IO. */ +const SIZING_CONCURRENCY = 2; + +export interface SessionArchiveServiceShape { + readonly scan: () => Effect.Effect; + readonly exportHistory: ( + threadIds: ReadonlyArray, + ) => Effect.Effect; + readonly reclaim: ( + input: SessionArchiveReclaimInput, + ) => Effect.Effect; + /** Retention-gated batch used by the sweeper; returns what it reclaimed. */ + readonly sweep: (input: { + readonly mode: SessionArchiveReclaimMode; + readonly minArchivedDays: number; + }) => Effect.Effect; +} + +export class SessionArchiveService extends Context.Service< + SessionArchiveService, + SessionArchiveServiceShape +>()("t3/sessionArchive/SessionArchiveService") {} + +const fail = (operation: string, cause: unknown) => + new SessionArchiveError({ + operation, + message: cause instanceof Error ? cause.message : String(cause), + }); + +/** + * Services the helpers below reach for lazily — the filesystem walk, the atomic + * writes, and the git reads. They are captured once and re-provided to every + * effect this service hands out, so the public shape stays requirement-free. + */ +type SessionArchivePlatform = FileSystem.FileSystem | Path.Path | GitVcsDriver.GitVcsDriver; + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig; + const settingsService = yield* ServerSettingsService; + const snapshots = yield* ProjectionSnapshotQuery; + const messages = yield* ProjectionThreadMessageRepository; + const gitWorkflow = yield* GitWorkflowService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* Effect.context(); + + const withPlatform = (effect: Effect.Effect) => + effect.pipe(Effect.provideContext(platform)); + + /** Configured override, or `/session-history`. */ + const resolveHistoryDir = Effect.gen(function* () { + const settings = yield* settingsService.getSettings; + const configured = settings.experimental.sessionArchive.historyDir.trim(); + return configured.length > 0 + ? configured + : path.join(config.baseDir, DEFAULT_SESSION_HISTORY_DIRNAME); + }); + + /** + * Everything a decision or an export needs, read once. + * + * Archived threads come from the archive-specific query, but the *usage* + * sets are built from the full snapshot: a live worktree has to be protected + * whether or not its thread is archived. + */ + const readContext = Effect.gen(function* () { + const [archived, full] = yield* Effect.all([ + snapshots.getArchivedShellSnapshot(), + snapshots.getShellSnapshot(), + ]); + + const usage = collectWorktreeUsage([...full.threads, ...archived.threads]); + const serverOwned = serverOwnedWorktrees({ + serverCwd: config.cwd, + worktreesDir: config.worktreesDir, + }); + const liveWorktreePaths = new Set([...usage.liveWorktreePaths, ...serverOwned]); + + const projectNames = new Map(); + for (const project of [...full.projects, ...archived.projects]) { + projectNames.set(project.id, project.title); + } + + return { + archivedThreads: archived.threads, + liveWorktreePaths, + activeThreadWorktreePaths: usage.activeThreadWorktreePaths, + projectNames, + projectRoots: new Map( + [...full.projects, ...archived.projects].map( + (project) => [project.id, project.workspaceRoot] as const, + ), + ), + }; + }); + + /** + * Git facts for a worktree, or the pessimistic default. + * + * A worktree that is already gone is reported as `null` so callers can + * distinguish "nothing to reclaim" from "we could not tell". + */ + const readGitFacts = (worktreePath: string | null) => + Effect.gen(function* () { + if (worktreePath === null) { + return null; + } + const exists = yield* fs.exists(worktreePath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return null; + } + return yield* readWorktreeGitFacts(worktreePath).pipe( + Effect.orElseSucceed(() => UNKNOWN_GIT_FACTS), + ); + }); + + const digestPathFor = ( + historyDir: string, + thread: OrchestrationThreadShell, + projectName: string, + ) => + sessionHistoryPaths({ + historyDir, + projectName, + threadId: thread.id, + title: thread.title, + archivedAt: thread.archivedAt ?? thread.createdAt, + }); + + /** + * Worktree directories on disk that no thread references. + * + * Reported, never reclaimed. On the box this was written for these are the + * majority of the directories, and nothing in the database can say what is + * safe about any of them. + */ + const findOrphanedWorktrees = (archivedThreads: ReadonlyArray) => + Effect.gen(function* () { + const known = new Set(); + const full = yield* snapshots.getShellSnapshot(); + for (const thread of [...full.threads, ...archivedThreads]) { + if (thread.worktreePath !== null) { + known.add(thread.worktreePath.trim()); + } + } + + const projectDirs = yield* fs + .readDirectory(config.worktreesDir) + .pipe(Effect.orElseSucceed(() => [])); + + const orphans: Array = []; + for (const projectDir of projectDirs) { + const absoluteProjectDir = path.join(config.worktreesDir, projectDir); + const worktreeNames = yield* fs + .readDirectory(absoluteProjectDir) + .pipe(Effect.orElseSucceed(() => [])); + for (const name of worktreeNames) { + const worktreePath = path.join(absoluteProjectDir, name); + if (known.has(worktreePath)) { + continue; + } + const info = yield* fs.stat(worktreePath).pipe(Effect.option); + if (info._tag === "None" || info.value.type !== "Directory") { + continue; + } + orphans.push({ + worktreePath, + // Sizing every orphan is exactly the IO storm this feature exists + // to avoid; the panel offers sizing on demand instead. + sizeBytes: null, + lastModifiedAt: formatOptionalDate(info.value.mtime), + }); + } + } + return orphans; + }); + + /** + * Rewrite a project's index with this entry merged in. + * + * Parsed back out of the existing file rather than kept in a sidecar state + * file: the index is the durable record, and a re-export has to update its + * row in place rather than appending a duplicate. + */ + const refreshProjectIndex = (input: { + readonly projectDir: string; + readonly indexPath: string; + readonly projectName: string; + readonly entry: SessionHistoryIndexEntry; + }) => + Effect.gen(function* () { + const existing = yield* fs + .readFileString(input.indexPath) + .pipe(Effect.orElseSucceed(() => "")); + const parsed = parseIndexRows(existing).filter( + (row) => row.fileName !== input.entry.fileName, + ); + const merged = [...parsed, input.entry]; + yield* writeFileStringAtomically({ + filePath: input.indexPath, + contents: renderSessionHistoryIndex(input.projectName, merged), + }); + }); + + const writeReadmeOnce = (historyDir: string) => + Effect.gen(function* () { + const readmePath = path.join(historyDir, SESSION_HISTORY_README_FILENAME); + const exists = yield* fs.exists(readmePath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return; + } + yield* writeFileStringAtomically({ + filePath: readmePath, + contents: renderSessionHistoryReadme(), + }); + }); + + /** Write one session's digest and transcript, and refresh the project index. */ + const exportThread = (thread: OrchestrationThreadShell, reclaimNote: string | null) => + Effect.gen(function* () { + const historyDir = yield* resolveHistoryDir; + const settings = yield* settingsService.getSettings; + const includeSidecar = settings.experimental.sessionArchive.includeTranscriptSidecar; + + const context = yield* readContext; + const projectName = context.projectNames.get(thread.projectId) ?? "unknown-project"; + const paths = digestPathFor(historyDir, thread, projectName); + + const [threadMessages, details, git] = yield* Effect.all([ + messages.listByThreadId({ threadId: thread.id }), + snapshots.getSessionListDetails([thread.id]), + readGitFacts(thread.worktreePath), + ]); + + const detail = details[0] ?? null; + const rollingSummary = detail?.rollingSummary ?? null; + + const digest = renderSessionHistoryDigest({ + threadId: thread.id, + title: thread.title, + projectName, + workspaceRoot: context.projectRoots.get(thread.projectId) ?? "", + worktreePath: thread.worktreePath, + providerInstanceId: thread.modelSelection.instanceId, + model: thread.modelSelection.model, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + rollingSummary, + turnSummaries: + detail?.latestTurnSummary != null + ? [ + { + summary: detail.latestTurnSummary.summary, + createdAt: detail.latestTurnSummary.createdAt, + }, + ] + : [], + userPrompts: threadMessages + .filter((message) => message.role === "user") + .map((message) => ({ text: message.text, createdAt: message.createdAt })), + git: + git === null + ? null + : { + branch: git.branch ?? thread.branch, + baseRef: git.baseRef, + headSha: git.headSha, + hasUncommittedChanges: git.hasUncommittedChanges, + hasUntrackedFiles: git.hasUntrackedFiles, + hasUnpushedCommits: git.hasUnpushedCommits, + changedFiles: git.changedFiles, + }, + messageCount: threadMessages.length, + transcriptFileName: includeSidecar ? path.basename(paths.transcriptPath) : null, + reclaimNote, + }); + + yield* writeFileStringAtomically({ filePath: paths.digestPath, contents: digest }); + + if (includeSidecar) { + const transcript = threadMessages + .map((message) => + JSON.stringify({ + messageId: message.messageId, + turnId: message.turnId, + role: message.role, + text: message.text, + sentByUserId: message.sentByUserId, + createdAt: message.createdAt, + }), + ) + .join("\n"); + yield* writeFileStringAtomically({ + filePath: paths.transcriptPath, + contents: transcript.length > 0 ? `${transcript}\n` : "", + }); + } + + yield* refreshProjectIndex({ + projectDir: paths.projectDir, + indexPath: paths.indexPath, + projectName, + entry: { + fileName: path.basename(paths.digestPath), + title: thread.title, + archivedAt: thread.archivedAt, + branch: git?.branch ?? thread.branch, + oneLineSummary: toOneLineSummary(rollingSummary), + }, + }); + + yield* writeReadmeOnce(historyDir); + + return { + threadId: thread.id, + digestPath: paths.digestPath, + transcriptPath: includeSidecar ? paths.transcriptPath : null, + messageCount: threadMessages.length, + } satisfies SessionArchiveExportedFile; + }); + + const scan = () => + Effect.gen(function* () { + const historyDir = yield* resolveHistoryDir; + const context = yield* readContext; + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + + // Largest-first would need sizes we do not have yet, so order by recency + // of archiving: the sessions an operator is most likely to act on. + const ordered = [...context.archivedThreads].sort((left, right) => + (right.archivedAt ?? "").localeCompare(left.archivedAt ?? ""), + ); + + let sizedCount = 0; + let sizingIncomplete = false; + + const entries = yield* Effect.forEach( + ordered, + (thread) => + Effect.gen(function* () { + const projectName = context.projectNames.get(thread.projectId) ?? "unknown-project"; + const git = yield* readGitFacts(thread.worktreePath); + + // Evaluated per mode: `remove` gates harder than `slim`, and the + // panel needs to say which of its two buttons will actually run. + const gateInput = { + thread: { + threadId: thread.id, + worktreePath: thread.worktreePath, + archivedAt: thread.archivedAt, + }, + git, + liveWorktreePaths: context.liveWorktreePaths, + activeThreadWorktreePaths: context.activeThreadWorktreePaths, + minArchivedDays: 0, + nowMs, + force: false, + } as const; + const eligibility = evaluateReclaimEligibility({ ...gateInput, mode: "slim" }); + const removeEligibility = evaluateReclaimEligibility({ + ...gateInput, + mode: "remove", + }); + + const shouldSize = + git !== null && eligibility.eligible && sizedCount < SIZED_ENTRY_LIMIT; + if (git !== null && eligibility.eligible && !shouldSize) { + sizingIncomplete = true; + } + if (shouldSize) { + sizedCount += 1; + } + + const sized = shouldSize + ? yield* scanWorktree({ + worktreePath: thread.worktreePath ?? "", + trackedPaths: git?.trackedPaths ?? new Set(), + }).pipe( + Effect.orElseSucceed(() => ({ + totalBytes: null, + reclaimableBytes: 0, + slimCandidates: [], + budgetExhausted: true, + })), + ) + : null; + + if (sized?.budgetExhausted === true) { + sizingIncomplete = true; + } + + const paths = digestPathFor(historyDir, thread, projectName); + const digestExists = yield* fs + .exists(paths.digestPath) + .pipe(Effect.orElseSucceed(() => false)); + + const reclaimState: SessionArchiveEntry["reclaimState"] = + thread.worktreePath === null + ? "removed" + : git === null + ? "missing" + : sized !== null && sized.slimCandidates.length === 0 + ? "slimmed" + : "present"; + + return { + threadId: thread.id, + projectId: thread.projectId, + projectName, + title: thread.title, + branch: thread.branch, + worktreePath: thread.worktreePath, + archivedAt: thread.archivedAt, + worktreeBytes: sized?.totalBytes ?? null, + reclaimableBytes: sized?.reclaimableBytes ?? null, + reclaimState, + blockedReason: eligibility.blockedReason, + removeBlockedReason: removeEligibility.blockedReason, + historyPath: digestExists ? paths.digestPath : null, + } satisfies SessionArchiveEntry; + }), + { concurrency: SIZING_CONCURRENCY }, + ); + + const orphanedWorktrees = yield* findOrphanedWorktrees(context.archivedThreads); + + return { + scannedAt: DateTime.formatIso(now), + entries, + orphanedWorktrees, + totalReclaimableBytes: entries.reduce( + (total, entry) => + total + (entry.blockedReason === null ? (entry.reclaimableBytes ?? 0) : 0), + 0, + ), + historyDir, + sizingIncomplete, + } satisfies SessionArchiveScanResult; + }).pipe(Effect.mapError((cause) => fail("scan", cause))); + + const exportHistory = (threadIds: ReadonlyArray) => + Effect.gen(function* () { + const context = yield* readContext; + const byId = new Map(context.archivedThreads.map((thread) => [thread.id, thread] as const)); + + const results = yield* Effect.forEach( + threadIds, + (threadId) => + Effect.gen(function* () { + const thread = byId.get(threadId); + if (thread === undefined) { + return { + _tag: "failure" as const, + threadId, + message: "No archived session with this id.", + }; + } + return yield* exportThread(thread, null).pipe( + Effect.map((exported) => ({ _tag: "success" as const, exported })), + Effect.catchCause((cause) => + Effect.succeed({ + _tag: "failure" as const, + threadId, + message: describeCause(cause), + }), + ), + ); + }), + { concurrency: 2 }, + ); + + return { + exported: results.flatMap((result) => (result._tag === "success" ? [result.exported] : [])), + failures: results.flatMap((result) => + result._tag === "failure" ? [{ threadId: result.threadId, message: result.message }] : [], + ), + } satisfies SessionArchiveExportResult; + }).pipe(Effect.mapError((cause) => fail("export", cause))); + + /** + * Reclaim one session. + * + * The export is not best-effort: if it throws, or lands an empty digest, this + * returns without touching the worktree. Losing a session's record to free + * disk is the one outcome the whole feature exists to prevent. + */ + const reclaimThread = (input: { + readonly thread: OrchestrationThreadShell; + readonly mode: SessionArchiveReclaimMode; + readonly force: boolean; + readonly minArchivedDays: number; + readonly nowMs: number; + readonly liveWorktreePaths: ReadonlySet; + readonly activeThreadWorktreePaths: ReadonlySet; + /** The project's main checkout; `git worktree remove` must run from it. */ + readonly workspaceRoot: string | null; + }) => + Effect.gen(function* () { + const { thread, mode } = input; + const skipped = (reason: string): SessionArchiveReclaimOutcome => ({ + threadId: thread.id, + reclaimed: false, + mode, + freedBytes: 0, + skippedReason: reason, + digestPath: null, + }); + + const git = yield* readGitFacts(thread.worktreePath); + const eligibility = evaluateReclaimEligibility({ + thread: { + threadId: thread.id, + worktreePath: thread.worktreePath, + archivedAt: thread.archivedAt, + }, + mode, + git, + liveWorktreePaths: input.liveWorktreePaths, + activeThreadWorktreePaths: input.activeThreadWorktreePaths, + minArchivedDays: input.minArchivedDays, + nowMs: input.nowMs, + force: input.force, + }); + + if (!eligibility.eligible) { + return skipped(describeBlockedReason(eligibility.blockedReason ?? "no-worktree")); + } + + const worktreePath = thread.worktreePath; + if (worktreePath === null || git === null) { + return skipped("This session has no worktree on disk."); + } + + const note = + mode === "slim" + ? "Regenerable directories were deleted from this session's worktree to reclaim disk. The checkout and its branch are intact." + : "This session's worktree was removed to reclaim disk. The branch named below is where the code lives."; + + const exported = yield* exportThread(thread, note).pipe( + Effect.map((value) => ({ _tag: "ok" as const, value })), + Effect.catchCause((cause) => + Effect.succeed({ _tag: "failed" as const, message: describeCause(cause) }), + ), + ); + if (exported._tag === "failed") { + return skipped(`History export failed, so nothing was deleted: ${exported.message}`); + } + + // Trust the file, not the effect's success: a truncated or empty digest + // is the same loss as no digest at all. + const digestInfo = yield* fs.stat(exported.value.digestPath).pipe(Effect.option); + if (digestInfo._tag === "None" || Number(digestInfo.value.size) === 0) { + return skipped("History export produced no file, so nothing was deleted."); + } + + if (mode === "slim") { + const sized = yield* scanWorktree({ + worktreePath, + trackedPaths: git.trackedPaths, + }); + const freedBytes = yield* slimWorktree({ + worktreePath, + candidates: sized.slimCandidates, + trackedPaths: git.trackedPaths, + }); + return { + threadId: thread.id, + reclaimed: true, + mode, + freedBytes, + skippedReason: null, + digestPath: exported.value.digestPath, + } satisfies SessionArchiveReclaimOutcome; + } + + const sized = yield* scanWorktree({ worktreePath, trackedPaths: git.trackedPaths }).pipe( + Effect.orElseSucceed(() => ({ + totalBytes: null, + reclaimableBytes: 0, + slimCandidates: [], + budgetExhausted: true, + })), + ); + + // `git worktree remove` cannot run from inside the worktree it removes, + // so it is issued from the project's main checkout. + if (input.workspaceRoot === null || input.workspaceRoot.trim().length === 0) { + return skipped("The project's main checkout is unknown, so the worktree was left alone."); + } + + const removal = yield* gitWorkflow + .removeWorktree({ cwd: input.workspaceRoot, path: worktreePath, force: input.force }) + .pipe( + Effect.as({ _tag: "ok" as const }), + Effect.catchCause((cause) => + Effect.succeed({ _tag: "failed" as const, message: describeCause(cause) }), + ), + ); + if (removal._tag === "failed") { + return skipped(`git worktree remove failed: ${removal.message}`); + } + + return { + threadId: thread.id, + reclaimed: true, + mode, + freedBytes: sized.totalBytes ?? 0, + skippedReason: null, + digestPath: exported.value.digestPath, + } satisfies SessionArchiveReclaimOutcome; + }); + + const runReclaim = (input: { + readonly threadIds: ReadonlyArray | null; + readonly mode: SessionArchiveReclaimMode; + readonly force: boolean; + readonly minArchivedDays: number; + }) => + Effect.gen(function* () { + const context = yield* readContext; + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + + const selected = + input.threadIds === null + ? context.archivedThreads + : context.archivedThreads.filter((thread) => input.threadIds?.includes(thread.id)); + + // Sequential on purpose: each reclaim is heavy IO, and a shared box would + // rather take longer than have several recursive deletes at once. + const outcomes = yield* Effect.forEach( + selected, + (thread) => + reclaimThread({ + thread, + mode: input.mode, + force: input.force, + minArchivedDays: input.minArchivedDays, + nowMs, + liveWorktreePaths: context.liveWorktreePaths, + activeThreadWorktreePaths: context.activeThreadWorktreePaths, + workspaceRoot: context.projectRoots.get(thread.projectId) ?? null, + }), + { concurrency: 1 }, + ); + + return { + outcomes, + totalFreedBytes: outcomes.reduce((total, outcome) => total + outcome.freedBytes, 0), + } satisfies SessionArchiveReclaimResult; + }).pipe(Effect.mapError((cause) => fail("reclaim", cause))); + + return { + scan: () => withPlatform(scan()), + exportHistory: (threadIds) => withPlatform(exportHistory(threadIds)), + reclaim: (input) => + withPlatform( + runReclaim({ + threadIds: input.threadIds, + mode: input.mode, + force: input.force, + // The operator is looking straight at the session; retention is a + // guard for the unattended sweeper, not for a deliberate click. + minArchivedDays: 0, + }), + ), + sweep: (input) => + withPlatform( + runReclaim({ + threadIds: null, + mode: input.mode, + force: false, + minArchivedDays: input.minArchivedDays, + }), + ), + } satisfies SessionArchiveServiceShape; +}); + +export const layer = Layer.effect(SessionArchiveService)(make); + +/** `Info.mtime` is an `Option`; platforms that cannot report it yield None. */ +function formatOptionalDate(value: Option.Option): string | null { + return Option.match(value, { + onNone: () => null, + onSome: (date) => date.toISOString(), + }); +} + +function describeCause(cause: unknown): string { + if (cause instanceof Error) { + return cause.message; + } + return String(cause); +} + +/** + * Recover index rows from a rendered index. + * + * Only the fields a re-render needs; anything unparseable is dropped rather + * than throwing, because a hand-edited index should degrade to "missing a row" + * and not to "export fails". + */ +export function parseIndexRows(markdown: string): ReadonlyArray { + const rows: Array = []; + for (const line of markdown.split("\n")) { + if (!line.startsWith("| ") || line.startsWith("| ---") || line.startsWith("| Archived")) { + continue; + } + const cells = line + .slice(1, -1) + .split(" | ") + .map((cell) => cell.trim()); + if (cells.length < 4) { + continue; + } + const link = /^\[(.*)\]\((.*)\)$/.exec(cells[1] ?? ""); + if (link === null) { + continue; + } + const branch = (cells[2] ?? "").replace(/^`|`$/g, ""); + rows.push({ + fileName: decodeURI(link[2] ?? ""), + title: (link[1] ?? "").replace(/\\\|/g, "|"), + archivedAt: cells[0] === "—" ? null : (cells[0] ?? null), + branch: branch === "—" || branch === "" ? null : branch, + oneLineSummary: cells[3] === "—" ? null : (cells[3] ?? "").replace(/\\\|/g, "|"), + }); + } + return rows; +} diff --git a/apps/server/src/sessionArchive/SessionArchiveSweeper.ts b/apps/server/src/sessionArchive/SessionArchiveSweeper.ts new file mode 100644 index 00000000000..ff15a4c0aca --- /dev/null +++ b/apps/server/src/sessionArchive/SessionArchiveSweeper.ts @@ -0,0 +1,95 @@ +/** + * T3-CUSTOM(expbkt3): Unattended reclaim of old archived sessions' worktrees. + * + * Off by default. When an operator turns it on, this walks the archive on a + * timer and reclaims anything past the configured retention window, using the + * same gates and the same export-before-delete ordering as the manual panel — + * this adds a schedule, not a second set of rules. + * + * Settings are re-read every tick rather than captured at start, so switching + * the sweep off takes effect at the next tick instead of needing a restart. + */ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; + +import { forkParked } from "../serverActivation.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { SessionArchiveService } from "./SessionArchiveService.ts"; + +/** + * How often the sweep wakes up. + * + * Deliberately slow. The thing it reclaims accumulates over days, and each tick + * costs a filesystem walk on a host that shares its IO with everything else. + */ +export const SWEEP_INTERVAL = Duration.hours(6); + +export interface SessionArchiveSweeperShape { + /** Start the background sweep within the provided scope. */ + readonly start: () => Effect.Effect; +} + +export class SessionArchiveSweeper extends Context.Service< + SessionArchiveSweeper, + SessionArchiveSweeperShape +>()("t3/sessionArchive/SessionArchiveSweeper") {} + +export const make = Effect.gen(function* () { + const settingsService = yield* ServerSettingsService; + const archive = yield* SessionArchiveService; + + const tick = Effect.gen(function* () { + const settings = yield* settingsService.getSettings; + const config = settings.experimental.sessionArchive; + + // Both switches, checked every tick: the feature itself, and the sweep. + if (!config.enabled || !config.autoSweep.enabled) { + return; + } + + const result = yield* archive.sweep({ + mode: config.autoSweep.mode, + minArchivedDays: config.autoSweep.minArchivedDays, + }); + + const reclaimed = result.outcomes.filter((outcome) => outcome.reclaimed); + if (reclaimed.length === 0) { + yield* Effect.logDebug("session-archive.sweep.no-op", { + considered: result.outcomes.length, + }); + return; + } + + // Logged at info even though it is routine: this deletes from disk + // without anyone watching, so there has to be a record of what went. + yield* Effect.logInfo("session-archive.sweep.reclaimed", { + mode: config.autoSweep.mode, + minArchivedDays: config.autoSweep.minArchivedDays, + reclaimedCount: reclaimed.length, + skippedCount: result.outcomes.length - reclaimed.length, + freedBytes: result.totalFreedBytes, + threadIds: reclaimed.map((outcome) => outcome.threadId), + }); + }); + + const start: SessionArchiveSweeperShape["start"] = () => + forkParked( + tick.pipe( + Effect.catch((error: unknown) => + Effect.logWarning("session-archive.sweep.failed", { error }), + ), + Effect.catchDefect((defect: unknown) => + Effect.logWarning("session-archive.sweep.defect", { defect }), + ), + Effect.repeat(Schedule.spaced(SWEEP_INTERVAL)), + ), + ); + + return { start } satisfies SessionArchiveSweeperShape; +}); + +export const layer = Layer.effect(SessionArchiveSweeper)(make); diff --git a/apps/server/src/sessionArchive/archivePaths.ts b/apps/server/src/sessionArchive/archivePaths.ts new file mode 100644 index 00000000000..777c7c4caa6 --- /dev/null +++ b/apps/server/src/sessionArchive/archivePaths.ts @@ -0,0 +1,106 @@ +/** + * T3-CUSTOM(expbkt3): Where an archived session's history is written. + * + * The layout is part of the contract with agents, not an implementation + * detail: the global CLI instruction files point at this directory, so a Claude + * or Codex session finds past work by reading `INDEX.md` and then a dated file. + * Names therefore have to be greppable by hand — date first so a directory + * listing sorts chronologically, title in the middle so `ls | grep auth` works, + * thread id last so two sessions on the same day never collide. + * + * Pure: joins with forward slashes and lets the caller resolve against a root. + */ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +/** Directory under the server base dir when no override is configured. */ +export const DEFAULT_SESSION_HISTORY_DIRNAME = "session-history"; + +/** Per-project entry point an agent reads first. */ +export const SESSION_HISTORY_INDEX_FILENAME = "INDEX.md"; + +/** Orientation file at the history root, for an agent that lands there cold. */ +export const SESSION_HISTORY_README_FILENAME = "README.md"; + +const MAX_SLUG_LENGTH = 60; + +/** + * Lowercase, hyphen-separated, filesystem-safe. + * + * Truncation happens at a hyphen where possible so a slug does not end + * mid-word, which matters because these names are read by people. + */ +export function slugify(value: string, maxLength: number = MAX_SLUG_LENGTH): string { + const base = value + .normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (base.length === 0) { + return "untitled"; + } + if (base.length <= maxLength) { + return base; + } + const clipped = base.slice(0, maxLength); + const lastHyphen = clipped.lastIndexOf("-"); + return (lastHyphen > maxLength / 2 ? clipped.slice(0, lastHyphen) : clipped).replace(/-+$/, ""); +} + +/** + * `YYYY-MM-DD`, in UTC, from an ISO timestamp. + * + * Goes through `DateTime` rather than a bare `Date` both because the repo bans + * global `Date` construction and because a timestamp carrying an offset has to + * be normalized before slicing — otherwise a session archived at 23:00-05:00 + * files itself under the wrong day. + * + * Falls back to `undated` rather than throwing: a session with a corrupt + * timestamp still deserves its history written somewhere findable. + */ +export function archiveDateSegment(isoTimestamp: string | null): string { + if (isoTimestamp === null) { + return "undated"; + } + return Option.match(DateTime.make(isoTimestamp), { + onNone: () => "undated", + onSome: (value) => DateTime.formatIso(value).slice(0, 10), + }); +} + +export interface SessionHistoryPathsInput { + readonly historyDir: string; + readonly projectName: string; + readonly threadId: string; + readonly title: string; + /** Preferred stamp; falls back to `createdAt` at the call site. */ + readonly archivedAt: string | null; +} + +export interface SessionHistoryPaths { + readonly projectDir: string; + readonly digestPath: string; + readonly transcriptPath: string; + readonly indexPath: string; + readonly baseName: string; +} + +/** + * Build every path for one session's export. + * + * The thread id is truncated to its first 8 characters: full ids make the + * names unreadable, and 8 hex characters inside one project-day are not going + * to collide in a workspace this size. + */ +export function sessionHistoryPaths(input: SessionHistoryPathsInput): SessionHistoryPaths { + const projectDir = `${input.historyDir}/${slugify(input.projectName)}`; + const date = archiveDateSegment(input.archivedAt); + const baseName = `${date}-${slugify(input.title)}-${input.threadId.slice(0, 8)}`; + return { + projectDir, + baseName, + digestPath: `${projectDir}/${baseName}.md`, + transcriptPath: `${projectDir}/${baseName}.jsonl`, + indexPath: `${projectDir}/${SESSION_HISTORY_INDEX_FILENAME}`, + }; +} diff --git a/apps/server/src/sessionArchive/gitFacts.ts b/apps/server/src/sessionArchive/gitFacts.ts new file mode 100644 index 00000000000..01ee6c42e30 --- /dev/null +++ b/apps/server/src/sessionArchive/gitFacts.ts @@ -0,0 +1,128 @@ +/** + * T3-CUSTOM(expbkt3): Git facts a reclaim decision and a history digest need. + * + * `GitWorkflowService.localStatus` folds untracked files into + * `hasWorkingTreeChanges`, which is enough to *block* a removal but not enough + * to describe one honestly in an exported digest. These readers ask git the + * narrower questions directly, through the driver's generic `execute`, and are + * deliberately forgiving: a worktree that has already been removed, or a + * directory that was never a repository, yields "nothing known" rather than + * failing the surrounding scan. + */ +import * as Effect from "effect/Effect"; + +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import type { HistoryFileChange } from "./historyMarkdown.ts"; +import type { ReclaimGitFacts } from "./reclaimEligibility.ts"; + +export interface WorktreeGitFacts extends ReclaimGitFacts { + readonly branch: string | null; + readonly baseRef: string | null; + readonly headSha: string | null; + readonly changedFiles: ReadonlyArray; + /** Tracked paths relative to the worktree root, for the slim guard. */ + readonly trackedPaths: ReadonlySet; +} + +/** + * What we assume when git cannot tell us anything. + * + * Every boolean is `true` on purpose. An unreadable worktree is one we know + * nothing about, and the gates read these as reasons to refuse — so silence + * blocks a removal rather than waving it through. + */ +export const UNKNOWN_GIT_FACTS: WorktreeGitFacts = { + branch: null, + baseRef: null, + headSha: null, + hasUncommittedChanges: true, + hasUntrackedFiles: true, + hasUnpushedCommits: true, + changedFiles: [], + trackedPaths: new Set(), +}; + +/** Split `-z` output, which is NUL-terminated rather than NUL-separated. */ +function splitNulSeparated(stdout: string): ReadonlyArray { + return stdout.split("\0").filter((entry) => entry.length > 0); +} + +const runGit = (cwd: string, operation: string, args: ReadonlyArray) => + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + return yield* git.execute({ + operation: `SessionArchive.${operation}`, + cwd, + args, + allowNonZeroExit: true, + }); + }); + +/** + * Read every fact in one pass. + * + * The calls are independent, so they run concurrently — but bounded, because a + * scan fans this out across hundreds of worktrees on a shared box. + */ +export const readWorktreeGitFacts = Effect.fn("SessionArchive.readWorktreeGitFacts")(function* ( + worktreePath: string, +) { + const [status, head, upstream, tracked] = yield* Effect.all( + [ + runGit(worktreePath, "status", ["status", "--porcelain=1", "-z", "--untracked-files=normal"]), + runGit(worktreePath, "head", ["rev-parse", "--short", "HEAD"]), + // Empty stdout means no upstream, which we treat as "nothing is pushed". + runGit(worktreePath, "upstream", ["rev-list", "--count", "@{upstream}..HEAD"]), + runGit(worktreePath, "lsFiles", ["ls-files", "-z"]), + ], + { concurrency: 2 }, + ); + + if (status.exitCode !== 0) { + return UNKNOWN_GIT_FACTS; + } + + const entries = splitNulSeparated(status.stdout); + const changedFiles: Array = []; + let hasUncommittedChanges = false; + let hasUntrackedFiles = false; + + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry === undefined || entry.length < 4) { + continue; + } + const code = entry.slice(0, 2); + const path = entry.slice(3); + if (code === "??") { + hasUntrackedFiles = true; + changedFiles.push({ path, status: "?" }); + continue; + } + hasUncommittedChanges = true; + changedFiles.push({ path, status: code.trim() }); + // A rename entry is followed by its source path as a separate record. + if (code.startsWith("R") || code.startsWith("C")) { + index += 1; + } + } + + const branchResult = yield* runGit(worktreePath, "branch", ["rev-parse", "--abbrev-ref", "HEAD"]); + const branch = + branchResult.exitCode === 0 && branchResult.stdout.trim() !== "HEAD" + ? branchResult.stdout.trim() || null + : null; + + return { + branch, + baseRef: null, + headSha: head.exitCode === 0 ? head.stdout.trim() || null : null, + hasUncommittedChanges, + hasUntrackedFiles, + // No upstream (non-zero exit) means nothing is on a remote, so treat the + // branch as unpushed rather than as "zero commits ahead". + hasUnpushedCommits: upstream.exitCode !== 0 || Number.parseInt(upstream.stdout.trim(), 10) > 0, + changedFiles, + trackedPaths: new Set(tracked.exitCode === 0 ? splitNulSeparated(tracked.stdout) : []), + } satisfies WorktreeGitFacts; +}); diff --git a/apps/server/src/sessionArchive/historyMarkdown.test.ts b/apps/server/src/sessionArchive/historyMarkdown.test.ts new file mode 100644 index 00000000000..5dfab4ae4db --- /dev/null +++ b/apps/server/src/sessionArchive/historyMarkdown.test.ts @@ -0,0 +1,197 @@ +/** + * T3-CUSTOM(expbkt3): Coverage for the exported session digest. + * + * These files are read by other agents, so the shape is a contract: the fenced + * prompt blocks in particular have to survive prompts that themselves contain + * fenced code, or every section after the first one is corrupt. + */ +import { describe, expect, it } from "@effect/vitest"; + +import { + renderSessionHistoryDigest, + renderSessionHistoryIndex, + toOneLineSummary, + type SessionHistoryDigestInput, +} from "./historyMarkdown.ts"; + +const digestInput = ( + overrides: Partial = {}, +): SessionHistoryDigestInput => ({ + threadId: "thread_abcdef123456", + title: "Fix the resume resync seam", + projectName: "t3code-bkmain", + workspaceRoot: "/home/ubuntu/repos/t3code", + worktreePath: "/home/ubuntu/.t3/dev/worktrees/t3code/feature", + providerInstanceId: "claudeAgent", + model: "claude-opus-5", + createdAt: "2026-07-01T10:00:00.000Z", + archivedAt: "2026-07-09T18:30:00.000Z", + rollingSummary: "Converged execution state on resume. Landed in PR #55.", + turnSummaries: [{ summary: "Reproduced the stuck frame", createdAt: "2026-07-01T10:05:00.000Z" }], + userPrompts: [{ text: "the thread hangs on resume", createdAt: "2026-07-01T10:00:00.000Z" }], + git: { + branch: "t3code/resume-resync", + baseRef: "main", + headSha: "b6dda6094", + hasUncommittedChanges: false, + hasUntrackedFiles: false, + hasUnpushedCommits: false, + changedFiles: [{ path: "apps/server/src/ws.ts", status: "M" }], + }, + messageCount: 42, + transcriptFileName: "2026-07-09-fix-the-resume-resync-seam-thread_ab.jsonl", + reclaimNote: "Worktree reclaimed (slim) on 2026-08-07.", + ...overrides, +}); + +describe("renderSessionHistoryDigest", () => { + it("leads with the title and the reclaim note", () => { + const output = renderSessionHistoryDigest(digestInput()); + expect(output.startsWith("# Fix the resume resync seam\n")).toBe(true); + expect(output).toContain("> Worktree reclaimed (slim) on 2026-08-07."); + }); + + it("carries the facts an agent cannot reconstruct", () => { + const output = renderSessionHistoryDigest(digestInput()); + expect(output).toContain("**Branch:** t3code/resume-resync"); + expect(output).toContain("**HEAD:** b6dda6094"); + expect(output).toContain("clean and pushed"); + expect(output).toContain("`M` apps/server/src/ws.ts"); + expect(output).toContain("**Thread id:** `thread_abcdef123456`"); + }); + + it("names the transcript sidecar and how to read it", () => { + const output = renderSessionHistoryDigest(digestInput()); + expect(output).toContain("2026-07-09-fix-the-resume-resync-seam-thread_ab.jsonl"); + expect(output).toContain("Grep it rather than reading it whole."); + }); + + it("omits the sidecar line when sidecars are disabled", () => { + const output = renderSessionHistoryDigest(digestInput({ transcriptFileName: null })); + expect(output).not.toContain("Full transcript"); + }); + + it("escapes a prompt that contains its own code fence", () => { + const prompt = "run this:\n```ts\nconst x = 1;\n```\nthen tell me"; + const output = renderSessionHistoryDigest( + digestInput({ userPrompts: [{ text: prompt, createdAt: "2026-07-01T10:00:00.000Z" }] }), + ); + // The outer fence must be longer than any run inside the prompt, so the + // sections after the prompt are still inside the document structure. + expect(output).toContain("````text"); + expect(output).toContain("```ts"); + }); + + it("numbers every prompt", () => { + const output = renderSessionHistoryDigest( + digestInput({ + userPrompts: [ + { text: "first", createdAt: "2026-07-01T10:00:00.000Z" }, + { text: "second", createdAt: "2026-07-01T11:00:00.000Z" }, + ], + }), + ); + expect(output).toContain("## Prompts (2)"); + expect(output).toContain("### 1. 2026-07-01T10:00:00.000Z"); + expect(output).toContain("### 2. 2026-07-01T11:00:00.000Z"); + }); + + it("flags the risky states plainly", () => { + const output = renderSessionHistoryDigest( + digestInput({ + git: { + branch: "wip", + baseRef: null, + headSha: null, + hasUncommittedChanges: true, + hasUntrackedFiles: true, + hasUnpushedCommits: true, + changedFiles: [], + }, + }), + ); + expect(output).toContain("uncommitted changes, untracked files, unpushed commits"); + }); + + it("survives a session with no summary, no git, and no prompts", () => { + const output = renderSessionHistoryDigest( + digestInput({ + rollingSummary: null, + turnSummaries: [], + userPrompts: [], + git: null, + reclaimNote: null, + title: " ", + }), + ); + expect(output).toContain("# Untitled session"); + expect(output).toContain("No git information was captured"); + }); + + it("skips turn summaries that never became ready", () => { + const output = renderSessionHistoryDigest( + digestInput({ + turnSummaries: [ + { summary: null, createdAt: "2026-07-01T10:05:00.000Z" }, + { summary: " ", createdAt: "2026-07-01T10:06:00.000Z" }, + ], + }), + ); + expect(output).not.toContain("## Turn by turn"); + }); +}); + +describe("renderSessionHistoryIndex", () => { + it("sorts newest first and links each digest", () => { + const output = renderSessionHistoryIndex("t3code-bkmain", [ + { + fileName: "2026-07-01-older.md", + title: "Older work", + archivedAt: "2026-07-01T00:00:00.000Z", + branch: "a", + oneLineSummary: "Did the older thing.", + }, + { + fileName: "2026-08-01-newer.md", + title: "Newer work", + archivedAt: "2026-08-01T00:00:00.000Z", + branch: "b", + oneLineSummary: "Did the newer thing.", + }, + ]); + expect(output.indexOf("Newer work")).toBeLessThan(output.indexOf("Older work")); + expect(output).toContain("[Newer work](2026-08-01-newer.md)"); + }); + + it("escapes pipes so a title cannot break the table", () => { + const output = renderSessionHistoryIndex("proj", [ + { + fileName: "x.md", + title: "fix a | b parsing", + archivedAt: null, + branch: null, + oneLineSummary: null, + }, + ]); + expect(output).toContain("fix a \\| b parsing"); + }); +}); + +describe("toOneLineSummary", () => { + it("takes the first sentence", () => { + expect(toOneLineSummary("Landed the fix. Then cleaned up.")).toBe("Landed the fix."); + }); + + it("collapses whitespace across lines", () => { + expect(toOneLineSummary("Landed\n the fix")).toBe("Landed the fix"); + }); + + it("truncates a long sentence with an ellipsis", () => { + expect(toOneLineSummary("a".repeat(300))?.endsWith("…")).toBe(true); + }); + + it("maps empty input to null", () => { + expect(toOneLineSummary(null)).toBeNull(); + expect(toOneLineSummary(" ")).toBeNull(); + }); +}); diff --git a/apps/server/src/sessionArchive/historyMarkdown.ts b/apps/server/src/sessionArchive/historyMarkdown.ts new file mode 100644 index 00000000000..c35825dcdb0 --- /dev/null +++ b/apps/server/src/sessionArchive/historyMarkdown.ts @@ -0,0 +1,281 @@ +/** + * T3-CUSTOM(expbkt3): Render an archived session as a digest another agent can read. + * + * The audience is a future Claude/Codex session that has been pointed at the + * history directory and needs to answer "what happened here, and where did it + * land?" without loading a whole transcript. So this leads with facts that are + * expensive to reconstruct — branch, HEAD, what changed, whether it shipped — + * and only then narrates. Every user prompt is included verbatim, because the + * prompts are the cheapest complete record of intent; assistant output is left + * to the `.jsonl` sidecar. + * + * Pure: takes plain data, returns a string. + */ + +export interface HistoryTurnSummary { + readonly summary: string | null; + readonly createdAt: string; +} + +export interface HistoryUserPrompt { + readonly text: string; + readonly createdAt: string; +} + +export interface HistoryFileChange { + readonly path: string; + /** e.g. "M", "A", "D" — whatever the diff source reports. */ + readonly status: string; +} + +export interface HistoryGitState { + readonly branch: string | null; + readonly baseRef: string | null; + readonly headSha: string | null; + readonly hasUncommittedChanges: boolean; + readonly hasUntrackedFiles: boolean; + readonly hasUnpushedCommits: boolean; + readonly changedFiles: ReadonlyArray; +} + +export interface SessionHistoryDigestInput { + readonly threadId: string; + readonly title: string; + readonly projectName: string; + readonly workspaceRoot: string; + readonly worktreePath: string | null; + readonly providerInstanceId: string | null; + readonly model: string | null; + readonly createdAt: string | null; + readonly archivedAt: string | null; + readonly rollingSummary: string | null; + readonly turnSummaries: ReadonlyArray; + readonly userPrompts: ReadonlyArray; + readonly git: HistoryGitState | null; + readonly messageCount: number; + /** Basename of the sidecar, or null when sidecars are disabled. */ + readonly transcriptFileName: string | null; + /** What the reclaim did, so the file explains its own existence. */ + readonly reclaimNote: string | null; +} + +const EM_DASH_FALLBACK = "—"; + +function orDash(value: string | null | undefined): string { + const trimmed = value?.trim(); + return trimmed ? trimmed : EM_DASH_FALLBACK; +} + +/** + * Fence a prompt without letting its own backticks break out. + * + * Prompts routinely contain fenced code, so a fixed three-backtick fence would + * terminate early and corrupt every following section of the digest. + */ +function fencedBlock(text: string): string { + const longestRun = [...text.matchAll(/`+/g)].reduce( + (longest, match) => Math.max(longest, match[0].length), + 0, + ); + const fence = "`".repeat(Math.max(3, longestRun + 1)); + return `${fence}text\n${text.trimEnd()}\n${fence}`; +} + +function renderGitSection(git: HistoryGitState | null): ReadonlyArray { + if (git === null) { + return ["## Git state", "", "No git information was captured for this session.", ""]; + } + + const flags: Array = []; + if (git.hasUncommittedChanges) flags.push("uncommitted changes"); + if (git.hasUntrackedFiles) flags.push("untracked files"); + if (git.hasUnpushedCommits) flags.push("unpushed commits"); + + const lines = [ + "## Git state", + "", + `- **Branch:** ${orDash(git.branch)}`, + `- **Base ref:** ${orDash(git.baseRef)}`, + `- **HEAD:** ${orDash(git.headSha)}`, + `- **At archive time:** ${flags.length > 0 ? flags.join(", ") : "clean and pushed"}`, + "", + ]; + + if (git.changedFiles.length > 0) { + lines.push(`### Files changed (${git.changedFiles.length})`, ""); + for (const file of git.changedFiles) { + lines.push(`- \`${file.status}\` ${file.path}`); + } + lines.push(""); + } + + return lines; +} + +/** + * Build the digest. + * + * Section order is deliberate: metadata, then the summary, then git, then + * prompts. An agent that reads only the first screen should already know what + * the session was and whether its work landed. + */ +export function renderSessionHistoryDigest(input: SessionHistoryDigestInput): string { + const lines: Array = []; + + lines.push(`# ${input.title.trim() || "Untitled session"}`, ""); + + if (input.reclaimNote !== null) { + lines.push(`> ${input.reclaimNote}`, ""); + } + + lines.push( + "## Session", + "", + `- **Project:** ${orDash(input.projectName)}`, + `- **Workspace:** ${orDash(input.workspaceRoot)}`, + `- **Worktree:** ${orDash(input.worktreePath)}`, + `- **Thread id:** \`${input.threadId}\``, + `- **Provider:** ${orDash(input.providerInstanceId)}${input.model ? ` (${input.model})` : ""}`, + `- **Created:** ${orDash(input.createdAt)}`, + `- **Archived:** ${orDash(input.archivedAt)}`, + `- **Messages:** ${input.messageCount}`, + "", + ); + + if (input.transcriptFileName !== null) { + lines.push( + `Full transcript: \`${input.transcriptFileName}\` — one JSON object per line.`, + "Grep it rather than reading it whole.", + "", + ); + } + + const rolling = input.rollingSummary?.trim(); + if (rolling) { + lines.push("## Summary", "", rolling, ""); + } + + lines.push(...renderGitSection(input.git)); + + const readySummaries = input.turnSummaries.filter( + (entry): entry is HistoryTurnSummary & { summary: string } => Boolean(entry.summary?.trim()), + ); + if (readySummaries.length > 0) { + lines.push("## Turn by turn", ""); + for (const entry of readySummaries) { + lines.push(`- **${entry.createdAt}** — ${entry.summary.trim()}`); + } + lines.push(""); + } + + if (input.userPrompts.length > 0) { + lines.push(`## Prompts (${input.userPrompts.length})`, ""); + for (const [index, prompt] of input.userPrompts.entries()) { + lines.push(`### ${index + 1}. ${prompt.createdAt}`, "", fencedBlock(prompt.text), ""); + } + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +export interface SessionHistoryIndexEntry { + readonly fileName: string; + readonly title: string; + readonly archivedAt: string | null; + readonly branch: string | null; + readonly oneLineSummary: string | null; +} + +/** First sentence, or first line, capped — enough to recognize a session by. */ +export function toOneLineSummary(summary: string | null, maxLength = 160): string | null { + const collapsed = summary?.replace(/\s+/g, " ").trim(); + if (!collapsed) { + return null; + } + const firstSentence = /^(.+?[.!?])(\s|$)/.exec(collapsed)?.[1] ?? collapsed; + return firstSentence.length <= maxLength + ? firstSentence + : `${firstSentence.slice(0, maxLength - 1).trimEnd()}…`; +} + +/** + * Render the per-project index. + * + * Rewritten whole from the caller's merged entry list rather than appended to: + * re-exporting a session has to update its row in place, and a table that + * accumulated duplicates would stop being a usable entry point. + */ +export function renderSessionHistoryIndex( + projectName: string, + entries: ReadonlyArray, +): string { + const sorted = [...entries].sort((left, right) => { + const leftKey = left.archivedAt ?? ""; + const rightKey = right.archivedAt ?? ""; + return rightKey.localeCompare(leftKey) || left.fileName.localeCompare(right.fileName); + }); + + const lines = [ + `# Session history — ${projectName}`, + "", + "Archived T3 Code sessions for this project, newest first. Each row links to a", + "digest; a matching `.jsonl` beside it holds the full transcript.", + "", + "| Archived | Session | Branch | Summary |", + "| --- | --- | --- | --- |", + ]; + + for (const entry of sorted) { + const cells = [ + entry.archivedAt?.slice(0, 10) ?? EM_DASH_FALLBACK, + `[${escapeCell(entry.title)}](${encodeURI(entry.fileName)})`, + entry.branch ? `\`${escapeCell(entry.branch)}\`` : EM_DASH_FALLBACK, + entry.oneLineSummary ? escapeCell(entry.oneLineSummary) : EM_DASH_FALLBACK, + ]; + lines.push(`| ${cells.join(" | ")} |`); + } + + lines.push(""); + return lines.join("\n"); +} + +/** Pipes and newlines would break the row; nothing else needs escaping here. */ +function escapeCell(value: string): string { + return value.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim(); +} + +/** + * Orientation file at the history root. + * + * Written once so an agent that is pointed here by a global instruction file, + * with no other context, can work out the layout from the directory itself. + */ +export function renderSessionHistoryReadme(): string { + return `# T3 Code session history + +Durable records of archived T3 Code sessions. Each session's worktree may have +been reclaimed to free disk; these files are what remains, and they are meant to +be read as context by a later agent session. + +## Layout + + /INDEX.md entry point — every session, newest first + /YYYY-MM-DD--<id>.md digest for one session + <project>/YYYY-MM-DD-<title>-<id>.jsonl full transcript for that session + +## How to use this + +1. Start at the \`INDEX.md\` of the project you care about. One row per session, + with a one-line summary and the branch it worked on. +2. Read the \`.md\` digest for the session that looks relevant. It carries the + metadata, summary, git state, changed files, and every user prompt verbatim. +3. Only open the \`.jsonl\` if the digest is not enough. It is one JSON object per + line and can be large — grep it for the term you need rather than reading it + whole. + +## What is not here + +Assistant output lives only in the \`.jsonl\`. Reclaimed worktrees are gone; the +branch named in the digest is the place to look for the code itself. +`; +} diff --git a/apps/server/src/sessionArchive/liveWorktrees.test.ts b/apps/server/src/sessionArchive/liveWorktrees.test.ts new file mode 100644 index 00000000000..d3f8618a7af --- /dev/null +++ b/apps/server/src/sessionArchive/liveWorktrees.test.ts @@ -0,0 +1,131 @@ +/** + * T3-CUSTOM(expbkt3): Coverage for the two "something is using this" protections. + * + * The server-owned case matters most on the deployment box, where T3 Code runs + * out of a worktree that often has no thread pointing at it — nothing in the + * projection would protect it, so this function has to. + */ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { collectWorktreeUsage, serverOwnedWorktrees } from "./liveWorktrees.ts"; + +const WORKTREES_DIR = "/home/ubuntu/.t3/bkt3-dev/worktrees"; + +/** + * `execution` and `session` are optional-key fields, so `Partial<…>` rejects the + * partial stubs these cases need under `exactOptionalPropertyTypes`. The shape + * under test reads five fields; the cast keeps the fixtures to those five. + */ +const thread = (overrides: Record<string, unknown>): OrchestrationThreadShell => + ({ + id: "thread_1", + worktreePath: `${WORKTREES_DIR}/proj/one`, + archivedAt: "2026-01-01T00:00:00.000Z", + session: null, + execution: null, + ...overrides, + }) as unknown as OrchestrationThreadShell; + +describe("collectWorktreeUsage", () => { + it("treats a running session's worktree as live", () => { + const usage = collectWorktreeUsage([ + thread({ session: { status: "running" } as OrchestrationThreadShell["session"] }), + ]); + expect(usage.liveWorktreePaths.has(`${WORKTREES_DIR}/proj/one`)).toBe(true); + }); + + it("treats an idle session's worktree as not live", () => { + const usage = collectWorktreeUsage([ + thread({ session: { status: "idle" } as OrchestrationThreadShell["session"] }), + ]); + expect(usage.liveWorktreePaths.size).toBe(0); + }); + + it("still protects a live worktree whose thread is archived", () => { + const usage = collectWorktreeUsage([ + thread({ + archivedAt: "2026-01-01T00:00:00.000Z", + session: { status: "ready" } as OrchestrationThreadShell["session"], + }), + ]); + expect(usage.liveWorktreePaths.size).toBe(1); + expect(usage.activeThreadWorktreePaths.size).toBe(0); + }); + + it("treats a non-archived thread's worktree as active", () => { + const usage = collectWorktreeUsage([thread({ archivedAt: null })]); + expect(usage.activeThreadWorktreePaths.has(`${WORKTREES_DIR}/proj/one`)).toBe(true); + }); + + it("reads liveness off the execution snapshot too", () => { + const usage = collectWorktreeUsage([ + thread({ + execution: { + providerSession: { state: "ready" }, + turn: null, + } as OrchestrationThreadShell["execution"], + }), + ]); + expect(usage.liveWorktreePaths.size).toBe(1); + }); + + it("treats an in-flight turn as live even with a stopped provider session", () => { + const usage = collectWorktreeUsage([ + thread({ + execution: { + providerSession: { state: "stopped" }, + turn: { executionId: "exec_1" }, + } as OrchestrationThreadShell["execution"], + }), + ]); + expect(usage.liveWorktreePaths.size).toBe(1); + }); + + it("ignores threads with no worktree", () => { + const usage = collectWorktreeUsage([thread({ worktreePath: null, archivedAt: null })]); + expect(usage.activeThreadWorktreePaths.size).toBe(0); + }); +}); + +describe("serverOwnedWorktrees", () => { + it("protects the worktree the server runs from", () => { + const owned = serverOwnedWorktrees({ + serverCwd: `${WORKTREES_DIR}/t3code-bkmain/t3code-401beb01`, + worktreesDir: WORKTREES_DIR, + }); + expect([...owned]).toEqual([`${WORKTREES_DIR}/t3code-bkmain/t3code-401beb01`]); + }); + + it("protects the worktree root even when the process sits deeper", () => { + const owned = serverOwnedWorktrees({ + serverCwd: `${WORKTREES_DIR}/t3code-bkmain/t3code-401beb01/apps/server`, + worktreesDir: WORKTREES_DIR, + }); + expect([...owned]).toEqual([`${WORKTREES_DIR}/t3code-bkmain/t3code-401beb01`]); + }); + + it("protects nothing when the server runs outside the worktrees directory", () => { + const owned = serverOwnedWorktrees({ + serverCwd: "/home/ubuntu/repos/t3code", + worktreesDir: WORKTREES_DIR, + }); + expect(owned.size).toBe(0); + }); + + it("does not mistake a sibling directory for a worktree root", () => { + const owned = serverOwnedWorktrees({ + serverCwd: `${WORKTREES_DIR}-backup/proj/one`, + worktreesDir: WORKTREES_DIR, + }); + expect(owned.size).toBe(0); + }); + + it("protects nothing when the path is only one level deep", () => { + const owned = serverOwnedWorktrees({ + serverCwd: `${WORKTREES_DIR}/t3code-bkmain`, + worktreesDir: WORKTREES_DIR, + }); + expect(owned.size).toBe(0); + }); +}); diff --git a/apps/server/src/sessionArchive/liveWorktrees.ts b/apps/server/src/sessionArchive/liveWorktrees.ts new file mode 100644 index 00000000000..85cc2dadd36 --- /dev/null +++ b/apps/server/src/sessionArchive/liveWorktrees.ts @@ -0,0 +1,105 @@ +/** + * T3-CUSTOM(expbkt3): Which worktrees are off-limits because something is using them. + * + * Two distinct protections, easy to conflate and both load-bearing: + * + * - *Active* — some thread that is not archived points at the worktree. + * Reclaiming would pull the ground out from a session an operator still has + * open, even if nothing is running in it right now. + * - *Live* — a provider session is actually running there. On this fork's + * deployment box the T3 servers themselves run out of worktrees, so this is + * the rule that stops a sweep from killing the server executing it. + * + * Pure so both can be asserted without a running orchestrator. + */ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +import { normalizeWorktreePath } from "./reclaimEligibility.ts"; + +/** Session states that mean a provider process is attached to the worktree. */ +const LIVE_SESSION_STATUSES = new Set(["starting", "running", "ready"]); + +/** Provider-session states that mean the same thing on the execution snapshot. */ +const LIVE_PROVIDER_STATES = new Set(["starting", "ready", "stopping"]); + +export interface WorktreeUsage { + readonly liveWorktreePaths: ReadonlySet<string>; + readonly activeThreadWorktreePaths: ReadonlySet<string>; +} + +function isLive(thread: OrchestrationThreadShell): boolean { + if (thread.session !== null && LIVE_SESSION_STATUSES.has(thread.session.status)) { + return true; + } + const execution = thread.execution; + if (execution !== undefined && execution !== null) { + if (LIVE_PROVIDER_STATES.has(execution.providerSession.state)) { + return true; + } + if (execution.turn !== null) { + return true; + } + } + return false; +} + +/** + * Partition every thread's worktree into the two protected sets. + * + * Takes the *full* shell snapshot, archived threads included: an archived + * thread whose session never stopped is still live, and excluding it here would + * make the live gate miss exactly the case it exists for. + */ +export function collectWorktreeUsage( + threads: ReadonlyArray<OrchestrationThreadShell>, +): WorktreeUsage { + const live = new Set<string>(); + const active = new Set<string>(); + + for (const thread of threads) { + const worktreePath = normalizeWorktreePath(thread.worktreePath); + if (worktreePath === null) { + continue; + } + if (isLive(thread)) { + live.add(worktreePath); + } + if (thread.archivedAt === null) { + active.add(worktreePath); + } + } + + return { liveWorktreePaths: live, activeThreadWorktreePaths: active }; +} + +/** + * Worktrees the current server process is itself running from. + * + * Independent of thread state on purpose: the deployment worktree of a running + * T3 Code server frequently has no thread pointing at it at all, so nothing in + * the projection would protect it. + */ +export function serverOwnedWorktrees(input: { + readonly serverCwd: string; + readonly worktreesDir: string; +}): ReadonlySet<string> { + const owned = new Set<string>(); + const cwd = normalizeWorktreePath(input.serverCwd); + if (cwd === null) { + return owned; + } + const root = normalizeWorktreePath(input.worktreesDir); + if (root === null || !cwd.startsWith(`${root}/`)) { + return owned; + } + // `<worktreesDir>/<project>/<worktree>` — protect the worktree directory + // itself, whatever subdirectory the process happens to be sitting in. + const relativeSegments = cwd + .slice(root.length + 1) + .split("/") + .filter(Boolean); + if (relativeSegments.length >= 2) { + owned.add(`${root}/${relativeSegments[0]}/${relativeSegments[1]}`); + } + return owned; +} diff --git a/apps/server/src/sessionArchive/reclaimEligibility.test.ts b/apps/server/src/sessionArchive/reclaimEligibility.test.ts new file mode 100644 index 00000000000..09e80cdeeaa --- /dev/null +++ b/apps/server/src/sessionArchive/reclaimEligibility.test.ts @@ -0,0 +1,234 @@ +/** + * T3-CUSTOM(expbkt3): Coverage for the archived-worktree reclaim gates. + * + * These gates are the only thing standing between the feature and deleting + * someone's running worktree, so each one is asserted directly rather than + * through the service that calls them. + */ +import { describe, expect, it } from "@effect/vitest"; + +import { + evaluateReclaimEligibility, + isPastRetention, + normalizeWorktreePath, + type ReclaimEligibilityInput, +} from "./reclaimEligibility.ts"; + +const NOW_MS = Date.parse("2026-08-07T00:00:00.000Z"); +const ARCHIVED_LONG_AGO = "2026-01-01T00:00:00.000Z"; +const WORKTREE = "/home/ubuntu/.t3/dev/worktrees/proj/feature"; + +const CLEAN_GIT = { + hasUncommittedChanges: false, + hasUntrackedFiles: false, + hasUnpushedCommits: false, +}; + +const input = (overrides: Partial<ReclaimEligibilityInput> = {}): ReclaimEligibilityInput => ({ + thread: { threadId: "thread_1", worktreePath: WORKTREE, archivedAt: ARCHIVED_LONG_AGO }, + mode: "slim", + git: CLEAN_GIT, + liveWorktreePaths: new Set(), + activeThreadWorktreePaths: new Set(), + minArchivedDays: 0, + nowMs: NOW_MS, + force: false, + ...overrides, +}); + +describe("evaluateReclaimEligibility", () => { + it("allows a slim of a clean, solely-owned, archived worktree", () => { + expect(evaluateReclaimEligibility(input())).toEqual({ eligible: true, blockedReason: null }); + }); + + it("refuses a thread that is not archived", () => { + const result = evaluateReclaimEligibility( + input({ thread: { threadId: "thread_1", worktreePath: WORKTREE, archivedAt: null } }), + ); + expect(result.blockedReason).toBe("not-archived"); + }); + + it("refuses a thread with no worktree", () => { + const result = evaluateReclaimEligibility( + input({ + thread: { threadId: "thread_1", worktreePath: null, archivedAt: ARCHIVED_LONG_AGO }, + }), + ); + expect(result.blockedReason).toBe("no-worktree"); + }); + + it("refuses a worktree a session is running out of", () => { + const result = evaluateReclaimEligibility(input({ liveWorktreePaths: new Set([WORKTREE]) })); + expect(result.blockedReason).toBe("worktree-live"); + }); + + it("refuses a worktree still referenced by an active thread", () => { + const result = evaluateReclaimEligibility( + input({ activeThreadWorktreePaths: new Set([WORKTREE]) }), + ); + expect(result.blockedReason).toBe("worktree-shared"); + }); + + it("keeps the live and shared gates un-forceable", () => { + expect( + evaluateReclaimEligibility( + input({ force: true, mode: "remove", liveWorktreePaths: new Set([WORKTREE]) }), + ).blockedReason, + ).toBe("worktree-live"); + expect( + evaluateReclaimEligibility( + input({ force: true, mode: "remove", activeThreadWorktreePaths: new Set([WORKTREE]) }), + ).blockedReason, + ).toBe("worktree-shared"); + }); + + it("reports the live gate ahead of a dirty tree", () => { + const result = evaluateReclaimEligibility( + input({ + mode: "remove", + liveWorktreePaths: new Set([WORKTREE]), + git: { ...CLEAN_GIT, hasUncommittedChanges: true }, + }), + ); + expect(result.blockedReason).toBe("worktree-live"); + }); + + it("holds a session inside the retention window", () => { + const result = evaluateReclaimEligibility( + input({ + minArchivedDays: 14, + thread: { + threadId: "thread_1", + worktreePath: WORKTREE, + archivedAt: "2026-08-01T00:00:00.000Z", + }, + }), + ); + expect(result.blockedReason).toBe("retention-window"); + }); + + it("releases a session once the retention window has passed", () => { + expect(evaluateReclaimEligibility(input({ minArchivedDays: 14 })).eligible).toBe(true); + }); + + describe("mode: remove", () => { + it("refuses uncommitted changes", () => { + const result = evaluateReclaimEligibility( + input({ mode: "remove", git: { ...CLEAN_GIT, hasUncommittedChanges: true } }), + ); + expect(result.blockedReason).toBe("dirty-worktree"); + }); + + it("refuses untracked files", () => { + const result = evaluateReclaimEligibility( + input({ mode: "remove", git: { ...CLEAN_GIT, hasUntrackedFiles: true } }), + ); + expect(result.blockedReason).toBe("dirty-worktree"); + }); + + it("refuses unpushed commits", () => { + const result = evaluateReclaimEligibility( + input({ mode: "remove", git: { ...CLEAN_GIT, hasUnpushedCommits: true } }), + ); + expect(result.blockedReason).toBe("unpushed-commits"); + }); + + it("allows those to be forced", () => { + const result = evaluateReclaimEligibility( + input({ + mode: "remove", + force: true, + git: { hasUncommittedChanges: true, hasUntrackedFiles: true, hasUnpushedCommits: true }, + }), + ); + expect(result.eligible).toBe(true); + }); + + it("refuses when git facts are unavailable", () => { + const result = evaluateReclaimEligibility(input({ mode: "remove", git: null })); + expect(result.blockedReason).toBe("no-worktree"); + }); + }); + + describe("mode: slim", () => { + it("ignores a dirty tree, because it only deletes ignored directories", () => { + const result = evaluateReclaimEligibility( + input({ + git: { hasUncommittedChanges: true, hasUntrackedFiles: true, hasUnpushedCommits: true }, + }), + ); + expect(result.eligible).toBe(true); + }); + }); +}); + +describe("isPastRetention", () => { + it("treats a zero window as always past", () => { + expect(isPastRetention({ archivedAt: null, minArchivedDays: 0, nowMs: NOW_MS })).toBe(true); + }); + + it("fails closed on a missing timestamp", () => { + expect(isPastRetention({ archivedAt: null, minArchivedDays: 1, nowMs: NOW_MS })).toBe(false); + }); + + it("fails closed on an unparseable timestamp", () => { + expect(isPastRetention({ archivedAt: "not a date", minArchivedDays: 1, nowMs: NOW_MS })).toBe( + false, + ); + }); + + it("is inclusive at the boundary", () => { + // Exactly 14 days before NOW_MS (2026-08-07T00:00:00Z). + const archivedAt = "2026-07-24T00:00:00.000Z"; + expect(isPastRetention({ archivedAt, minArchivedDays: 14, nowMs: NOW_MS })).toBe(true); + }); + + it("holds one millisecond short of the boundary", () => { + const archivedAt = "2026-07-24T00:00:00.001Z"; + expect(isPastRetention({ archivedAt, minArchivedDays: 14, nowMs: NOW_MS })).toBe(false); + }); +}); + +describe("normalizeWorktreePath", () => { + it("maps blank and whitespace-only paths to null", () => { + expect(normalizeWorktreePath(null)).toBeNull(); + expect(normalizeWorktreePath("")).toBeNull(); + expect(normalizeWorktreePath(" ")).toBeNull(); + }); + + it("trims so both tiers compare the same string", () => { + expect(normalizeWorktreePath(` ${WORKTREE} `)).toBe(WORKTREE); + }); +}); + +describe("per-mode reporting the scan relies on", () => { + // The panel reports `blockedReason` (slim) and `removeBlockedReason` (remove) + // separately, and its force affordance keys off the difference. These assert + // the two evaluations really do diverge only where they should. + it("lets a dirty worktree slim while refusing to remove it", () => { + const dirty = { ...CLEAN_GIT, hasUncommittedChanges: true }; + expect( + evaluateReclaimEligibility(input({ mode: "slim", git: dirty })).blockedReason, + ).toBeNull(); + expect(evaluateReclaimEligibility(input({ mode: "remove", git: dirty })).blockedReason).toBe( + "dirty-worktree", + ); + }); + + it("reports a mode-independent gate identically for both modes", () => { + for (const overrides of [ + { liveWorktreePaths: new Set([WORKTREE]) }, + { activeThreadWorktreePaths: new Set([WORKTREE]) }, + ]) { + const slim = evaluateReclaimEligibility(input({ ...overrides, mode: "slim" })); + const remove = evaluateReclaimEligibility(input({ ...overrides, mode: "remove" })); + expect(slim.blockedReason).toBe(remove.blockedReason); + expect(slim.blockedReason).not.toBeNull(); + } + }); + + it("only ever reports a forceable reason for the remove mode", () => { + const bad = { hasUncommittedChanges: true, hasUntrackedFiles: true, hasUnpushedCommits: true }; + expect(evaluateReclaimEligibility(input({ mode: "slim", git: bad })).blockedReason).toBeNull(); + }); +}); diff --git a/apps/server/src/sessionArchive/reclaimEligibility.ts b/apps/server/src/sessionArchive/reclaimEligibility.ts new file mode 100644 index 00000000000..a92d359211b --- /dev/null +++ b/apps/server/src/sessionArchive/reclaimEligibility.ts @@ -0,0 +1,180 @@ +/** + * T3-CUSTOM(expbkt3): Whether an archived session's worktree may be reclaimed. + * + * This is the gate that keeps the feature from being destructive. Two of its + * rules protect *other* people's work — a worktree shared with a live thread, + * or one a session is running out of right now — and those are never + * overridable. The rest protect the operator's own uncommitted work and can be + * forced deliberately. + * + * Pure on purpose, in the style of `../thread-title/titleRefreshCadence.ts`: + * the service call site stays one line, and every gate is testable without a + * filesystem, a git repository, or a running server. + */ +import type { SessionArchiveBlockedReason, SessionArchiveReclaimMode } from "@t3tools/contracts"; + +/** The subset of a thread shell this decision needs. */ +export interface ReclaimThreadFacts { + readonly threadId: string; + readonly worktreePath: string | null; + /** Null means the thread is not archived. */ + readonly archivedAt: string | null; +} + +/** Git facts for the worktree, read once per scan. */ +export interface ReclaimGitFacts { + /** Tracked files with modifications, or staged changes. */ + readonly hasUncommittedChanges: boolean; + /** Untracked, non-ignored files. Lost forever on a `remove`. */ + readonly hasUntrackedFiles: boolean; + /** Commits on the branch that no remote has. */ + readonly hasUnpushedCommits: boolean; +} + +export interface ReclaimEligibilityInput { + readonly thread: ReclaimThreadFacts; + readonly mode: SessionArchiveReclaimMode; + /** Git facts, or null when the worktree is already gone from disk. */ + readonly git: ReclaimGitFacts | null; + /** + * Worktree paths in use by a running provider session or by a live T3 + * deployment. Reclaiming one of these kills a running process. + */ + readonly liveWorktreePaths: ReadonlySet<string>; + /** Worktree paths referenced by at least one thread that is *not* archived. */ + readonly activeThreadWorktreePaths: ReadonlySet<string>; + /** + * Retention floor, in days, applied to `archivedAt`. Zero disables it. The + * panel passes zero (the operator is looking right at the session); the + * sweeper passes the configured window. + */ + readonly minArchivedDays: number; + /** Now, as epoch milliseconds. Injected so the gate stays pure. */ + readonly nowMs: number; + /** + * Override the dirty-tree and unpushed-commit gates. Deliberately powerless + * against the shared/live gates below. + */ + readonly force: boolean; +} + +export interface ReclaimEligibility { + readonly eligible: boolean; + readonly blockedReason: SessionArchiveBlockedReason | null; +} + +const ELIGIBLE: ReclaimEligibility = { eligible: true, blockedReason: null }; + +const blocked = (blockedReason: SessionArchiveBlockedReason): ReclaimEligibility => ({ + eligible: false, + blockedReason, +}); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** Normalize the way `worktreeCleanup.ts` does, so both tiers compare alike. */ +export function normalizeWorktreePath(path: string | null | undefined): string | null { + const trimmed = path?.trim(); + return trimmed ? trimmed : null; +} + +/** + * Whether enough time has passed since archiving. + * + * An unparseable or missing `archivedAt` fails closed: a retention window we + * cannot evaluate is not a window we may ignore. + */ +export function isPastRetention(input: { + readonly archivedAt: string | null; + readonly minArchivedDays: number; + readonly nowMs: number; +}): boolean { + if (input.minArchivedDays <= 0) { + return true; + } + if (input.archivedAt === null) { + return false; + } + const archivedMs = Date.parse(input.archivedAt); + if (Number.isNaN(archivedMs)) { + return false; + } + return input.nowMs - archivedMs >= input.minArchivedDays * MS_PER_DAY; +} + +/** + * Evaluate every gate in order and report the first that fails. + * + * Order matters for the message the operator sees: the un-overridable + * protections are checked before the forceable ones, so "shared with a live + * session" is never masked by "you have uncommitted changes". + */ +export function evaluateReclaimEligibility(input: ReclaimEligibilityInput): ReclaimEligibility { + const { thread, mode, git, force } = input; + + if (thread.archivedAt === null) { + return blocked("not-archived"); + } + + const worktreePath = normalizeWorktreePath(thread.worktreePath); + if (worktreePath === null) { + return blocked("no-worktree"); + } + + // Un-overridable: these protect work that is not the operator's to discard. + if (input.liveWorktreePaths.has(worktreePath)) { + return blocked("worktree-live"); + } + if (input.activeThreadWorktreePaths.has(worktreePath)) { + return blocked("worktree-shared"); + } + + if ( + !isPastRetention({ + archivedAt: thread.archivedAt, + minArchivedDays: input.minArchivedDays, + nowMs: input.nowMs, + }) + ) { + return blocked("retention-window"); + } + + // A slim only deletes regenerable, git-ignored directories, so uncommitted + // work survives it. Removing the worktree does not, hence the extra gates. + if (mode === "remove") { + if (git === null) { + return blocked("no-worktree"); + } + if (!force && (git.hasUncommittedChanges || git.hasUntrackedFiles)) { + return blocked("dirty-worktree"); + } + if (!force && git.hasUnpushedCommits) { + return blocked("unpushed-commits"); + } + } + + return ELIGIBLE; +} + +/** + * Human-facing text for a gate. Kept beside the gates so a new reason cannot be + * added without deciding what the panel will say about it. + */ +export function describeBlockedReason(reason: SessionArchiveBlockedReason): string { + switch (reason) { + case "not-archived": + return "Only archived sessions can be reclaimed."; + case "worktree-shared": + return "Another active session still uses this worktree."; + case "worktree-live": + return "A session is running out of this worktree right now."; + case "retention-window": + return "Archived too recently for the configured retention window."; + case "dirty-worktree": + return "Uncommitted or untracked changes would be lost."; + case "unpushed-commits": + return "Commits here are not on any remote yet."; + case "no-worktree": + return "This session has no worktree on disk."; + } +} diff --git a/apps/server/src/sessionArchive/slimTargets.test.ts b/apps/server/src/sessionArchive/slimTargets.test.ts new file mode 100644 index 00000000000..4b3eeed88ff --- /dev/null +++ b/apps/server/src/sessionArchive/slimTargets.test.ts @@ -0,0 +1,111 @@ +/** + * T3-CUSTOM(expbkt3): Coverage for what a "slim" is allowed to delete. + * + * The tracked-path guard is the load-bearing rule here: the target list alone + * would happily delete a `dist/` that a project actually commits. + */ +import { describe, expect, it } from "@effect/vitest"; + +import { + decideSlimCandidate, + outermostCandidates, + shouldDescendInto, + SLIM_TARGET_DIRECTORY_NAMES, +} from "./slimTargets.ts"; + +const noTrackedFiles = new Set<string>(); + +const candidate = (relativePath: string) => ({ + relativePath, + name: relativePath.split("/").findLast((segment) => segment.length > 0) ?? "", +}); + +describe("decideSlimCandidate", () => { + it("deletes a top-level node_modules", () => { + expect(decideSlimCandidate(candidate("node_modules"), noTrackedFiles)).toEqual({ + deletable: true, + reason: "regenerable", + }); + }); + + it("deletes a nested package's node_modules", () => { + expect( + decideSlimCandidate(candidate("packages/contracts/node_modules"), noTrackedFiles).deletable, + ).toBe(true); + }); + + it("leaves a directory that is not a known target", () => { + expect(decideSlimCandidate(candidate("src"), noTrackedFiles)).toEqual({ + deletable: false, + reason: "not-a-target", + }); + }); + + it("never touches anything under .git", () => { + expect(decideSlimCandidate(candidate(".git/modules/x/node_modules"), noTrackedFiles)).toEqual({ + deletable: false, + reason: "excluded-directory", + }); + }); + + it("refuses a target directory that holds tracked files", () => { + const tracked = new Set(["vendor/dist/bundled.js"]); + expect(decideSlimCandidate(candidate("vendor/dist"), tracked)).toEqual({ + deletable: false, + reason: "git-tracked", + }); + }); + + it("refuses a target directory that is itself a tracked path", () => { + const tracked = new Set(["build"]); + expect(decideSlimCandidate(candidate("build"), tracked).reason).toBe("git-tracked"); + }); + + it("is not confused by a tracked sibling with a shared prefix", () => { + const tracked = new Set(["dist-notes/readme.md"]); + expect(decideSlimCandidate(candidate("dist"), tracked).deletable).toBe(true); + }); + + it("rejects a path that escapes the worktree", () => { + expect(decideSlimCandidate(candidate("../node_modules"), noTrackedFiles)).toEqual({ + deletable: false, + reason: "escapes-worktree", + }); + expect(decideSlimCandidate({ relativePath: "", name: "" }, noTrackedFiles).reason).toBe( + "escapes-worktree", + ); + }); + + it("covers every advertised target name", () => { + for (const name of SLIM_TARGET_DIRECTORY_NAMES) { + expect(decideSlimCandidate(candidate(name), noTrackedFiles).deletable).toBe(true); + } + }); +}); + +describe("shouldDescendInto", () => { + it("stops at .git and at matched targets", () => { + expect(shouldDescendInto(".git")).toBe(false); + expect(shouldDescendInto("node_modules")).toBe(false); + }); + + it("descends into ordinary directories", () => { + expect(shouldDescendInto("packages")).toBe(true); + }); +}); + +describe("outermostCandidates", () => { + it("drops a match nested inside another match", () => { + const result = outermostCandidates([ + candidate("node_modules"), + candidate("node_modules/foo/node_modules"), + candidate("apps/web/dist"), + ]); + expect(result.map((entry) => entry.relativePath)).toEqual(["node_modules", "apps/web/dist"]); + }); + + it("keeps siblings whose paths share a prefix", () => { + const result = outermostCandidates([candidate("dist"), candidate("dist-tools")]); + expect(result).toHaveLength(2); + }); +}); diff --git a/apps/server/src/sessionArchive/slimTargets.ts b/apps/server/src/sessionArchive/slimTargets.ts new file mode 100644 index 00000000000..710ee05584f --- /dev/null +++ b/apps/server/src/sessionArchive/slimTargets.ts @@ -0,0 +1,144 @@ +/** + * T3-CUSTOM(expbkt3): Which directories inside a worktree are regenerable. + * + * "Slimming" a worktree means deleting what a package manager or build tool can + * put back, and nothing else. The checkout stays valid, `git status` stays + * clean, and reopening the session costs an install rather than a clone. + * + * Pure on purpose: the decision about what may be deleted is the dangerous part + * of this feature, so it is testable without touching a filesystem. + */ + +/** + * Directory names deleted at any depth below the worktree root. + * + * Every entry has to satisfy two things: a standard tool recreates it, and it + * is conventionally git-ignored. `target` is the widest of these — it is Rust's + * build directory but a plausible source directory name elsewhere — so the + * tracked-path guard below, not this list, is what makes it safe. + */ +export const SLIM_TARGET_DIRECTORY_NAMES: ReadonlyArray<string> = [ + "node_modules", + "dist", + "build", + "out", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + ".vite", + ".parcel-cache", + ".venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "coverage", + "target", + ".gradle", + ".cargo-target", +]; + +/** + * Never descended into, whatever else matches. + * + * `.git` holds the worktree's link to the repository; deleting anything under + * it turns a slim into a corruption. The rest are directories a match inside + * would be meaningless for — a `node_modules` nested under another + * `node_modules` is removed with its parent anyway. + */ +export const SLIM_EXCLUDED_DIRECTORY_NAMES: ReadonlyArray<string> = [".git"]; + +const targetNames = new Set(SLIM_TARGET_DIRECTORY_NAMES); +const excludedNames = new Set(SLIM_EXCLUDED_DIRECTORY_NAMES); + +export interface SlimCandidate { + /** Path relative to the worktree root, using forward slashes. */ + readonly relativePath: string; + readonly name: string; +} + +export interface SlimDecision { + readonly deletable: boolean; + readonly reason: + | "regenerable" + | "not-a-target" + | "excluded-directory" + | "git-tracked" + | "escapes-worktree"; +} + +/** Split a relative path the same way for every check below. */ +function segmentsOf(relativePath: string): ReadonlyArray<string> { + return relativePath.split("/").filter((segment) => segment.length > 0); +} + +/** + * Decide whether one directory may be deleted. + * + * `trackedPaths` are the repository's tracked paths relative to the worktree + * root (`git ls-files`). A directory containing tracked files is never + * regenerable no matter what it is called — that is what stops a project which + * genuinely commits its `dist/` from being gutted. + */ +export function decideSlimCandidate( + candidate: SlimCandidate, + trackedPaths: ReadonlySet<string>, +): SlimDecision { + const segments = segmentsOf(candidate.relativePath); + + // `..` can only appear if a caller handed us a path outside the root. + if (segments.length === 0 || segments.includes("..")) { + return { deletable: false, reason: "escapes-worktree" }; + } + if (segments.some((segment) => excludedNames.has(segment))) { + return { deletable: false, reason: "excluded-directory" }; + } + if (!targetNames.has(candidate.name)) { + return { deletable: false, reason: "not-a-target" }; + } + + const prefix = `${segments.join("/")}/`; + for (const tracked of trackedPaths) { + if (tracked === candidate.relativePath || tracked.startsWith(prefix)) { + return { deletable: false, reason: "git-tracked" }; + } + } + + return { deletable: true, reason: "regenerable" }; +} + +/** + * Whether a walk should descend into a directory. + * + * A matched target is not descended into: it is deleted whole, and its children + * would only produce redundant candidates. + */ +export function shouldDescendInto(name: string): boolean { + return !excludedNames.has(name) && !targetNames.has(name); +} + +/** + * Reduce candidates to the outermost ones. + * + * A walk that does not honour {@link shouldDescendInto} can surface nested + * matches; deleting the parent already removes the child, and attempting the + * child afterwards would fail on a missing path. + */ +export function outermostCandidates( + candidates: ReadonlyArray<SlimCandidate>, +): ReadonlyArray<SlimCandidate> { + const sorted = [...candidates].sort( + (left, right) => left.relativePath.length - right.relativePath.length, + ); + const kept: Array<SlimCandidate> = []; + for (const candidate of sorted) { + const nested = kept.some((existing) => + candidate.relativePath.startsWith(`${existing.relativePath}/`), + ); + if (!nested) { + kept.push(candidate); + } + } + return kept; +} diff --git a/apps/server/src/sessionArchive/worktreeScan.ts b/apps/server/src/sessionArchive/worktreeScan.ts new file mode 100644 index 00000000000..1165f287c4b --- /dev/null +++ b/apps/server/src/sessionArchive/worktreeScan.ts @@ -0,0 +1,232 @@ +/** + * T3-CUSTOM(expbkt3): Walking a worktree to size it and find what to slim. + * + * Sizing hundreds of worktrees is the expensive part of this feature — on the + * box that motivated it, `du` over the whole worktrees directory took minutes + * and this host has already had one IO-saturation incident. So the walk is + * budgeted rather than exhaustive: it stops after a fixed number of entries and + * reports that it stopped, which the panel surfaces instead of pretending the + * number is complete. + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { + decideSlimCandidate, + outermostCandidates, + shouldDescendInto, + type SlimCandidate, +} from "./slimTargets.ts"; + +/** + * Directory entries visited before a single walk gives up. + * + * A fully installed monorepo worktree is well under this; a pathological one is + * exactly the case we do not want to spend the box's IO budget on. + */ +export const WALK_ENTRY_BUDGET = 200_000; + +export interface WorktreeScanResult { + /** Total bytes, or null when the budget ran out before the walk finished. */ + readonly totalBytes: number | null; + /** Bytes inside slim candidates — what a slim would actually give back. */ + readonly reclaimableBytes: number; + readonly slimCandidates: ReadonlyArray<SlimCandidate>; + readonly budgetExhausted: boolean; +} + +const EMPTY_RESULT: WorktreeScanResult = { + totalBytes: 0, + reclaimableBytes: 0, + slimCandidates: [], + budgetExhausted: false, +}; + +/** + * Sum a directory's apparent size. + * + * Symlinks are counted as their own (tiny) size and never followed: a link out + * of the worktree would otherwise be billed to this session, and a cyclic one + * would not terminate. + */ +const measureDirectory = Effect.fn("SessionArchive.measureDirectory")(function* ( + root: string, + budget: { remaining: number }, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + let total = 0; + const pending: Array<string> = [root]; + + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + if (budget.remaining <= 0) { + return { bytes: total, exhausted: true }; + } + + const names = yield* fs.readDirectory(current).pipe(Effect.orElseSucceed(() => [])); + for (const name of names) { + budget.remaining -= 1; + if (budget.remaining <= 0) { + return { bytes: total, exhausted: true }; + } + const child = path.join(current, name); + const info = yield* fs.stat(child).pipe(Effect.option); + if (info._tag === "None") { + continue; + } + const entry = info.value; + if (entry.type === "Directory") { + pending.push(child); + continue; + } + if (entry.type === "File") { + total += Number(entry.size); + } + } + } + + return { bytes: total, exhausted: false }; +}); + +/** + * Walk a worktree once, collecting its size and its slim candidates. + * + * `trackedPaths` comes from `git ls-files`; it is what stops a project that + * genuinely commits a `dist/` from having it deleted. Passing an empty set is + * safe but conservative in the wrong direction, so callers should read git + * first and only fall back to empty when the worktree is not a repository. + */ +export const scanWorktree = Effect.fn("SessionArchive.scanWorktree")(function* (input: { + readonly worktreePath: string; + readonly trackedPaths: ReadonlySet<string>; + readonly entryBudget?: number; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const exists = yield* fs.exists(input.worktreePath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return EMPTY_RESULT; + } + + const budget = { remaining: input.entryBudget ?? WALK_ENTRY_BUDGET }; + const candidates: Array<SlimCandidate> = []; + let totalBytes = 0; + let exhausted = false; + + // Directories still to visit, as paths relative to the worktree root. + const pending: Array<string> = [""]; + + while (pending.length > 0 && !exhausted) { + const relativeDir = pending.pop(); + if (relativeDir === undefined) break; + + const absoluteDir = + relativeDir === "" ? input.worktreePath : path.join(input.worktreePath, relativeDir); + const names = yield* fs.readDirectory(absoluteDir).pipe(Effect.orElseSucceed(() => [])); + + for (const name of names) { + budget.remaining -= 1; + if (budget.remaining <= 0) { + exhausted = true; + break; + } + + const relativePath = relativeDir === "" ? name : `${relativeDir}/${name}`; + const absolutePath = path.join(input.worktreePath, relativePath); + const info = yield* fs.stat(absolutePath).pipe(Effect.option); + if (info._tag === "None") { + continue; + } + const entry = info.value; + + if (entry.type === "File") { + totalBytes += Number(entry.size); + continue; + } + if (entry.type !== "Directory") { + continue; + } + + const candidate: SlimCandidate = { relativePath, name }; + if (decideSlimCandidate(candidate, input.trackedPaths).deletable) { + candidates.push(candidate); + // Size it as a unit; the walk does not descend into a directory it is + // about to delete whole. + const measured = yield* measureDirectory(absolutePath, budget); + totalBytes += measured.bytes; + exhausted = exhausted || measured.exhausted; + continue; + } + + if (shouldDescendInto(name)) { + pending.push(relativePath); + continue; + } + + // Not descended into and not deletable — `.git`, or a target the tracked + // guard vetoed. Still counts toward the total. + const measured = yield* measureDirectory(absolutePath, budget); + totalBytes += measured.bytes; + exhausted = exhausted || measured.exhausted; + } + } + + const slimCandidates = outermostCandidates(candidates); + let reclaimableBytes = 0; + for (const candidate of slimCandidates) { + const measured = yield* measureDirectory( + path.join(input.worktreePath, candidate.relativePath), + { + remaining: WALK_ENTRY_BUDGET, + }, + ); + reclaimableBytes += measured.bytes; + } + + return { + totalBytes: exhausted ? null : totalBytes, + reclaimableBytes, + slimCandidates, + budgetExhausted: exhausted, + } satisfies WorktreeScanResult; +}); + +/** + * Delete a worktree's slim candidates. + * + * Re-checks each candidate against the tracked-path guard immediately before + * deleting rather than trusting the scan's verdict: a scan result can be + * minutes old by the time an operator clicks, and the cost of re-deciding is + * nothing next to the cost of being wrong. + */ +export const slimWorktree = Effect.fn("SessionArchive.slimWorktree")(function* (input: { + readonly worktreePath: string; + readonly candidates: ReadonlyArray<SlimCandidate>; + readonly trackedPaths: ReadonlySet<string>; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + let freedBytes = 0; + for (const candidate of input.candidates) { + if (!decideSlimCandidate(candidate, input.trackedPaths).deletable) { + continue; + } + const absolutePath = path.join(input.worktreePath, candidate.relativePath); + const measured = yield* measureDirectory(absolutePath, { remaining: WALK_ENTRY_BUDGET }); + const removed = yield* fs.remove(absolutePath, { recursive: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (removed) { + freedBytes += measured.bytes; + } + } + + return freedBytes; +}); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index b64bb997e80..d68cc1081b8 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -24,12 +24,21 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildCatchupSummaryPrompt, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + buildWorkSummaryPrompt, + // T3-CUSTOM(expbkt3): END buildRollingSummaryPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { normalizeCliError, sanitizeCatchupSummary, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + sanitizeWorkSummary, + sanitizeWorkSummaryPercent, + sanitizeWorkSummaryRemaining, + sanitizeWorkSummaryStage, + // T3-CUSTOM(expbkt3): END sanitizeCommitSubject, sanitizePrTitle, sanitizeRollingSummary, @@ -91,7 +100,10 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateBranchName" | "generateThreadTitle" | "updateRollingSummary" - | "generateCatchupSummary", + | "generateCatchupSummary" + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + | "generateWorkSummary", + // T3-CUSTOM(expbkt3): END value: unknown, detail: string, ): Effect.Effect<string, TextGenerationError> => @@ -123,7 +135,10 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateBranchName" | "generateThreadTitle" | "updateRollingSummary" - | "generateCatchupSummary"; + | "generateCatchupSummary" + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + | "generateWorkSummary"; + // T3-CUSTOM(expbkt3): END cwd: string; prompt: string; outputSchemaJson: S; @@ -411,6 +426,31 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu } satisfies TextGeneration.CatchupSummaryGenerationResult; }); + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + const generateWorkSummary: TextGeneration.TextGeneration["Service"]["generateWorkSummary"] = + Effect.fn("ClaudeTextGeneration.generateWorkSummary")(function* (input) { + const { prompt, outputSchema } = buildWorkSummaryPrompt({ + context: input.context, + promptInstructions: input.promptInstructions, + }); + + const generated = yield* runClaudeJson({ + operation: "generateWorkSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + summary: sanitizeWorkSummary(generated.summary), + stage: sanitizeWorkSummaryStage(generated.stage), + remaining: sanitizeWorkSummaryRemaining(generated.remaining), + percent: sanitizeWorkSummaryPercent(generated.percent), + } satisfies TextGeneration.WorkSummaryGenerationResult; + }); + // T3-CUSTOM(expbkt3): END + return { generateCommitMessage, generatePrContent, @@ -418,5 +458,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu generateThreadTitle, updateRollingSummary, generateCatchupSummary, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + generateWorkSummary, + // T3-CUSTOM(expbkt3): END } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 10e70fefe77..1224f8952dd 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -26,12 +26,21 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildCatchupSummaryPrompt, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + buildWorkSummaryPrompt, + // T3-CUSTOM(expbkt3): END buildRollingSummaryPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { normalizeCliError, sanitizeCatchupSummary, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + sanitizeWorkSummary, + sanitizeWorkSummaryPercent, + sanitizeWorkSummaryRemaining, + sanitizeWorkSummaryStage, + // T3-CUSTOM(expbkt3): END sanitizeCommitSubject, sanitizePrTitle, sanitizeRollingSummary, @@ -107,7 +116,10 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateBranchName" | "generateThreadTitle" | "updateRollingSummary" - | "generateCatchupSummary", + | "generateCatchupSummary" + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + | "generateWorkSummary", + // T3-CUSTOM(expbkt3): END value: unknown, ): Effect.Effect<string, TextGenerationError> => encodeJsonString(value).pipe( @@ -128,7 +140,10 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateBranchName" | "generateThreadTitle" | "updateRollingSummary" - | "generateCatchupSummary", + | "generateCatchupSummary" + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + | "generateWorkSummary", + // T3-CUSTOM(expbkt3): END attachments: TextGeneration.BranchNameGenerationInput["attachments"], ): Effect.fn.Return<MaterializedImageAttachments, TextGenerationError> { if (!attachments || attachments.length === 0) { @@ -172,7 +187,10 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateBranchName" | "generateThreadTitle" | "updateRollingSummary" - | "generateCatchupSummary"; + | "generateCatchupSummary" + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + | "generateWorkSummary"; + // T3-CUSTOM(expbkt3): END cwd: string; prompt: string; outputSchemaJson: S; @@ -459,6 +477,31 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } satisfies TextGeneration.CatchupSummaryGenerationResult; }); + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + const generateWorkSummary: TextGeneration.TextGeneration["Service"]["generateWorkSummary"] = + Effect.fn("CodexTextGeneration.generateWorkSummary")(function* (input) { + const { prompt, outputSchema } = buildWorkSummaryPrompt({ + context: input.context, + promptInstructions: input.promptInstructions, + }); + + const generated = yield* runCodexJson({ + operation: "generateWorkSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + summary: sanitizeWorkSummary(generated.summary), + stage: sanitizeWorkSummaryStage(generated.stage), + remaining: sanitizeWorkSummaryRemaining(generated.remaining), + percent: sanitizeWorkSummaryPercent(generated.percent), + } satisfies TextGeneration.WorkSummaryGenerationResult; + }); + // T3-CUSTOM(expbkt3): END + return { generateCommitMessage, generatePrContent, @@ -466,5 +509,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func generateThreadTitle, updateRollingSummary, generateCatchupSummary, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + generateWorkSummary, + // T3-CUSTOM(expbkt3): END } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index a824d201a36..25758416eee 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -16,6 +16,9 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildCatchupSummaryPrompt, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + buildWorkSummaryPrompt, + // T3-CUSTOM(expbkt3): END buildRollingSummaryPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; @@ -23,6 +26,12 @@ import { sanitizeCommitSubject, sanitizePrTitle, sanitizeCatchupSummary, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + sanitizeWorkSummary, + sanitizeWorkSummaryPercent, + sanitizeWorkSummaryRemaining, + sanitizeWorkSummaryStage, + // T3-CUSTOM(expbkt3): END sanitizeRollingSummary, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; @@ -60,7 +69,10 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | "generateBranchName" | "generateThreadTitle" | "updateRollingSummary" - | "generateCatchupSummary"; + | "generateCatchupSummary" + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + | "generateWorkSummary"; + // T3-CUSTOM(expbkt3): END cwd: string; prompt: string; outputSchemaJson: S; @@ -309,6 +321,31 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu } satisfies TextGeneration.CatchupSummaryGenerationResult; }); + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + const generateWorkSummary: TextGeneration.TextGeneration["Service"]["generateWorkSummary"] = + Effect.fn("CursorTextGeneration.generateWorkSummary")(function* (input) { + const { prompt, outputSchema } = buildWorkSummaryPrompt({ + context: input.context, + promptInstructions: input.promptInstructions, + }); + + const generated = yield* runCursorJson({ + operation: "generateWorkSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + summary: sanitizeWorkSummary(generated.summary), + stage: sanitizeWorkSummaryStage(generated.stage), + remaining: sanitizeWorkSummaryRemaining(generated.remaining), + percent: sanitizeWorkSummaryPercent(generated.percent), + } satisfies TextGeneration.WorkSummaryGenerationResult; + }); + // T3-CUSTOM(expbkt3): END + return { generateCommitMessage, generatePrContent, @@ -316,5 +353,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu generateThreadTitle, updateRollingSummary, generateCatchupSummary, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + generateWorkSummary, + // T3-CUSTOM(expbkt3): END } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1c239b21d71..a99c4fb7102 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -17,6 +17,9 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildCatchupSummaryPrompt, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + buildWorkSummaryPrompt, + // T3-CUSTOM(expbkt3): END buildRollingSummaryPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; @@ -24,6 +27,12 @@ import { sanitizeCommitSubject, sanitizePrTitle, sanitizeCatchupSummary, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + sanitizeWorkSummary, + sanitizeWorkSummaryPercent, + sanitizeWorkSummaryRemaining, + sanitizeWorkSummaryStage, + // T3-CUSTOM(expbkt3): END sanitizeRollingSummary, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; @@ -58,7 +67,10 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi | "generateBranchName" | "generateThreadTitle" | "updateRollingSummary" - | "generateCatchupSummary"; + | "generateCatchupSummary" + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + | "generateWorkSummary"; + // T3-CUSTOM(expbkt3): END cwd: string; prompt: string; outputSchemaJson: S; @@ -301,6 +313,31 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi } satisfies TextGeneration.CatchupSummaryGenerationResult; }); + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + const generateWorkSummary: TextGeneration.TextGeneration["Service"]["generateWorkSummary"] = + Effect.fn("GrokTextGeneration.generateWorkSummary")(function* (input) { + const { prompt, outputSchema } = buildWorkSummaryPrompt({ + context: input.context, + promptInstructions: input.promptInstructions, + }); + + const generated = yield* runGrokJson({ + operation: "generateWorkSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + summary: sanitizeWorkSummary(generated.summary), + stage: sanitizeWorkSummaryStage(generated.stage), + remaining: sanitizeWorkSummaryRemaining(generated.remaining), + percent: sanitizeWorkSummaryPercent(generated.percent), + } satisfies TextGeneration.WorkSummaryGenerationResult; + }); + // T3-CUSTOM(expbkt3): END + return { generateCommitMessage, generatePrContent, @@ -308,5 +345,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi generateThreadTitle, updateRollingSummary, generateCatchupSummary, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + generateWorkSummary, + // T3-CUSTOM(expbkt3): END } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index dc9af312704..f9dc8d282ed 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -23,6 +23,9 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildCatchupSummaryPrompt, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + buildWorkSummaryPrompt, + // T3-CUSTOM(expbkt3): END buildRollingSummaryPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; @@ -31,6 +34,12 @@ import { sanitizeCommitSubject, sanitizePrTitle, sanitizeCatchupSummary, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + sanitizeWorkSummary, + sanitizeWorkSummaryPercent, + sanitizeWorkSummaryRemaining, + sanitizeWorkSummaryStage, + // T3-CUSTOM(expbkt3): END sanitizeRollingSummary, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; @@ -45,6 +54,9 @@ const OpenCodeTextGenerationOperation = Schema.Literals([ "generateThreadTitle", "updateRollingSummary", "generateCatchupSummary", + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + "generateWorkSummary", + // T3-CUSTOM(expbkt3): END ]); type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; @@ -261,7 +273,10 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | "generateBranchName" | "generateThreadTitle" | "updateRollingSummary" - | "generateCatchupSummary"; + | "generateCatchupSummary" + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + | "generateWorkSummary"; + // T3-CUSTOM(expbkt3): END }) => sharedServerMutex.withPermit( Effect.gen(function* () { @@ -667,6 +682,31 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" } satisfies TextGeneration.CatchupSummaryGenerationResult; }); + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + const generateWorkSummary: TextGeneration.TextGeneration["Service"]["generateWorkSummary"] = + Effect.fn("OpenCodeTextGeneration.generateWorkSummary")(function* (input) { + const { prompt, outputSchema } = buildWorkSummaryPrompt({ + context: input.context, + promptInstructions: input.promptInstructions, + }); + + const generated = yield* runOpenCodeJson({ + operation: "generateWorkSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + summary: sanitizeWorkSummary(generated.summary), + stage: sanitizeWorkSummaryStage(generated.stage), + remaining: sanitizeWorkSummaryRemaining(generated.remaining), + percent: sanitizeWorkSummaryPercent(generated.percent), + } satisfies TextGeneration.WorkSummaryGenerationResult; + }); + // T3-CUSTOM(expbkt3): END + return { generateCommitMessage, generatePrContent, @@ -674,5 +714,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" generateThreadTitle, updateRollingSummary, generateCatchupSummary, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + generateWorkSummary, + // T3-CUSTOM(expbkt3): END } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index f808c9ac67c..a963f6cf8da 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -25,6 +25,7 @@ const makeStubTextGeneration = ( Effect.die("updateRollingSummary stub not configured for this test"), generateCatchupSummary: () => Effect.die("generateCatchupSummary stub not configured for this test"), + generateWorkSummary: () => Effect.die("generateWorkSummary stub not configured for this test"), ...overrides, }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index f304587c189..e600e2cceee 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -106,6 +106,30 @@ export interface CatchupSummaryGenerationResult { summary: string; } +// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. +// +// A peer of the catch-up summary rather than an extension of it: separate +// settings, separate model, separate prompt, and a structured result the table +// sorts by. The reactor renders `context` itself so every provider receives an +// identical, already budget-capped payload. +export interface WorkSummaryGenerationInput { + cwd: string; + /** Rendered session context, already capped to the configured char budget. */ + context: string; + /** Optional user-supplied prompt instructions from settings. */ + promptInstructions?: string | undefined; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface WorkSummaryGenerationResult { + summary: string; + stage: "planning" | "implementing" | "blocked" | "awaiting-review" | "done"; + remaining: string; + percent: number; +} +// T3-CUSTOM(expbkt3): END + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, @@ -119,6 +143,9 @@ export interface TextGenerationService { generateCatchupSummary( input: CatchupSummaryGenerationInput, ): Promise<CatchupSummaryGenerationResult>; + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + generateWorkSummary(input: WorkSummaryGenerationInput): Promise<WorkSummaryGenerationResult>; + // T3-CUSTOM(expbkt3): END } /** @@ -166,6 +193,15 @@ export class TextGeneration extends Context.Service< readonly generateCatchupSummary: ( input: CatchupSummaryGenerationInput, ) => Effect.Effect<CatchupSummaryGenerationResult, TextGenerationError>; + + /** + * T3-CUSTOM(expbkt3): BEGIN — Write the bulk session manager's work summary + * and assigned progress for one session. + */ + readonly generateWorkSummary: ( + input: WorkSummaryGenerationInput, + ) => Effect.Effect<WorkSummaryGenerationResult, TextGenerationError>; + // T3-CUSTOM(expbkt3): END } >()("t3/textGeneration/TextGeneration") {} @@ -178,7 +214,10 @@ export type TextGenerationOp = | "generateBranchName" | "generateThreadTitle" | "updateRollingSummary" - | "generateCatchupSummary"; + | "generateCatchupSummary" + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + | "generateWorkSummary"; +// T3-CUSTOM(expbkt3): END const resolveInstance = ( registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], @@ -226,6 +265,12 @@ export const makeTextGenerationFromRegistry = ( resolveInstance(registry, "generateCatchupSummary", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateCatchupSummary(input)), ), + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + generateWorkSummary: (input) => + resolveInstance(registry, "generateWorkSummary", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateWorkSummary(input)), + ), + // T3-CUSTOM(expbkt3): END }); export const make = Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 25540f13808..2666f18a4d5 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import { buildBranchNamePrompt, buildCatchupSummaryPrompt, + buildWorkSummaryPrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildRollingSummaryPrompt, @@ -13,7 +15,14 @@ import { sanitizeCatchupSummary, sanitizeRollingSummary, sanitizeThreadTitle, + sanitizeWorkSummary, + sanitizeWorkSummaryPercent, + sanitizeWorkSummaryRemaining, + sanitizeWorkSummaryStage, MAX_ROLLING_SUMMARY_CHARS, + MAX_WORK_SUMMARY_CHARS, + MAX_WORK_SUMMARY_REMAINING_CHARS, + WORK_SUMMARY_STAGES, } from "./TextGenerationUtils.ts"; import { TextGenerationError } from "@t3tools/contracts"; @@ -408,3 +417,84 @@ describe("sanitizeRollingSummary", () => { expect(sanitizeRollingSummary(" short summary ")).toBe("short summary"); }); }); + +// T3-CUSTOM(expbkt3): bulk session manager work summary prompt + sanitizers. +describe("buildWorkSummaryPrompt", () => { + it("asks for the four structured fields the bulk table renders", () => { + const result = buildWorkSummaryPrompt({ + context: "Session title: Ship the manager\n\nTranscript:\nuser: build it", + }); + + expect(Object.keys(result.outputSchema.fields)).toEqual([ + "summary", + "stage", + "remaining", + "percent", + ]); + expect(result.prompt).toContain( + "Return a JSON object with keys: summary, stage, remaining, percent.", + ); + expect(result.prompt).toContain("2 to 4 sentences"); + expect(result.prompt).toContain(WORK_SUMMARY_STAGES.join(", ")); + expect(result.prompt).toContain(String(MAX_WORK_SUMMARY_REMAINING_CHARS)); + expect(result.prompt).toContain("Ship the manager"); + }); + + it("accepts only the five sortable stage buckets", () => { + const isOutput = Schema.is(buildWorkSummaryPrompt({ context: "ctx" }).outputSchema); + const base = { summary: "did work", remaining: "land it", percent: 50 }; + + for (const stage of WORK_SUMMARY_STAGES) { + expect(isOutput({ ...base, stage })).toBe(true); + } + // A sixth bucket would be unsortable in the table and unmappable in the + // contract, so the provider must reject it rather than pass it through. + expect(isOutput({ ...base, stage: "shipping" })).toBe(false); + expect(isOutput({ ...base, stage: "done", percent: "50" })).toBe(false); + }); + + it("appends user-supplied instructions only when configured", () => { + const withInstructions = buildWorkSummaryPrompt({ + context: "ctx", + promptInstructions: "Mention the Linear ticket.", + }); + expect(withInstructions.prompt).toContain("Additional instructions:"); + expect(withInstructions.prompt).toContain("Mention the Linear ticket."); + + const without = buildWorkSummaryPrompt({ context: "ctx" }); + expect(without.prompt).not.toContain("Additional instructions:"); + }); +}); + +describe("work summary sanitizers", () => { + it("collapses the summary to one plain-text paragraph", () => { + expect(sanitizeWorkSummary("- did a thing\n\n* then another")).toBe("did a thing then another"); + }); + + it("truncates a summary past the table's bound", () => { + const result = sanitizeWorkSummary("z".repeat(MAX_WORK_SUMMARY_CHARS + 200)); + + expect(result.length).toBeLessThanOrEqual(MAX_WORK_SUMMARY_CHARS + 3); + expect(result.endsWith("...")).toBe(true); + }); + + it("keeps remaining to a single capped line", () => { + expect(sanitizeWorkSummaryRemaining("- land the PR\nthen celebrate")).toBe("land the PR"); + expect(sanitizeWorkSummaryRemaining("q".repeat(200)).length).toBe( + MAX_WORK_SUMMARY_REMAINING_CHARS, + ); + }); + + it("falls back to implementing for an unknown stage", () => { + expect(sanitizeWorkSummaryStage("Awaiting Review")).toBe("awaiting-review"); + expect(sanitizeWorkSummaryStage("shipping")).toBe("implementing"); + expect(sanitizeWorkSummaryStage("")).toBe("implementing"); + }); + + it("clamps percent into 0..100", () => { + expect(sanitizeWorkSummaryPercent(-40)).toBe(0); + expect(sanitizeWorkSummaryPercent(140)).toBe(100); + expect(sanitizeWorkSummaryPercent(61.6)).toBe(62); + expect(sanitizeWorkSummaryPercent(Number.NaN)).toBe(0); + }); +}); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 338945693fc..5ed7566ad92 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -14,6 +14,11 @@ import { limitSectionTail, MAX_CATCHUP_SUMMARY_LINES, MAX_ROLLING_SUMMARY_CHARS, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + MAX_WORK_SUMMARY_CHARS, + MAX_WORK_SUMMARY_REMAINING_CHARS, + WORK_SUMMARY_STAGES, + // T3-CUSTOM(expbkt3): END } from "./TextGenerationUtils.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; @@ -416,3 +421,61 @@ export function buildCatchupSummaryPrompt(input: CatchupSummaryPromptInput) { return { prompt, outputSchema }; } + +// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary prompt. + +export interface WorkSummaryPromptInput { + /** Rendered session context: title, goal, and a budget-capped transcript. */ + context: string; + /** Optional user-supplied instructions appended to the prompt. */ + promptInstructions?: string | undefined; +} + +/** + * Writes the two AI columns of the bulk session manager: a work summary and an + * assigned progress (stage, what remains, percent). + * + * The reader here is not returning to one session — they are scanning thirty + * rows deciding which to open. So the summary answers "what did this actually + * do and where does it stand", not "what happened in the last turn", and the + * progress fields are shaped to be sortable rather than descriptive. + */ +export function buildWorkSummaryPrompt(input: WorkSummaryPromptInput) { + const prompt = [ + "You summarize one coding session for an operator who is scanning a table of", + "about thirty sessions at once, deciding which ones need attention.", + "Return a JSON object with keys: summary, stage, remaining, percent.", + "Rules:", + "- summary: 2 to 4 sentences describing what this session actually did so far", + " and where it now stands; write it to be read at a glance next to 29 others", + `- summary must be plain prose under ${MAX_WORK_SUMMARY_CHARS} characters: no markdown,`, + " no bullets, no headings, no labels, no preamble", + "- summary must be concrete: name the feature, files, commands, or errors involved", + `- stage: exactly one of ${WORK_SUMMARY_STAGES.join(", ")}`, + "- stage is judged from what remains, not from how much text the session has:", + ' "planning" before implementation starts, "implementing" while work is in', + ' progress, "blocked" when it cannot proceed without a decision, an answer, or', + ' a fix, "awaiting-review" when the work is done but unmerged or unverified,', + ' and "done" only when nothing remains', + `- remaining: ONE line of at most ${MAX_WORK_SUMMARY_REMAINING_CHARS} characters saying what is left`, + '- remaining must be the empty string "" when stage is "done"', + "- percent: an integer from 0 to 100, the rough completion of the session's", + " stated goal; do not report 100 unless stage is done", + "- never invent progress the transcript does not support; an idle session that", + " never started work is 0 percent and planning", + ...policyInstruction(input.promptInstructions), + "", + "Session context:", + input.context, + ].join("\n"); + + const outputSchema = Schema.Struct({ + summary: Schema.String, + stage: Schema.Literals([...WORK_SUMMARY_STAGES]), + remaining: Schema.String, + percent: Schema.Int, + }); + + return { prompt, outputSchema }; +} +// T3-CUSTOM(expbkt3): END diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index 28c8fb6748f..998aa5b8498 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -54,6 +54,85 @@ export function sanitizeCatchupSummary(raw: string): string { return lines.slice(0, MAX_CATCHUP_SUMMARY_LINES).join("\n"); } +// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary sanitizers. +// +// The bulk table renders thirty of these at once, so the caps are tighter than +// the catch-up card's and enforced here rather than trusted from the prompt: a +// chatty model must not be able to stretch a table row. +export const WORK_SUMMARY_STAGES = [ + "planning", + "implementing", + "blocked", + "awaiting-review", + "done", +] as const; +export type WorkSummaryStage = (typeof WORK_SUMMARY_STAGES)[number]; + +/** Roughly four sentences of prose. */ +export const MAX_WORK_SUMMARY_CHARS = 700; +/** One scannable line in a table cell. */ +export const MAX_WORK_SUMMARY_REMAINING_CHARS = 90; + +const WORK_SUMMARY_STAGE_SET: ReadonlySet<string> = new Set(WORK_SUMMARY_STAGES); + +/** Collapse to a single plain-text paragraph, strip list/heading markers, cap. */ +export function sanitizeWorkSummary(raw: string): string { + const normalized = raw + .trim() + .split(/\r?\n/g) + .map((line) => + line + .trim() + .replace(/^([-*+]|\d+[.)]|#{1,6})\s+/, "") + .trim(), + ) + .filter((line) => line.length > 0) + .join(" ") + .replace(/\s+/g, " "); + + return normalized.length <= MAX_WORK_SUMMARY_CHARS + ? normalized + : `${normalized.slice(0, MAX_WORK_SUMMARY_CHARS).trimEnd()}...`; +} + +/** First line only, no markers, capped to one table-cell line. */ +export function sanitizeWorkSummaryRemaining(raw: string): string { + const firstLine = + raw + .trim() + .split(/\r?\n/g) + .map((line) => + line + .trim() + .replace(/^([-*+]|\d+[.)]|#{1,6})\s+/, "") + .trim(), + ) + .find((line) => line.length > 0) ?? ""; + const normalized = firstLine.replace(/\s+/g, " "); + return normalized.length <= MAX_WORK_SUMMARY_REMAINING_CHARS + ? normalized + : `${normalized.slice(0, MAX_WORK_SUMMARY_REMAINING_CHARS - 3).trimEnd()}...`; +} + +/** + * Unknown stage falls back to "implementing" — the neutral middle bucket. A + * wrong-but-plausible stage is a smaller lie in a sortable column than a + * missing row or an invented sixth value. + */ +export function sanitizeWorkSummaryStage(raw: string): WorkSummaryStage { + const normalized = raw.trim().toLowerCase().replace(/\s+/g, "-"); + return WORK_SUMMARY_STAGE_SET.has(normalized) ? (normalized as WorkSummaryStage) : "implementing"; +} + +/** Clamp to 0..100 and round; non-finite input reports zero progress. */ +export function sanitizeWorkSummaryPercent(raw: number): number { + if (!Number.isFinite(raw)) { + return 0; + } + return Math.min(100, Math.max(0, Math.round(raw))); +} +// T3-CUSTOM(expbkt3): END + /** Keep the stored rolling summary bounded regardless of model behavior. */ export function sanitizeRollingSummary(raw: string): string { const normalized = raw.trim(); diff --git a/apps/server/src/thread-bootstrap/Coordinator.test.ts b/apps/server/src/thread-bootstrap/Coordinator.test.ts index 7121ef46df2..adfec3162ef 100644 --- a/apps/server/src/thread-bootstrap/Coordinator.test.ts +++ b/apps/server/src/thread-bootstrap/Coordinator.test.ts @@ -277,6 +277,98 @@ describe("ThreadBootstrapCoordinator", () => { }), ); + // T3-CUSTOM(expbkt3): session lineage on the ATOMIC path. A prompt-bearing + // t3_create_session commits thread, message and intent in one turn.start + // rather than a separate thread.create, so lineage has to ride along in + // bootstrap.createThread. Missing it here is what left every agent-spawned + // session at the top level while the no-prompt path already worked. + it.effect("carries session lineage through an atomically accepted first turn", () => + Effect.gen(function* () { + const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]); + const turnStarted = yield* Deferred.make<void>(); + const bootstrapCompleted = yield* Deferred.make<void>(); + const dependencies = testLayer({ + commands, + turnStarted, + bootstrapCompleted, + setup: () => Effect.die("setup must be owned by the durable execution coordinator"), + request: { ...resolvedRequest(), parentThreadId: ThreadId.make("thread-parent") }, + }); + + yield* Effect.gen(function* () { + const coordinator = yield* ThreadBootstrapCoordinator; + yield* coordinator.request(requestCommand(), { + createThread: true, + turnStart: { + type: "thread.turn.start", + commandId: CommandId.make("original-turn-command"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Build it", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + }); + yield* Deferred.await(turnStarted); + + const emitted = (yield* Ref.get(commands))[0]; + expect( + emitted?.type === "thread.turn.start" + ? emitted.bootstrap?.createThread?.parentThreadId + : undefined, + ).toBe("thread-parent"); + }).pipe(Effect.provide(dependencies)); + }), + ); + + it.effect("creates a root session on the atomic path when there is no parent", () => + Effect.gen(function* () { + const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]); + const turnStarted = yield* Deferred.make<void>(); + const bootstrapCompleted = yield* Deferred.make<void>(); + const dependencies = testLayer({ + commands, + turnStarted, + bootstrapCompleted, + setup: () => Effect.die("setup must be owned by the durable execution coordinator"), + }); + + yield* Effect.gen(function* () { + const coordinator = yield* ThreadBootstrapCoordinator; + yield* coordinator.request(requestCommand(), { + createThread: true, + turnStart: { + type: "thread.turn.start", + commandId: CommandId.make("original-turn-command"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Build it", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + }); + yield* Deferred.await(turnStarted); + + const emitted = (yield* Ref.get(commands))[0]; + expect( + emitted?.type === "thread.turn.start" + ? emitted.bootstrap?.createThread?.parentThreadId + : undefined, + ).toBe(null); + }).pipe(Effect.provide(dependencies)); + }), + ); + it.effect("returns after queueing and gates the first turn on setup success", () => Effect.gen(function* () { const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]); @@ -323,6 +415,65 @@ describe("ThreadBootstrapCoordinator", () => { }), ); + // T3-CUSTOM(expbkt3): session lineage. The resolver puts parentThreadId on the + // resolved request, but only this dispatch carries it into the created + // thread — when it did not, every agent-spawned session was stamped NULL and + // rendered as an unrelated top-level row. + it.effect("carries session lineage from the bootstrap request into thread.create", () => + Effect.gen(function* () { + const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]); + const turnStarted = yield* Deferred.make<void>(); + const bootstrapCompleted = yield* Deferred.make<void>(); + const dependencies = testLayer({ + commands, + turnStarted, + bootstrapCompleted, + setup: () => Effect.succeed({ status: "no-script" as const }), + request: { ...resolvedRequest(), parentThreadId: ThreadId.make("thread-parent") }, + }); + + yield* Effect.gen(function* () { + const coordinator = yield* ThreadBootstrapCoordinator; + yield* coordinator.request(requestCommand()); + yield* Deferred.await(turnStarted); + yield* Deferred.await(bootstrapCompleted); + + const created = (yield* Ref.get(commands)).find( + (command) => command.type === "thread.create", + ); + expect(created?.type === "thread.create" ? created.parentThreadId : undefined).toBe( + "thread-parent", + ); + }).pipe(Effect.provide(dependencies)); + }), + ); + + it.effect("creates a root session when the bootstrap request has no parent", () => + Effect.gen(function* () { + const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]); + const turnStarted = yield* Deferred.make<void>(); + const bootstrapCompleted = yield* Deferred.make<void>(); + const dependencies = testLayer({ + commands, + turnStarted, + bootstrapCompleted, + setup: () => Effect.succeed({ status: "no-script" as const }), + }); + + yield* Effect.gen(function* () { + const coordinator = yield* ThreadBootstrapCoordinator; + yield* coordinator.request(requestCommand()); + yield* Deferred.await(turnStarted); + yield* Deferred.await(bootstrapCompleted); + + const created = (yield* Ref.get(commands)).find( + (command) => command.type === "thread.create", + ); + expect(created?.type === "thread.create" ? created.parentThreadId : undefined).toBe(null); + }).pipe(Effect.provide(dependencies)); + }), + ); + it.effect("adopts an existing empty thread without dispatching thread.create", () => Effect.gen(function* () { const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]); diff --git a/apps/server/src/thread-bootstrap/Coordinator.ts b/apps/server/src/thread-bootstrap/Coordinator.ts index f9fe1ffc8c7..f1c80cf18f0 100644 --- a/apps/server/src/thread-bootstrap/Coordinator.ts +++ b/apps/server/src/thread-bootstrap/Coordinator.ts @@ -631,6 +631,11 @@ const make = Effect.gen(function* () { ...(resolved.ownerUserId ? { ownerUserId: resolved.ownerUserId } : {}), createdAt: resolved.createdAt, priority: resolved.priority, + // T3-CUSTOM(expbkt3): session lineage. This is the branch + // a prompt-bearing t3_create_session takes — thread, + // message and intent commit atomically here rather than + // through the separate thread.create below. + parentThreadId: resolved.parentThreadId ?? null, }, } : {}), @@ -667,6 +672,9 @@ const make = Effect.gen(function* () { ...(resolved.ownerUserId ? { ownerUserId: resolved.ownerUserId } : {}), createdAt: resolved.createdAt, priority: resolved.priority, + // T3-CUSTOM(expbkt3): session lineage survives bootstrap into the + // created thread. + parentThreadId: resolved.parentThreadId ?? null, }); } const recorded = yield* dispatch({ diff --git a/apps/server/src/thread-bootstrap/DefaultsResolver.test.ts b/apps/server/src/thread-bootstrap/DefaultsResolver.test.ts index 157ed7c0060..f4892e5a52f 100644 --- a/apps/server/src/thread-bootstrap/DefaultsResolver.test.ts +++ b/apps/server/src/thread-bootstrap/DefaultsResolver.test.ts @@ -197,3 +197,28 @@ describe("resolveExactBranch", () => { ).toBeNull(); }); }); + +// T3-CUSTOM(expbkt3): session lineage. Resolution is one link in the chain that +// carries a spawning session into the created thread; the Coordinator dispatch +// is the other. +describe("mergeThreadCreationDefaults session lineage", () => { + it("carries an explicit parent through resolution", () => { + const resolved = mergeThreadCreationDefaults({ + command: command({ parentThreadId: ThreadId.make("thread-parent") }), + project: project(), + settings: DEFAULT_SERVER_SETTINGS, + }); + + expect(resolved.parentThreadId).toBe("thread-parent"); + }); + + it("resolves an absent parent to null, so a human-started session is a root", () => { + const resolved = mergeThreadCreationDefaults({ + command: command(), + project: project(), + settings: DEFAULT_SERVER_SETTINGS, + }); + + expect(resolved.parentThreadId).toBe(null); + }); +}); diff --git a/apps/server/src/thread-bootstrap/DefaultsResolver.ts b/apps/server/src/thread-bootstrap/DefaultsResolver.ts index 8a84e8f7e83..d6cac44943a 100644 --- a/apps/server/src/thread-bootstrap/DefaultsResolver.ts +++ b/apps/server/src/thread-bootstrap/DefaultsResolver.ts @@ -142,6 +142,8 @@ export function mergeThreadCreationDefaults(input: { ...(command.initialTurn ? { initialTurn: command.initialTurn } : {}), sourceControlProfileId: command.sourceControlProfileId ?? null, priority: command.priority ?? null, + // T3-CUSTOM(expbkt3): session lineage survives bootstrap resolution. + parentThreadId: command.parentThreadId ?? null, ...(command.ownerUserId ? { ownerUserId: command.ownerUserId } : {}), createdAt: command.createdAt, }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0b499ee1a68..88aefed68ef 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -109,6 +109,8 @@ import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as UserMcpProfileStore from "./mcp/UserMcpProfileStore.ts"; +// T3-CUSTOM(expbkt3): native plan review service. +import { PlanReviewService } from "./planreview/PlanReviewService.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -119,6 +121,8 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +// T3-CUSTOM(expbkt3): archived-session worktree reclaim +import * as SessionArchiveService from "./sessionArchive/SessionArchiveService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -393,6 +397,8 @@ const makeWsRpcLayer = ( const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const gitVcsDriver = yield* GitVcsDriver.GitVcsDriver; + // T3-CUSTOM(expbkt3): archived-session worktree reclaim. + const sessionArchive = yield* SessionArchiveService.SessionArchiveService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const terminalManager = yield* TerminalManager.TerminalManager; const previewManager = yield* PreviewManager.PreviewManager; @@ -428,6 +434,20 @@ const makeWsRpcLayer = ( ); const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const environmentUsers = yield* EnvironmentUserService.EnvironmentUserService; + // T3-CUSTOM(expbkt3): BEGIN native plan review connection state. + const planReview = yield* PlanReviewService; + // Resolved once per connection: it only labels this actor's own comments. + const actorLabel = + actorUserId === null + ? null + : yield* clerkDirectory.listOrgMembers().pipe( + Effect.map((members) => { + const member = members.find((user) => user.id === actorUserId); + return member?.name ?? member?.email ?? null; + }), + Effect.orElseSucceed(() => null), + ); + // T3-CUSTOM(expbkt3): END native plan review connection state. const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map( @@ -1120,11 +1140,14 @@ const makeWsRpcLayer = ( httpClient, sourceControlProfiles, environmentUsers, + planReview, + actorLabel, systemResourceMonitor, providerRateLimits, projectionSnapshotQuery, orchestrationEngine, gitVcsDriver, + sessionArchive, executionSupervisor, sourceControlActionLock, enrichOrchestrationEvents, diff --git a/apps/server/src/wsForkHandlers.ts b/apps/server/src/wsForkHandlers.ts index b658df4ebe7..e96e49f66bb 100644 --- a/apps/server/src/wsForkHandlers.ts +++ b/apps/server/src/wsForkHandlers.ts @@ -18,6 +18,7 @@ import { WsRpcGroup, EnvironmentAuthorizationError, OrchestrationGetSnapshotError, + PlanReviewError, SourceControlProfileError, type AuthSessionId, type OrchestrationEvent, @@ -33,6 +34,7 @@ import type * as EnvironmentUserService from "./auth/EnvironmentUserService.ts"; import type * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import type * as UserMcpProfileStore from "./mcp/UserMcpProfileStore.ts"; import type * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import type * as PlanReviewService from "./planreview/PlanReviewService.ts"; import type * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import type * as SystemResourceMonitor from "./observability/SystemResourceMonitor.ts"; import type { ProviderRateLimitsShape } from "./provider/ProviderRateLimits.ts"; @@ -41,6 +43,7 @@ import { githubSshRemoteToHttps } from "./sourceControl/GitHubRemoteUrl.ts"; import type * as SourceControlProfileService from "./sourceControl/SourceControlProfileService.ts"; import type { ThreadExecutionSupervisorShape } from "./execution/ThreadExecutionSupervisor.ts"; import { resolveLinearIssueStatuses } from "./linear/LinearIssueResolver.ts"; +import type { SessionArchiveServiceShape } from "./sessionArchive/SessionArchiveService.ts"; import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"; type WsRpcs = RpcGroup.Rpcs<typeof WsRpcGroup>; @@ -58,11 +61,17 @@ export interface ForkWsHandlerDeps { readonly httpClient: HttpClient.HttpClient; readonly sourceControlProfiles: SourceControlProfileService.SourceControlProfileService["Service"]; readonly environmentUsers: EnvironmentUserService.EnvironmentUserService["Service"]; + // T3-CUSTOM(expbkt3): native plan review. + readonly planReview: PlanReviewService.PlanReviewService["Service"]; + /** Display name for the acting user, stamped onto their review comments. */ + readonly actorLabel: string | null; readonly systemResourceMonitor: SystemResourceMonitor.SystemResourceMonitor["Service"]; readonly providerRateLimits: ProviderRateLimitsShape; readonly projectionSnapshotQuery: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; readonly orchestrationEngine: OrchestrationEngine.OrchestrationEngineService["Service"]; readonly gitVcsDriver: GitVcsDriver.GitVcsDriver["Service"]; + // T3-CUSTOM(expbkt3): archived-session worktree reclaim. + readonly sessionArchive: SessionArchiveServiceShape; readonly executionSupervisor: ThreadExecutionSupervisorShape; /** Serialises source-control actions per thread. */ readonly sourceControlActionLock: { @@ -103,11 +112,14 @@ export const makeForkWsHandlers = ({ httpClient, sourceControlProfiles, environmentUsers, + planReview, + actorLabel, systemResourceMonitor, providerRateLimits, projectionSnapshotQuery, orchestrationEngine, gitVcsDriver, + sessionArchive, executionSupervisor, sourceControlActionLock, enrichOrchestrationEvents, @@ -115,8 +127,47 @@ export const makeForkWsHandlers = ({ observeRpcStream, requireThreadAccess, visibleAggregateIdsForActor, -}: ForkWsHandlerDeps) => - ({ +}: ForkWsHandlerDeps) => { + // T3-CUSTOM(expbkt3): BEGIN native plan review helpers. + const planReviewAccessError = (cause: OrchestrationGetSnapshotError) => + new PlanReviewError({ operation: "access", reason: "not-found", detail: cause.message }); + + const toPlanReviewError = + (operation: string) => (cause: { readonly _tag: string; readonly message: string }) => + cause._tag === "PlanReviewError" + ? (cause as unknown as PlanReviewError) + : new PlanReviewError({ + operation, + reason: + cause._tag === "PlanReviewNotFoundError" + ? "not-found" + : cause._tag === "PlanDraftConflictError" + ? "draft-conflict" + : cause._tag === "PlanVersionConflictError" + ? "version-conflict" + : "invalid", + detail: cause.message, + }); + + /** + * Reviews are reachable only through the thread that owns them, so every + * entry point resolves the document first and then applies thread access. + * A denial reads as "not found" so the check cannot leak existence. + */ + const guardDocument = (documentId: string) => + planReview.getReview(documentId).pipe( + Effect.mapError(toPlanReviewError("access")), + Effect.tap((snapshot) => + requireThreadAccess(snapshot.document.threadId).pipe( + Effect.mapError(planReviewAccessError), + ), + ), + ); + + const guardedReview = (documentId: string) => guardDocument(documentId); + // T3-CUSTOM(expbkt3): END native plan review helpers. + + return { [WS_METHODS.personalMcpGetProfile]: (_input) => observeRpcEffect( WS_METHODS.personalMcpGetProfile, @@ -152,6 +203,22 @@ export const makeForkWsHandlers = ({ }), { "rpc.aggregate": "linear-issues" }, ), + // T3-CUSTOM(expbkt3): BEGIN — archived-session worktree reclaim. + [WS_METHODS.sessionArchiveScan]: (_input) => + observeRpcEffect(WS_METHODS.sessionArchiveScan, sessionArchive.scan(), { + "rpc.aggregate": "session-archive", + }), + [WS_METHODS.sessionArchiveExport]: (input) => + observeRpcEffect( + WS_METHODS.sessionArchiveExport, + sessionArchive.exportHistory(input.threadIds), + { "rpc.aggregate": "session-archive" }, + ), + [WS_METHODS.sessionArchiveReclaim]: (input) => + observeRpcEffect(WS_METHODS.sessionArchiveReclaim, sessionArchive.reclaim(input), { + "rpc.aggregate": "session-archive", + }), + // T3-CUSTOM(expbkt3): END [WS_METHODS.sourceControlProfilesList]: (_input) => observeRpcEffect(WS_METHODS.sourceControlProfilesList, sourceControlProfiles.list, { "rpc.aggregate": "source-control-profile", @@ -332,4 +399,127 @@ export const makeForkWsHandlers = ({ observeRpcStream(WS_METHODS.subscribeProviderRateLimits, providerRateLimits.stream, { "rpc.aggregate": "server", }), - }) satisfies ForkWsHandlers; + // T3-CUSTOM(expbkt3): BEGIN native plan review. + [WS_METHODS.planReviewGet]: (input) => + observeRpcEffect(WS_METHODS.planReviewGet, guardedReview(input.documentId), { + "rpc.aggregate": "plan-review", + }), + [WS_METHODS.planReviewList]: (input) => + observeRpcEffect( + WS_METHODS.planReviewList, + requireThreadAccess(input.threadId).pipe( + Effect.mapError(planReviewAccessError), + Effect.andThen(planReview.listForThread(input.threadId)), + Effect.map((documents) => ({ documents })), + Effect.mapError(toPlanReviewError("list")), + ), + { "rpc.aggregate": "plan-review" }, + ), + [WS_METHODS.planReviewSaveDraft]: (input) => + observeRpcEffect( + WS_METHODS.planReviewSaveDraft, + guardDocument(input.documentId).pipe( + Effect.andThen( + planReview.saveDraft({ + documentId: input.documentId, + contentValueJson: input.contentValueJson, + expectedRevisionToken: input.expectedRevisionToken, + actorUserId, + }), + ), + Effect.mapError(toPlanReviewError("saveDraft")), + ), + { "rpc.aggregate": "plan-review" }, + ), + [WS_METHODS.planReviewCutVersion]: (input) => + observeRpcEffect( + WS_METHODS.planReviewCutVersion, + guardDocument(input.documentId).pipe( + Effect.andThen( + planReview.cutVersion({ + documentId: input.documentId, + contentMarkdown: input.contentMarkdown, + contentValueJson: input.contentValueJson, + summary: input.summary, + actorUserId, + }), + ), + Effect.andThen(planReview.getReview(input.documentId)), + Effect.mapError(toPlanReviewError("cutVersion")), + ), + { "rpc.aggregate": "plan-review" }, + ), + [WS_METHODS.planReviewUpsertDiscussion]: (input) => + observeRpcEffect( + WS_METHODS.planReviewUpsertDiscussion, + guardDocument(input.documentId).pipe( + Effect.andThen( + planReview.upsertDiscussion({ + documentId: input.documentId, + discussionId: input.discussionId, + quotedText: input.quotedText, + bodyMarkdown: input.bodyMarkdown, + actorUserId, + }), + ), + Effect.andThen(planReview.getReview(input.documentId)), + Effect.mapError(toPlanReviewError("upsertDiscussion")), + ), + { "rpc.aggregate": "plan-review" }, + ), + [WS_METHODS.planReviewResolveDiscussion]: (input) => + observeRpcEffect( + WS_METHODS.planReviewResolveDiscussion, + guardDocument(input.documentId).pipe( + Effect.andThen( + planReview.resolveDiscussion({ + documentId: input.documentId, + discussionId: input.discussionId, + isResolved: input.isResolved, + actorUserId, + }), + ), + Effect.andThen(planReview.getReview(input.documentId)), + Effect.mapError(toPlanReviewError("resolveDiscussion")), + ), + { "rpc.aggregate": "plan-review" }, + ), + [WS_METHODS.planReviewVersionDiff]: (input) => + observeRpcEffect( + WS_METHODS.planReviewVersionDiff, + guardDocument(input.documentId).pipe( + Effect.andThen(planReview.getVersionDiff(input)), + Effect.mapError(toPlanReviewError("versionDiff")), + ), + { "rpc.aggregate": "plan-review" }, + ), + [WS_METHODS.planReviewSubmit]: (input) => + observeRpcEffect( + WS_METHODS.planReviewSubmit, + guardDocument(input.documentId).pipe( + Effect.andThen( + planReview.submit({ + documentId: input.documentId, + decision: input.decision, + globalComment: input.globalComment, + editedMarkdown: input.editedMarkdown, + actorUserId, + actorLabel, + }), + ), + Effect.mapError(toPlanReviewError("submit")), + ), + { "rpc.aggregate": "plan-review" }, + ), + [WS_METHODS.subscribePlanReview]: (input) => + observeRpcStream( + WS_METHODS.subscribePlanReview, + Stream.fromEffect(guardDocument(input.documentId)).pipe( + Stream.flatMap(() => planReview.watch(input.documentId)), + Stream.mapError(toPlanReviewError("watch")), + ), + { "rpc.aggregate": "plan-review" }, + ), + // T3-CUSTOM(expbkt3): END native plan review. + } satisfies ForkWsHandlers; +}; diff --git a/apps/web/package.json b/apps/web/package.json index f0e5a076e84..d0803f51082 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,16 +26,27 @@ "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", + "@platejs/basic-nodes": "53.0.0", + "@platejs/code-block": "53.0.0", + "@platejs/comment": "53.0.0", + "@platejs/link": "53.3.1", + "@platejs/list-classic": "53.0.0", + "@platejs/markdown": "53.3.3", + "@platejs/suggestion": "53.2.3", + "@platejs/table": "53.0.9", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "class-variance-authority": "^0.7.1", + "date-fns": "^4.4.0", "effect": "catalog:", "jose": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "mermaid": "11.16.1", + "platejs": "53.3.3", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 31e13f1faf7..982ed4a3ac3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -147,6 +147,8 @@ import { type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; +// T3-CUSTOM(expbkt3): native plan review surface. +import { PlanReviewPanel, useOpenPlanReviewDocumentId } from "../fork/planReviewSurface"; import { isPreviewSupportedInRuntime, setActivePreviewTab, @@ -177,6 +179,8 @@ import { AlarmClockIcon, CheckCircle2Icon, ChevronDownIcon, + // T3-CUSTOM(expbkt3): icon for the native plan review pill. + ClipboardListIcon, GitBranchIcon, WifiOffIcon, } from "lucide-react"; @@ -439,6 +443,7 @@ const PreviewPanel = lazy(() => ); const DiffPanel = lazy(() => import("./DiffPanel")); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); +// T3-CUSTOM(expbkt3): native plan review surface (lazy: Plate is ~200 kB gzip). const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet<string> = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", @@ -3553,6 +3558,17 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef], ); + const openPlanReviewSurface = useCallback( + (documentId: string) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().openPlanReview(activeThreadRef, documentId); + }, + [activeThreadRef], + ); + const planReviewDocumentId = useOpenPlanReviewDocumentId( + activeThreadRef?.environmentId ?? null, + activeThreadRef?.threadId ?? null, + ); // T3-CUSTOM(expbkt3): END const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; @@ -6621,7 +6637,20 @@ function ChatViewContent(props: ChatViewProps) { initialGitScope={initialDiffPanelGitScope} /> </Suspense> - ) : activeRightPanelSurface?.kind === "agents" ? ( + ) : /* T3-CUSTOM(expbkt3): BEGIN — native plan review panel. */ + activeRightPanelSurface?.kind === "planReview" ? ( + <Suspense fallback={null}> + <PlanReviewPanel + key={activeRightPanelSurface.documentId} + environmentId={activeThreadRef.environmentId} + documentId={activeRightPanelSurface.documentId} + onClose={() => + useRightPanelStore.getState().closeSurface(activeThreadRef, activeRightPanelSurface.id) + } + /> + </Suspense> + ) : /* T3-CUSTOM(expbkt3): END */ + activeRightPanelSurface?.kind === "agents" ? ( <AgentsPanel model={agentPanelModel} environmentId={activeThreadRef?.environmentId ?? null} @@ -6770,6 +6799,8 @@ function ChatViewContent(props: ChatViewProps) { routeThreadKey={routeThreadKey} onOpenTurnDiff={onOpenTurnDiff} onOpenPlannotator={openPlannotatorSurface} + onOpenPlanReview={openPlanReviewSurface} + planReviewDocumentId={planReviewDocumentId} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} @@ -6809,6 +6840,28 @@ function ChatViewContent(props: ChatViewProps) { </button> </div> )} + {/* T3-CUSTOM(expbkt3): BEGIN — floating entry point for a plan awaiting review. */} + {planReviewDocumentId !== null && + !showScrollToBottom && + activeRightPanelSurface?.kind !== "planReview" ? ( + <div + className="pointer-events-none absolute left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5" + style={{ bottom: composerOverlayHeight + 4 }} + > + <button + type="button" + aria-label="Open the plan in preview" + title="Open the plan in preview" + data-plan-review-pill + onClick={() => openPlanReviewSurface(planReviewDocumentId)} + className="chat-composer-glass pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:cursor-pointer hover:border-border hover:text-foreground" + > + <ClipboardListIcon className="size-3.5" /> + Open the plan in preview + </button> + </div> + ) : null} + {/* T3-CUSTOM(expbkt3): END */} </div> {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */} diff --git a/apps/web/src/components/PhaseGroupedSidebar.tsx b/apps/web/src/components/PhaseGroupedSidebar.tsx index 4eb41fda967..194ab2f9e85 100644 --- a/apps/web/src/components/PhaseGroupedSidebar.tsx +++ b/apps/web/src/components/PhaseGroupedSidebar.tsx @@ -21,6 +21,8 @@ import { type EnvironmentId, type LinearIssueStatusSummary, type ScopedThreadRef, + // T3-CUSTOM(expbkt3): session lineage. + type ThreadId, type VcsStatusResult, } from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -29,7 +31,9 @@ import { ArchiveIcon, CheckIcon, ChevronDownIcon, + ChevronRightIcon, ClockIcon, + CornerDownRightIcon, FilterIcon, FolderGit2Icon, FolderPlusIcon, @@ -37,10 +41,14 @@ import { LaptopIcon, PlusIcon, RotateCcwIcon, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager entry point. + Rows3Icon, + // T3-CUSTOM(expbkt3): END SearchIcon, XIcon, } from "lucide-react"; import { + Fragment, memo, useCallback, useEffect, @@ -48,6 +56,7 @@ import { useRef, useState, type MouseEvent as ReactMouseEvent, + type ReactNode, } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -109,11 +118,10 @@ import { import { PHASE_SIDEBAR_PHASES, buildPhaseSidebarFilterChips, - buildPhaseSidebarGroups, buildPhaseSidebarRepositoryOptions, + comparePhaseSidebarRows, derivePhaseSidebarRepositoryKey, filterVisiblePhaseSidebarRows, - flattenPhaseSidebarGroups, isThreadAssignedToUser, partitionPhaseSidebarRows, phaseSidebarGroupHeaderClassName, @@ -134,7 +142,10 @@ import { PHASE_SIDEBAR_PRIORITY_CHOICES, // T3-CUSTOM(expbkt3): strict in-group ordering. PHASE_SIDEBAR_SORT_DIRECTION_LABELS, + // T3-CUSTOM(expbkt3): ownership and co-participant facets. + phaseSidebarThreadParticipantIds, compactPhaseSidebarTimeLabel, + type PhaseSidebarAttentionKind, type PhaseSidebarPhaseId, type PhaseSidebarRow, type PhaseSidebarSection, @@ -146,7 +157,20 @@ import { PHASE_SIDEBAR_METADATA_CLASS_NAME, } from "./sidebar/PhaseSidebarRowLayout"; // T3-CUSTOM(expbkt3): END +// T3-CUSTOM(expbkt3): BEGIN — session trees. +import { + buildPhaseSidebarTreeGroups, + collectPhaseSidebarSubtreeKeys, + flattenPhaseSidebarTree, + phaseSidebarTreeIndent, + type PhaseSidebarTreeNode, +} from "./sidebar/PhaseSidebarTree.logic"; +import { usePhaseSidebarTreeStore } from "../phaseSidebarTreeStore"; +import { MoveUnderSessionDialog } from "./sidebar/MoveUnderSessionDialog"; +// T3-CUSTOM(expbkt3): END import { useCurrentUserId } from "../state/identity"; +// T3-CUSTOM(expbkt3): directory for the co-participant filter facet. +import { useOrgMembers } from "../state/orgMembers"; import { T3_CONDUCTOR_ENABLED } from "../experimentalFeatures"; import { SidebarChromeFooter, @@ -164,6 +188,8 @@ import { runningSessionDividerPhase, shouldShowRunningSessionGlint, } from "./sidebar/RunningSessionGlint.logic"; +// T3-CUSTOM(expbkt3): teammate avatars in the filter popover. +import { Avatar, userDisplayName } from "./ui/avatar"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { Checkbox } from "./ui/checkbox"; @@ -296,12 +322,14 @@ function PhaseFilterPopover({ repositoryKeys, phaseIds, providerKinds, - assignedToMe, + ownedByMe, + participantUserIds, sort, toggleRepository, togglePhase, toggleProvider, - toggleAssignedToMe, + toggleOwnedByMe, + toggleParticipant, setSortDirection, togglePriorityFirst, } = usePhaseSidebarFilterStore( @@ -309,21 +337,31 @@ function PhaseFilterPopover({ repositoryKeys: state.repositoryKeys, phaseIds: state.phaseIds, providerKinds: state.providerKinds, - assignedToMe: state.assignedToMe, + ownedByMe: state.ownedByMe, + participantUserIds: state.participantUserIds, sort: state.sort, toggleRepository: state.toggleRepository, togglePhase: state.togglePhase, toggleProvider: state.toggleProvider, - toggleAssignedToMe: state.toggleAssignedToMe, + toggleOwnedByMe: state.toggleOwnedByMe, + toggleParticipant: state.toggleParticipant, setSortDirection: state.setSortDirection, togglePriorityFirst: state.togglePriorityFirst, })), ); + // T3-CUSTOM(expbkt3): everyone except the operator — "sessions I share with + // this person" is the question; a self entry would just mean "all of them". + const { users } = useOrgMembers(); + const teammates = useMemo( + () => users.filter((user) => user.id !== currentUserId), + [currentUserId, users], + ); const selectionCount = repositoryKeys.length + phaseIds.length + providerKinds.length + - (assignmentAvailable && assignedToMe ? 1 : 0); + participantUserIds.length + + (assignmentAvailable && ownedByMe ? 1 : 0); const needle = search.trim().toLowerCase(); const visibleRepositories = repositories.filter((option) => option.searchText.toLowerCase().includes(needle), @@ -339,6 +377,11 @@ function PhaseFilterPopover({ PHASE_SIDEBAR_SORT_DIRECTION_LABELS, ).join(" ")}`.toLowerCase(); const sortVisible = sortSearchText.includes(needle); + // T3-CUSTOM(expbkt3): the new facets answer to the same search box. + const ownershipVisible = assignmentAvailable && "ownership started by me".includes(needle); + const visibleTeammates = teammates.filter((user) => + `${user.name ?? ""} ${user.email ?? ""}`.toLowerCase().includes(needle), + ); return ( <Popover> @@ -443,18 +486,34 @@ function PhaseFilterPopover({ /> ))} </FacetSection> - {assignmentAvailable && "assigned to me".includes(needle) ? ( - <FacetSection label="Assignment"> + {/* T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant facets. */} + {ownershipVisible ? ( + <FacetSection label="Ownership"> <FacetOption - checked={assignedToMe} - label="Assigned to me" - onCheckedChange={() => toggleAssignedToMe()} + checked={ownedByMe} + label="Started by me" + onCheckedChange={() => toggleOwnedByMe()} /> </FacetSection> ) : null} + {assignmentAvailable && visibleTeammates.length > 0 ? ( + <FacetSection label="People on the session"> + {visibleTeammates.map((user) => ( + <FacetOption + key={user.id} + checked={participantUserIds.includes(user.id)} + label={userDisplayName(user)} + onCheckedChange={() => toggleParticipant(user.id)} + leading={<Avatar size="xs" user={user} />} + /> + ))} + </FacetSection> + ) : null} + {/* T3-CUSTOM(expbkt3): END */} {visibleRepositories.length + visiblePhases.length + visibleProviders.length === 0 && !sortVisible && - !(assignmentAvailable && "assigned to me".includes(needle)) ? ( + !ownershipVisible && + visibleTeammates.length === 0 ? ( <p className="px-2 py-6 text-center text-xs text-muted-foreground"> No filter options match. </p> @@ -551,28 +610,38 @@ function ActiveFilterChips({ repositoryKeys, phaseIds, providerKinds, - assignedToMe, + ownedByMe, + participantUserIds, toggleRepository, togglePhase, toggleProvider, - toggleAssignedToMe, + toggleOwnedByMe, + toggleParticipant, clearAll, } = usePhaseSidebarFilterStore( useShallow((state) => ({ repositoryKeys: state.repositoryKeys, phaseIds: state.phaseIds, providerKinds: state.providerKinds, - assignedToMe: state.assignedToMe, + ownedByMe: state.ownedByMe, + participantUserIds: state.participantUserIds, toggleRepository: state.toggleRepository, togglePhase: state.togglePhase, toggleProvider: state.toggleProvider, - toggleAssignedToMe: state.toggleAssignedToMe, + toggleOwnedByMe: state.toggleOwnedByMe, + toggleParticipant: state.toggleParticipant, clearAll: state.clearAll, })), ); + // T3-CUSTOM(expbkt3): a person chip reads as their name, not an opaque id. + const { users } = useOrgMembers(); + const peopleLabels = useMemo( + () => new Map(users.map((user) => [String(user.id), userDisplayName(user)] as const)), + [users], + ); const chips = buildPhaseSidebarFilterChips( - { repositoryKeys, phaseIds, providerKinds, assignedToMe }, - { repositories: repositoryLabels, providers: providerLabels }, + { repositoryKeys, phaseIds, providerKinds, ownedByMe, participantUserIds }, + { repositories: repositoryLabels, providers: providerLabels, people: peopleLabels }, ); if (chips.length === 0) return null; @@ -586,7 +655,8 @@ function ActiveFilterChips({ if (chip.facet === "repository") toggleRepository(chip.value); if (chip.facet === "phase") togglePhase(chip.value as PhaseSidebarPhaseId); if (chip.facet === "provider") toggleProvider(chip.value); - if (chip.facet === "assignment") toggleAssignedToMe(); + if (chip.facet === "assignment") toggleOwnedByMe(); + if (chip.facet === "person") toggleParticipant(chip.value); }} /> ))} @@ -774,6 +844,44 @@ interface PhaseThreadRowProps { // T3-CUSTOM(expbkt3): null clears a manually attached Linear issue. readonly onSetLinearIssueUrl: (row: PhaseSidebarRow, url: string | null) => void; readonly linearIssueStatus: LinearIssueStatusSummary | null; + // T3-CUSTOM(expbkt3): BEGIN — session tree. Deliberately flat primitives plus + // one stable actions object rather than a per-row object: this row is memo'd + // and the sidebar re-renders on every shell event, so a fresh object per + // render would defeat the memo for the entire active list. + // `treeActions` absent = shelf row, which renders as flat history. + readonly treeActions?: PhaseThreadRowTreeActions; + readonly treeDepth?: number; + readonly treeDescendantCount?: number; + readonly treeHasBusyDescendant?: boolean; + readonly treeDescendantAttention?: PhaseSidebarAttentionKind | null; + readonly treeExpanded?: boolean; + readonly treeParentKey?: string | null; + readonly treeParentTitle?: string | null; + // T3-CUSTOM(expbkt3): END +} + +/** + * T3-CUSTOM(expbkt3): Tree actions, keyed by scoped thread key so one object + * instance serves every row. + */ +type PhaseThreadRowTreeProps = Pick< + PhaseThreadRowProps, + | "treeActions" + | "treeDepth" + | "treeDescendantCount" + | "treeHasBusyDescendant" + | "treeDescendantAttention" + | "treeExpanded" + | "treeParentKey" + | "treeParentTitle" +>; + +interface PhaseThreadRowTreeActions { + readonly onToggle: (threadKey: string) => void; + readonly onSetSubtreeExpanded: (threadKey: string, expanded: boolean) => void; + readonly onMoveUnder: (row: PhaseSidebarRow) => void; + readonly onDetach: (row: PhaseSidebarRow) => void; + readonly onJumpToParent: (parentKey: string) => void; } const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) { @@ -804,6 +912,14 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) onSetPriority, onSetLinearIssueUrl, linearIssueStatus, + treeActions, + treeDepth, + treeDescendantCount, + treeHasBusyDescendant, + treeDescendantAttention, + treeExpanded, + treeParentKey, + treeParentTitle, } = props; const threadRef = scopeThreadRef(row.thread.environmentId, row.thread.id); const threadKey = scopedThreadKey(threadRef); @@ -840,6 +956,17 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) const workspacePath = row.thread.worktreePath ?? project?.workspaceRoot ?? null; const needsUserInput = row.phaseId === "needs_input"; const attentionKind = resolvePhaseSidebarAttentionKind(row.thread); + // T3-CUSTOM(expbkt3): BEGIN — session tree derivations. Subtree state only + // surfaces on the parent while the subtree is closed; once open, the child + // rows speak for themselves. + const hasChildren = (treeDescendantCount ?? 0) > 0; + const hasCollapsedBusyDescendant = + hasChildren && treeHasBusyDescendant === true && treeExpanded !== true; + // Attention outranks work: a parent hoisted into Needs Input has to say which + // of its descendants is stuck, or the group placement reads as a glitch. + const collapsedDescendantAttention = + hasChildren && treeExpanded !== true ? (treeDescendantAttention ?? null) : null; + // T3-CUSTOM(expbkt3): END const recoveryExhausted = row.thread.execution?.intent?.phase === "recovery-exhausted"; // T3-CUSTOM(expbkt3): BEGIN — settle/snooze affordances. // While the preset popover is open the pointer sits over the popup, not @@ -977,6 +1104,24 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) : []), ] : []; + // Session lineage. "Detach" is always offered when a parent exists — + // nesting must never be a one-way door. + const lineageItems = treeActions + ? [ + { id: "move-under", label: "Move under session…" }, + ...(row.thread.parentThreadId != null + ? [{ id: "detach-parent", label: "Detach from parent" }] + : []), + ...(hasChildren + ? [ + { + id: treeExpanded ? "collapse-subtree" : "expand-subtree", + label: treeExpanded ? "Collapse all children" : "Expand all children", + }, + ] + : []), + ] + : []; // T3-CUSTOM(expbkt3): END const action = await api.contextMenu.show( [ @@ -984,6 +1129,8 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) { id: "mark-unread", label: "Mark unread" }, ...priorityItems, ...linearItems, + // T3-CUSTOM(expbkt3): session lineage. + ...lineageItems, ...settlementItems, ...snoozeItems, { @@ -1041,14 +1188,30 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) if (action === "copy-id") await navigator.clipboard.writeText(row.thread.id); if (action === "archive") onArchive(row); if (action === "delete") onDelete(row); + // T3-CUSTOM(expbkt3): session lineage. + if (action === "move-under") treeActions?.onMoveUnder(row); + if (action === "detach-parent") treeActions?.onDetach(row); + if (action === "expand-subtree") treeActions?.onSetSubtreeExpanded(threadKey, true); + if (action === "collapse-subtree") treeActions?.onSetSubtreeExpanded(threadKey, false); }; return ( - <li data-thread-item> + <li + data-thread-item + // T3-CUSTOM(expbkt3): nested rows indent, capped so a deep chain does not + // eat the title. Depth 0 emits no style, keeping root rows unchanged. + {...(treeDepth !== undefined && treeDepth > 0 + ? { + "data-thread-depth": treeDepth, + style: { paddingLeft: phaseSidebarTreeIndent(treeDepth) }, + } + : {})} + > <button type="button" className={phaseSidebarRowClassName(active, selected, needsUserInput)} aria-current={active ? "page" : undefined} + aria-expanded={hasChildren ? treeExpanded : undefined} data-attention={needsUserInput ? "user-input" : undefined} data-testid={`phase-thread-row-${row.thread.id}`} onClick={handleClick} @@ -1056,7 +1219,13 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) onContextMenu={(event) => void handleContextMenu(event)} > {workBadge?.monitoring !== true && - (executionPresentation.active || shouldShowRunningSessionGlint(row.phaseId, section)) ? ( + (executionPresentation.active || + shouldShowRunningSessionGlint(row.phaseId, section) || + // T3-CUSTOM(expbkt3): a collapsed parent carries its subtree's + // running signal. Only while collapsed — once open the child that is + // actually working carries it, and two sweeps for one unit of work + // would both mislead and repaint twice. + (hasCollapsedBusyDescendant && workBadge === null)) ? ( <RunningSessionGlint /> ) : null} {active ? ( @@ -1066,7 +1235,56 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) className="pointer-events-none absolute inset-y-1 right-0 w-0.5 rounded-full bg-primary shadow-[0_0_6px_var(--color-primary)]" /> ) : null} - <PhaseSidebarUnreadIndicator isUnread={row.isUnreadCompletion} threadId={row.thread.id} /> + {/* T3-CUSTOM(expbkt3): BEGIN — session-tree disclosure. + This sits in the SAME lane the unread dot reserves rather than + adding another one: the row is a flex box with gap-2, so an extra + child would cost its own width plus a gap and shove the title ~48px + right of every neighbouring row. The count is bare tabular text for + the same reason — pill chrome costs another ~10px of horizontal + padding for one glyph. */} + {hasChildren ? ( + <span + role="button" + tabIndex={-1} + aria-label={treeExpanded ? "Collapse child sessions" : "Expand child sessions"} + data-testid={`phase-thread-disclosure-${row.thread.id}`} + className="-my-1 -ml-0.5 flex shrink-0 cursor-pointer items-center gap-px self-center rounded py-1 text-muted-foreground/70 transition-colors hover:text-foreground" + onClick={(event) => { + // The row itself navigates; opening a subtree must not. + event.stopPropagation(); + event.preventDefault(); + treeActions?.onToggle(threadKey); + }} + > + <ChevronRightIcon + aria-hidden + className={cn( + "size-3.5 shrink-0 transition-transform duration-150", + treeExpanded && "rotate-90", + )} + /> + <span + aria-label={`${treeDescendantCount} child sessions`} + className={cn( + "text-[10px] font-semibold tabular-nums leading-none", + collapsedDescendantAttention !== null + ? "text-red-600 dark:text-red-300" + : hasCollapsedBusyDescendant + ? "text-sky-600 dark:text-sky-300" + : "text-current", + )} + > + {treeDescendantCount} + </span> + </span> + ) : null} + {/* T3-CUSTOM(expbkt3): END */} + {/* T3-CUSTOM(expbkt3): the empty spacer only earns its width when there + is no disclosure in the lane. An unread dot still renders on a + parent — that is real state, not reserved space. */} + {hasChildren && !row.isUnreadCompletion ? null : ( + <PhaseSidebarUnreadIndicator isUnread={row.isUnreadCompletion} threadId={row.thread.id} /> + )} {/* T3-CUSTOM(expbkt3): Vertically centered adaptive content lane. */} <span className={PHASE_SIDEBAR_CONTENT_CLASS_NAME}> {renaming ? ( @@ -1100,6 +1318,33 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) )} {/* T3-CUSTOM(expbkt3): Checkout and Linear details remain in the content lane. */} <span className={PHASE_SIDEBAR_METADATA_CLASS_NAME}> + {/* T3-CUSTOM(expbkt3): This row has a parent that is not rendering + here (settled, snoozed, filtered out). Naming it keeps the + lineage visible instead of silently flattening the row. */} + {treeParentKey && treeParentTitle ? ( + <Tooltip> + <TooltipTrigger + render={ + <span + role="link" + tabIndex={0} + data-testid={`phase-thread-parent-crumb-${row.thread.id}`} + aria-label={`Go to parent session ${treeParentTitle}`} + className="inline-flex max-w-full shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap text-muted-foreground/70 hover:text-foreground hover:underline" + onClick={(event) => { + event.stopPropagation(); + treeActions?.onJumpToParent(treeParentKey); + }} + onDoubleClick={(event) => event.stopPropagation()} + /> + } + > + <CornerDownRightIcon aria-hidden className="size-2.5 shrink-0" /> + <span className="min-w-0 truncate">{treeParentTitle}</span> + </TooltipTrigger> + <TooltipPopup side="top">Started by {treeParentTitle}</TooltipPopup> + </Tooltip> + ) : null} <Tooltip> {/* T3-CUSTOM(expbkt3): BEGIN — wrap complete labels as units. */} <TooltipTrigger @@ -1182,6 +1427,42 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) {workBadge.label.toUpperCase()} </span> ) : null} + {/* T3-CUSTOM(expbkt3): A descendant is waiting on a human. Outlined + with a ↳ glyph, same grammar as the derived work badge: solid is + this row, outlined is somewhere beneath it. */} + {collapsedDescendantAttention !== null && attentionKind === null ? ( + <span + role="status" + aria-label={`A child session needs ${collapsedDescendantAttention}`} + data-testid={`phase-thread-subtree-attention-${row.thread.id}`} + className={cn( + "rounded-sm border px-1 py-0.5 text-[8px] font-black tracking-wide", + collapsedDescendantAttention === "input" + ? "border-red-500/50 text-red-600 dark:border-red-400/50 dark:text-red-300" + : collapsedDescendantAttention === "approval" + ? "border-amber-500/50 text-amber-700 dark:border-amber-400/50 dark:text-amber-300" + : "border-red-500/40 text-red-700 dark:border-red-400/40 dark:text-red-300", + )} + > + ↳ {collapsedDescendantAttention.toUpperCase()} + </span> + ) : null} + {/* T3-CUSTOM(expbkt3): Work happening BELOW this row, not in it. + Outlined rather than filled, with a ↳ glyph: the same grammar + distinguishes "mine" from "my subtree's" everywhere on the row. */} + {hasCollapsedBusyDescendant && + workBadge === null && + attentionKind === null && + collapsedDescendantAttention === null ? ( + <span + role="status" + aria-label="A child session is working" + data-testid={`phase-thread-subtree-working-${row.thread.id}`} + className="rounded-sm border border-sky-500/40 px-1 py-0.5 text-[8px] font-black tracking-wide text-sky-700 dark:border-sky-400/40 dark:text-sky-300" + > + ↳ WORKING + </span> + ) : null} {attentionKind === "input" ? ( <span aria-label="Awaiting input" @@ -1439,12 +1720,19 @@ export function PhaseGroupedSidebar() { const t3Conductor = personalMcpProfile?.conductor ?? legacyT3Conductor; const primaryEnvironmentId = usePrimaryEnvironmentId(); const lastVisitedAtByThreadKey = useUiStateStore((state) => state.threadLastVisitedAtById); + // T3-CUSTOM(expbkt3): directory ids backing the co-participant facet. + const { users: orgMembers } = useOrgMembers(); + const orgMemberIds = useMemo( + () => new Set(orgMembers.map((user) => String(user.id))), + [orgMembers], + ); const filters = usePhaseSidebarFilterStore( useShallow((state) => ({ repositoryKeys: state.repositoryKeys, phaseIds: state.phaseIds, providerKinds: state.providerKinds, - assignedToMe: state.assignedToMe, + ownedByMe: state.ownedByMe, + participantUserIds: state.participantUserIds, })), ); const clearFilters = usePhaseSidebarFilterStore((state) => state.clearAll); @@ -1601,6 +1889,10 @@ export function PhaseGroupedSidebar() { providerName: provider?.displayName ?? thread.session?.providerName ?? String(instanceId), isAssignedToMe: currentUserId !== null && isThreadAssignedToUser(thread, currentUserId), + // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant facets. + isOwnedByMe: currentUserId !== null && thread.ownerUserId === currentUserId, + participantUserIds: phaseSidebarThreadParticipantIds(thread), + // T3-CUSTOM(expbkt3): END attentionPriority: resolvePhaseSidebarAttentionPriority(thread, vcsStatus), isUnreadCompletion, // T3-CUSTOM(expbkt3): BEGIN — lifecycle parking inputs. @@ -1628,17 +1920,39 @@ export function PhaseGroupedSidebar() { // T3-CUSTOM(expbkt3): BEGIN — split the inbox from the parked shelves. // Filtering happens once, before the partition, so a filter chip means the // same thing in the lifecycle groups and on both shelves. - const { activeRows, snoozedRows, settledRows } = useMemo(() => { + const { + activeRows: unfilteredActiveRows, + snoozedRows: unfilteredSnoozedRows, + settledRows: unfilteredSettledRows, + } = useMemo(() => { // Snooze classification uses a REAL clock, not the quantized minute: a // thread whose wake time just passed must leave the shelf immediately. // snoozeWakeTick re-runs this at the exact boundary. void snoozeWakeTick; - return partitionPhaseSidebarRows(filterVisiblePhaseSidebarRows(allRows, filters), { - now: nowMinute, - preciseNow: new Date().toISOString(), - autoSettleAfterDays, - }); - }, [allRows, autoSettleAfterDays, filters, nowMinute, snoozeWakeTick]); + // T3-CUSTOM(expbkt3): partition the UNFILTERED set. Classification never + // reads the filters, and the lifecycle groups need every row: a session + // tree has to be able to keep a parent that does not itself match so a + // matching child stays reachable. Each section applies the filter below. + return partitionPhaseSidebarRows( + allRows.filter((row) => row.thread.archivedAt === null), + { + now: nowMinute, + preciseNow: new Date().toISOString(), + autoSettleAfterDays, + }, + ); + }, [allRows, autoSettleAfterDays, nowMinute, snoozeWakeTick]); + // T3-CUSTOM(expbkt3): the shelves are flat history lists, so they filter + // row-by-row as before. Only the lifecycle groups nest. + const activeRows = unfilteredActiveRows; + const snoozedRows = useMemo( + () => filterVisiblePhaseSidebarRows(unfilteredSnoozedRows, filters), + [filters, unfilteredSnoozedRows], + ); + const settledRows = useMemo( + () => filterVisiblePhaseSidebarRows(unfilteredSettledRows, filters), + [filters, unfilteredSettledRows], + ); // Wake exactly when the soonest snooze expires (the shelf is sorted, so // that is the first row). Clamped at 0, and capped so a far-future wake @@ -1651,13 +1965,58 @@ export function PhaseGroupedSidebar() { return () => window.clearTimeout(id); }, [snoozedRows]); - const groups = useMemo( - () => buildPhaseSidebarGroups(activeRows, filters, sortOrder, rowSort), - [activeRows, filters, rowSort, sortOrder], + // T3-CUSTOM(expbkt3): BEGIN — session trees. Rows created by another session + // nest under it; grouping then runs over roots only, with a parent pulled + // into Implementing whenever anything in its subtree is working. + const titleForThreadKey = useCallback( + (key: string) => + allRows.find( + (row) => scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)) === key, + )?.thread.title ?? null, + [allRows], ); + const { groups, forcedExpansionKeys } = useMemo( + () => + buildPhaseSidebarTreeGroups({ + rows: activeRows, + filters, + compareSiblings: (left, right) => comparePhaseSidebarRows(left, right, sortOrder, rowSort), + titleForKey: titleForThreadKey, + }), + [activeRows, filters, rowSort, sortOrder, titleForThreadKey], + ); + const storedExpandedKeys = usePhaseSidebarTreeStore((state) => state.expandedKeys); + const toggleTreeKey = usePhaseSidebarTreeStore((state) => state.toggle); + const setTreeKeysExpanded = usePhaseSidebarTreeStore((state) => state.setExpanded); + // A filter match inside a closed parent forces that parent open for as long + // as the filter is on, without touching what the user chose. + const expandedKeys = useMemo(() => { + const keys = new Set(storedExpandedKeys); + for (const key of forcedExpansionKeys) keys.add(key); + return keys; + }, [forcedExpansionKeys, storedExpandedKeys]); + const isTreeKeyExpanded = useCallback((key: string) => expandedKeys.has(key), [expandedKeys]); + // Keys that HAVE children, so the arrow-key handler can ignore leaf rows. + const expandableThreadKeys = useMemo(() => { + const keys = new Set<string>(); + const visit = (node: PhaseSidebarTreeNode) => { + if (node.children.length > 0) keys.add(node.key); + for (const child of node.children) visit(child); + }; + for (const group of groups) for (const node of group.nodes) visit(node); + return keys; + }, [groups]); + // T3-CUSTOM(expbkt3): END // T3-CUSTOM(expbkt3): Separate idle lifecycle groups from live agent work. const runningDividerPhaseId = runningSessionDividerPhase(groups.map((group) => group.id)); - const activeVisibleRows = useMemo(() => flattenPhaseSidebarGroups(groups), [groups]); + const activeVisibleNodes = useMemo( + () => groups.flatMap((group) => flattenPhaseSidebarTree(group.nodes, isTreeKeyExpanded)), + [groups, isTreeKeyExpanded], + ); + const activeVisibleRows = useMemo( + () => activeVisibleNodes.map((node) => node.row), + [activeVisibleNodes], + ); // The settled tail renders in pages: history must not dominate the list. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); @@ -1768,7 +2127,8 @@ export function PhaseGroupedSidebar() { filters.repositoryKeys.length + filters.phaseIds.length + filters.providerKinds.length + - (filters.assignedToMe ? 1 : 0); + filters.participantUserIds.length + + (filters.ownedByMe ? 1 : 0); const activeThreadHidden = activeFiltersCount > 0 && routeThreadKey !== null && @@ -1839,12 +2199,18 @@ export function PhaseGroupedSidebar() { repositoryKeys: new Set(repositoryOptions.map((option) => option.key)), providerKinds: new Set(providerOptions.map((option) => option.kind)), assignmentAvailable: currentUserId !== null, + // T3-CUSTOM(expbkt3): drop people who left the directory, so a departed + // teammate cannot leave an unremovable filter pinned over the sidebar. + // Skipped while the directory is still loading — an empty list then would + // clear a perfectly good filter. + ...(orgMemberIds.size > 0 ? { participantUserIds: orgMemberIds } : {}), }); }, [ allEnvironmentShellsLive, currentUserId, environments.length, networkStatus, + orgMemberIds, providerOptions, reconcileFilters, repositoryOptions, @@ -1867,6 +2233,27 @@ export function PhaseGroupedSidebar() { if (event.defaultPrevented || event.repeat) return; if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return; + // T3-CUSTOM(expbkt3): BEGIN — arrows open and close the routed row's + // subtree, matching how every other tree in the app behaves. Only + // meaningful on a row that has children, so anything else falls through + // to the normal shortcut resolution untouched. + if ( + (event.key === "ArrowRight" || event.key === "ArrowLeft") && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + routeThreadKey !== null && + expandableThreadKeys.has(routeThreadKey) + ) { + const shouldExpand = event.key === "ArrowRight"; + if (expandedKeys.has(routeThreadKey) !== shouldExpand) { + event.preventDefault(); + event.stopPropagation(); + setTreeKeysExpanded(routeThreadKey, shouldExpand); + } + return; + } + // T3-CUSTOM(expbkt3): END const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, context: { @@ -1895,7 +2282,16 @@ export function PhaseGroupedSidebar() { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, navigateToRow, routeThreadKey, visibleRowByKey, visibleThreadKeys]); + }, [ + expandableThreadKeys, + expandedKeys, + keybindings, + navigateToRow, + routeThreadKey, + setTreeKeysExpanded, + visibleRowByKey, + visibleThreadKeys, + ]); useEffect(() => { const onMouseDown = (event: MouseEvent) => { @@ -2002,6 +2398,80 @@ export function PhaseGroupedSidebar() { }, [forceStopThreadSession], ); + // Session lineage. Both directions travel on the same thread.meta.update + // command the Linear tag uses; the server rejects a parent that would close + // a cycle, so the failure toast is the only handling needed here. + const [moveUnderRow, setMoveUnderRow] = useState<PhaseSidebarRow | null>(null); + const setThreadParent = useCallback( + (row: PhaseSidebarRow, parentThreadId: ThreadId | null) => { + if ((row.thread.parentThreadId ?? null) === parentThreadId) return; + void updateThreadMetadata({ + environmentId: row.thread.environmentId, + input: { threadId: row.thread.id, parentThreadId }, + }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: + parentThreadId === null ? "Failed to detach session" : "Failed to move session", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }); + }, + [updateThreadMetadata], + ); + const detachFromParent = useCallback( + (row: PhaseSidebarRow) => setThreadParent(row, null), + [setThreadParent], + ); + const openMoveUnderDialog = useCallback((row: PhaseSidebarRow) => setMoveUnderRow(row), []); + const subtreeKeysByThreadKey = useMemo(() => { + const map = new Map<string, ReadonlyArray<string>>(); + const visit = (node: PhaseSidebarTreeNode) => { + map.set(node.key, collectPhaseSidebarSubtreeKeys(node)); + for (const child of node.children) visit(child); + }; + for (const group of groups) for (const node of group.nodes) visit(node); + return map; + }, [groups]); + const jumpToThreadKey = useCallback( + (key: string) => { + const target = allRows.find( + (candidate) => + scopedThreadKey(scopeThreadRef(candidate.thread.environmentId, candidate.thread.id)) === + key, + ); + if (target) navigateToRow(scopeThreadRef(target.thread.environmentId, target.thread.id)); + }, + [allRows, navigateToRow], + ); + // One object instance shared by every row, so the memo on PhaseThreadRow + // survives the sidebar's frequent re-renders. + const treeActions = useMemo<PhaseThreadRowTreeActions>( + () => ({ + onToggle: (threadKey) => toggleTreeKey(threadKey), + onSetSubtreeExpanded: (threadKey, expanded) => + setTreeKeysExpanded( + [threadKey, ...(subtreeKeysByThreadKey.get(threadKey) ?? [])], + expanded, + ), + onMoveUnder: openMoveUnderDialog, + onDetach: detachFromParent, + onJumpToParent: jumpToThreadKey, + }), + [ + detachFromParent, + jumpToThreadKey, + openMoveUnderDialog, + setTreeKeysExpanded, + subtreeKeysByThreadKey, + toggleTreeKey, + ], + ); // T3-CUSTOM(expbkt3): END const requestArchive = useCallback( async (row: PhaseSidebarRow) => { @@ -2190,7 +2660,16 @@ export function PhaseGroupedSidebar() { // T3-CUSTOM(expbkt3): One row shape for the lifecycle groups and both // parked shelves — only `section` differs. - const renderThreadRow = (row: PhaseSidebarRow, section: PhaseSidebarSection) => { + const renderThreadRow = ( + row: PhaseSidebarRow, + section: PhaseSidebarSection, + // T3-CUSTOM(expbkt3): omitted on shelf rows, which stay a flat history list. + // Spread this straight onto the row — never nest it under a `tree` key. + // JSX spread skips excess-property checking, so a stale wrapper compiles + // clean while silently leaving every treeXxx prop undefined, which reads as + // "the feature is off" rather than as a build error. + tree?: PhaseThreadRowTreeProps, + ) => { const key = scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)); const project = projectByKey.get( @@ -2234,10 +2713,44 @@ export function PhaseGroupedSidebar() { ) ?? null) : null; })()} + {...(tree ?? {})} /> ); }; + // T3-CUSTOM(expbkt3): BEGIN — a session tree renders as nested <ul>s so the + // list stays a list for assistive tech, and each level animates on its own. + const renderTreeNode = (node: PhaseSidebarTreeNode): ReactNode => { + const expanded = expandedKeys.has(node.key); + return ( + <Fragment key={node.key}> + {renderThreadRow(node.row, "active", { + treeActions, + treeDepth: node.depth, + treeDescendantCount: node.descendantCount, + treeHasBusyDescendant: node.hasBusyDescendant, + treeDescendantAttention: node.descendantAttention, + treeExpanded: expanded, + treeParentKey: node.orphanedFrom?.key ?? null, + treeParentTitle: node.orphanedFrom?.title ?? null, + })} + {expanded && node.children.length > 0 ? ( + <li> + <ul + ref={attachAutoAnimate} + role="group" + aria-label={`Sessions started by ${node.row.thread.title}`} + className="mt-0.5 space-y-0.5 border-l border-sidebar-border pl-1" + > + {node.children.map((child) => renderTreeNode(child))} + </ul> + </li> + ) : null} + </Fragment> + ); + }; + // T3-CUSTOM(expbkt3): END + return ( <> {threads.map((thread) => { @@ -2284,6 +2797,18 @@ export function PhaseGroupedSidebar() { ) : null} </SidebarMenuButton> </SidebarMenuItem> + {/* T3-CUSTOM(expbkt3): BEGIN — entry point for the bulk session manager. */} + <SidebarMenuItem> + <SidebarMenuButton + size="sm" + className="gap-2 px-2 py-1.5" + onClick={() => void router.navigate({ to: "/sessions" })} + > + <Rows3Icon className="size-3.5" /> + <span className="flex-1 text-left text-xs">Manage sessions</span> + </SidebarMenuButton> + </SidebarMenuItem> + {/* T3-CUSTOM(expbkt3): END */} </SidebarMenu> </SidebarGroup> <SidebarEnvironmentNotices /> @@ -2333,11 +2858,11 @@ export function PhaseGroupedSidebar() { {group.helperText} </span> <span className="min-w-4 rounded-full bg-background/45 px-1.5 py-0.5 text-center text-[9px] font-semibold tabular-nums text-current/70"> - {group.rows.length} + {group.nodes.length} </span> </header> <ul ref={attachAutoAnimate} className="space-y-0.5"> - {group.rows.map((row) => renderThreadRow(row, "active"))} + {group.nodes.map((node) => renderTreeNode(node))} </ul> </section> ))} @@ -2455,6 +2980,21 @@ export function PhaseGroupedSidebar() { /> {/* T3-CUSTOM(expbkt3): attach-to-external-session. */} <AttachExternalSessionDialog open={attachSessionOpen} onOpenChange={setAttachSessionOpen} /> + {/* T3-CUSTOM(expbkt3): session lineage. */} + <MoveUnderSessionDialog + subject={moveUnderRow?.thread ?? null} + threads={allRows.map((row) => row.thread)} + repositoryLabelFor={(thread) => + allRows.find((row) => row.thread.id === thread.id)?.repositoryLabel ?? "" + } + onOpenChange={(open) => { + if (!open) setMoveUnderRow(null); + }} + onSelect={(parentThreadId) => { + if (moveUnderRow) setThreadParent(moveUnderRow, parentThreadId); + setMoveUnderRow(null); + }} + /> </> ); } diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index a9cd7ea4703..163c7cc1273 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -218,6 +218,8 @@ function surfaceTitle( // T3-CUSTOM(expbkt3): BEGIN — label the experimental review surface. case "plannotator": return "Plannotator"; + case "planReview": + return "Plan review"; // T3-CUSTOM(expbkt3): END case "agents": return "Agents"; @@ -283,6 +285,8 @@ function SurfaceIcon({ // T3-CUSTOM(expbkt3): BEGIN — icon for the experimental review surface. case "plannotator": return <ClipboardList className="size-3.5 shrink-0 text-violet-500" />; + case "planReview": + return <ClipboardList className="size-3.5 shrink-0 text-sky-500" />; // T3-CUSTOM(expbkt3): END case "agents": return <Bot className="size-3 shrink-0" />; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 935130c1b79..5d9aa7f5dbb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -157,6 +157,10 @@ interface TimelineRowSharedState { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; // T3-CUSTOM(expbkt3): Native proposed-plan cards open the focused review surface. onOpenPlannotator: (url: `/plannotator/${string}/`) => void; + // T3-CUSTOM(expbkt3): native plan review entry point. Optional so upstream + // fixtures that predate it keep compiling. + onOpenPlanReview?: ((documentId: string) => void) | undefined; + planReviewDocumentId?: string | null | undefined; onRegenerateCatchupSummary?: ((turnId: TurnId) => void) | undefined; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; @@ -237,6 +241,10 @@ interface MessagesTimelineProps { routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onOpenPlannotator: (url: `/plannotator/${string}/`) => void; + // T3-CUSTOM(expbkt3): native plan review entry point. Optional so upstream + // fixtures that predate it keep compiling. + onOpenPlanReview?: ((documentId: string) => void) | undefined; + planReviewDocumentId?: string | null | undefined; onRegenerateCatchupSummary?: ((turnId: TurnId) => void) | undefined; revertTurnCountByUserMessageId: Map<MessageId, number>; onRevertUserMessage: (messageId: MessageId) => void; @@ -288,6 +296,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ routeThreadKey, onOpenTurnDiff, onOpenPlannotator, + onOpenPlanReview, + planReviewDocumentId = null, onRegenerateCatchupSummary, revertTurnCountByUserMessageId, onRevertUserMessage, @@ -552,6 +562,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onOpenTurnDiff, onOpenPlannotator, + onOpenPlanReview, + planReviewDocumentId, onRegenerateCatchupSummary, onToggleTurnFold, onToggleWorkGroup, @@ -572,6 +584,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onOpenTurnDiff, onOpenPlannotator, + onOpenPlanReview, + planReviewDocumentId, onRegenerateCatchupSummary, onToggleTurnFold, onToggleWorkGroup, @@ -1242,6 +1256,8 @@ function ProposedPlanTimelineRow({ cwd={ctx.markdownCwd} workspaceRoot={ctx.workspaceRoot} onOpenPlannotator={ctx.onOpenPlannotator} + onOpenPlanReview={ctx.onOpenPlanReview} + planReviewDocumentId={ctx.planReviewDocumentId} reviewable={row.proposedPlan.implementedAt === null} /> </div> diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 654d187982a..3d4b6113fb1 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -41,6 +41,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ cwd, workspaceRoot, onOpenPlannotator, + onOpenPlanReview, + planReviewDocumentId, reviewable = false, }: { planMarkdown: string; @@ -49,6 +51,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ cwd: string | undefined; workspaceRoot: string | undefined; onOpenPlannotator?: ((url: `/plannotator/${string}/`) => void) | undefined; + onOpenPlanReview?: ((documentId: string) => void) | undefined; + planReviewDocumentId?: string | null | undefined; reviewable?: boolean | undefined; }) { const [expanded, setExpanded] = useState(false); @@ -196,7 +200,9 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" /> ) : null} </div> - {canCollapse || (reviewable && onOpenPlannotator) ? ( + {canCollapse || + (reviewable && onOpenPlannotator) || + (reviewable && onOpenPlanReview && planReviewDocumentId) ? ( <div className="mt-4 flex flex-wrap justify-center gap-2"> {canCollapse ? ( <Button @@ -209,6 +215,18 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ </Button> ) : null} {/* T3-CUSTOM(expbkt3): BEGIN — native timeline plan review entry point. */} + {reviewable && onOpenPlanReview && planReviewDocumentId ? ( + <Button + size="sm" + data-scroll-anchor-ignore + data-plan-review-trigger + aria-label="Open the plan in the review panel" + onClick={() => onOpenPlanReview(planReviewDocumentId)} + > + Preview + <ArrowRightIcon className="size-4" /> + </Button> + ) : null} {reviewable && onOpenPlannotator ? ( plannotatorUrl ? ( <Button diff --git a/apps/web/src/components/planreview/PlanReviewDiscussions.tsx b/apps/web/src/components/planreview/PlanReviewDiscussions.tsx new file mode 100644 index 00000000000..a10f2138ce5 --- /dev/null +++ b/apps/web/src/components/planreview/PlanReviewDiscussions.tsx @@ -0,0 +1,116 @@ +/** + * T3-CUSTOM(expbkt3): anchored discussion rail for the plan review panel. + * + * Each discussion carries the text it was anchored to; that quote is what the + * server turns into a line range when the feedback is sent, so it is shown + * verbatim rather than paraphrased. + */ +import type { PlanReviewComment, PlanReviewDiscussion } from "@t3tools/contracts"; +import { CheckIcon, MessageSquareIcon, RotateCcwIcon } from "lucide-react"; +import { memo, useMemo } from "react"; + +import { Avatar, userDisplayName } from "../ui/avatar"; +import { Button } from "../ui/button"; +import { cn } from "../../lib/utils"; +import { useCurrentUserId } from "../../state/identity"; +import { useOrgMembers } from "../../state/orgMembers"; + +interface PlanReviewDiscussionsProps { + readonly discussions: ReadonlyArray<PlanReviewDiscussion>; + readonly comments: ReadonlyArray<PlanReviewComment>; + readonly onResolve: (discussionId: string, isResolved: boolean) => void; + readonly disabled: boolean; +} + +function PlanReviewDiscussionsImpl({ + discussions, + comments, + onResolve, + disabled, +}: PlanReviewDiscussionsProps) { + const { resolveUser } = useOrgMembers(); + const viewerUserId = useCurrentUserId(); + + const commentsByDiscussion = useMemo(() => { + const grouped = new Map<string, PlanReviewComment[]>(); + for (const comment of comments) { + const bucket = grouped.get(comment.discussionId); + if (bucket) bucket.push(comment); + else grouped.set(comment.discussionId, [comment]); + } + return grouped; + }, [comments]); + + if (discussions.length === 0) { + return ( + <div className="flex flex-col items-center gap-2 px-4 py-8 text-center"> + <MessageSquareIcon className="size-5 text-muted-foreground" aria-hidden /> + <p className="text-muted-foreground text-sm"> + Select text in the plan and add a comment to start a discussion. + </p> + </div> + ); + } + + return ( + <ul className="flex flex-col gap-2 p-2"> + {discussions.map((discussion) => { + const thread = commentsByDiscussion.get(discussion.discussionId) ?? []; + return ( + <li + key={discussion.discussionId} + className={cn( + "rounded-md border p-2", + discussion.isResolved ? "border-border/50 opacity-60" : "border-border", + )} + > + <blockquote className="mb-2 border-primary/40 border-l-2 pl-2 text-muted-foreground text-xs italic"> + {discussion.quotedText} + </blockquote> + + {thread.map((comment) => { + const isViewer = + comment.authorUserId === null || comment.authorUserId === viewerUserId; + return ( + <div key={comment.commentId} className="mb-1.5 flex gap-2 last:mb-0"> + {comment.authorUserId === null ? null : ( + <Avatar user={resolveUser(comment.authorUserId)} size="xs" /> + )} + <div className="min-w-0 flex-1"> + <p className="font-medium text-xs"> + {isViewer ? "You" : userDisplayName(resolveUser(comment.authorUserId!))} + </p> + <p className="whitespace-pre-wrap break-words text-sm"> + {comment.bodyMarkdown} + </p> + </div> + </div> + ); + })} + + <div className="mt-2 flex justify-end"> + <Button + size="sm" + variant="ghost" + disabled={disabled} + onClick={() => onResolve(discussion.discussionId, !discussion.isResolved)} + > + {discussion.isResolved ? ( + <> + <RotateCcwIcon className="size-3.5" aria-hidden /> Reopen + </> + ) : ( + <> + <CheckIcon className="size-3.5" aria-hidden /> Resolve + </> + )} + </Button> + </div> + </li> + ); + })} + </ul> + ); +} + +export const PlanReviewDiscussions = memo(PlanReviewDiscussionsImpl); diff --git a/apps/web/src/components/planreview/PlanReviewEditor.tsx b/apps/web/src/components/planreview/PlanReviewEditor.tsx new file mode 100644 index 00000000000..96595e56454 --- /dev/null +++ b/apps/web/src/components/planreview/PlanReviewEditor.tsx @@ -0,0 +1,350 @@ +/** + * T3-CUSTOM(expbkt3): the Plate editing surface for a plan document. + * + * Suggestion mode is on by default, so every human edit is an attributed + * insert/delete the reviewer can accept or reject before it becomes a version. + * The editor owns no persistence: it reports serialized markdown upward and + * the panel decides when that becomes a draft or a version. + */ +import { CommentPlugin } from "@platejs/comment/react"; +import { MarkdownPlugin } from "@platejs/markdown"; +import { SuggestionPlugin } from "@platejs/suggestion/react"; +import { + BlockquoteRules, + BoldRules, + CodeRules, + HeadingRules, + HighlightRules, + HorizontalRuleRules, + ItalicRules, + StrikethroughRules, +} from "@platejs/basic-nodes"; +import { + BlockquotePlugin, + BoldPlugin, + CodePlugin, + H1Plugin, + H2Plugin, + H3Plugin, + H4Plugin, + H5Plugin, + H6Plugin, + HighlightPlugin, + HorizontalRulePlugin, + ItalicPlugin, + KbdPlugin, + StrikethroughPlugin, + UnderlinePlugin, +} from "@platejs/basic-nodes/react"; +import { CodeBlockRules } from "@platejs/code-block"; +import { CodeBlockPlugin, CodeLinePlugin } from "@platejs/code-block/react"; +import { LinkPlugin } from "@platejs/link/react"; +import { BulletedListRules, OrderedListRules, TaskListRules } from "@platejs/list-classic"; +import { + BulletedListPlugin, + ListItemContentPlugin, + ListItemPlugin, + ListPlugin, + NumberedListPlugin, + TaskListPlugin, +} from "@platejs/list-classic/react"; +import { + TableCellHeaderPlugin, + TableCellPlugin, + TablePlugin, + TableRowPlugin, +} from "@platejs/table/react"; +import { ParagraphPlugin, Plate, PlateContent, usePlateEditor } from "platejs/react"; +import remarkGfm from "remark-gfm"; +import { memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; + +import { PlanReviewFloatingToolbar } from "./plate/PlanReviewFloatingToolbar"; +import { + BlockquoteElement, + BulletedListElement, + CodeBlockElement, + CodeLeaf, + CodeLineElement, + H1Element, + H2Element, + H3Element, + H4Element, + H5Element, + H6Element, + HighlightLeaf, + HorizontalRuleElement, + KbdLeaf, + LinkElement, + ListItemElement, + NumberedListElement, + ParagraphElement, + TableCellElement, + TableCellHeaderElement, + TableElement, + TableRowElement, + TaskListElement, +} from "./plate/PlanReviewNodes"; +import { CommentLeaf, SuggestionLeaf } from "./plate/PlanReviewReviewMarks"; +import { normalizeQuotedText } from "./planReviewMarkdown"; +import { Button } from "../ui/button"; +import { cn } from "../../lib/utils"; + +/** + * Scoped to what agent plans actually contain — headings, marks, lists, code, + * tables, links, quotes — plus comments and suggestions. Every plugin carries + * its component: a registered node type with no component renders as an + * unstyled block, which is what makes an editor look like a textarea. + * + * Kept module-local and unexported: an exported plugin array would force + * TypeScript to name Plate's internal option types across package boundaries. + */ +const PLAN_REVIEW_PLUGINS = [ + ParagraphPlugin.withComponent(ParagraphElement), + // `rules.break.empty: "reset"` makes Enter on an empty heading fall back to a + // paragraph, which is what every markdown editor does. + H1Plugin.configure({ + inputRules: [HeadingRules.markdown()], + rules: { break: { empty: "reset" } }, + }).withComponent(H1Element), + H2Plugin.configure({ + inputRules: [HeadingRules.markdown()], + rules: { break: { empty: "reset" } }, + }).withComponent(H2Element), + H3Plugin.configure({ + inputRules: [HeadingRules.markdown()], + rules: { break: { empty: "reset" } }, + }).withComponent(H3Element), + H4Plugin.configure({ + inputRules: [HeadingRules.markdown()], + rules: { break: { empty: "reset" } }, + }).withComponent(H4Element), + H5Plugin.withComponent(H5Element), + H6Plugin.withComponent(H6Element), + BlockquotePlugin.configure({ inputRules: [BlockquoteRules.markdown()] }).withComponent( + BlockquoteElement, + ), + HorizontalRulePlugin.configure({ + inputRules: [ + HorizontalRuleRules.markdown({ variant: "-" }), + HorizontalRuleRules.markdown({ variant: "_" }), + ], + }).withComponent(HorizontalRuleElement), + CodeBlockPlugin.configure({ + inputRules: [CodeBlockRules.markdown({ on: "match" })], + }).withComponent(CodeBlockElement), + CodeLinePlugin.withComponent(CodeLineElement), + LinkPlugin.withComponent(LinkElement), + + // Classic ul/ol/li lists map straight from markdown, and carry task lists. + ListPlugin.configure({ + inputRules: [ + BulletedListRules.markdown({ variant: "-" }), + BulletedListRules.markdown({ variant: "*" }), + OrderedListRules.markdown({ variant: "." }), + OrderedListRules.markdown({ variant: ")" }), + TaskListRules.markdown({ checked: false }), + TaskListRules.markdown({ checked: true }), + ], + }), + ListItemContentPlugin, + ListItemPlugin.withComponent(ListItemElement), + BulletedListPlugin.withComponent(BulletedListElement), + NumberedListPlugin.withComponent(NumberedListElement), + TaskListPlugin.withComponent(TaskListElement), + + TablePlugin.withComponent(TableElement), + TableRowPlugin.withComponent(TableRowElement), + TableCellPlugin.withComponent(TableCellElement), + TableCellHeaderPlugin.withComponent(TableCellHeaderElement), + + BoldPlugin.configure({ + inputRules: [BoldRules.markdown({ variant: "*" }), BoldRules.markdown({ variant: "_" })], + }), + ItalicPlugin.configure({ + inputRules: [ItalicRules.markdown({ variant: "*" }), ItalicRules.markdown({ variant: "_" })], + }), + UnderlinePlugin, + StrikethroughPlugin.configure({ inputRules: [StrikethroughRules.markdown()] }), + HighlightPlugin.configure({ + inputRules: [HighlightRules.markdown({ variant: "==" })], + }).withComponent(HighlightLeaf), + CodePlugin.configure({ inputRules: [CodeRules.markdown()] }).withComponent(CodeLeaf), + KbdPlugin.withComponent(KbdLeaf), + + CommentPlugin.withComponent(CommentLeaf), + SuggestionPlugin.withComponent(SuggestionLeaf), + + // Markdown last: it reads the node types the plugins above registered. + MarkdownPlugin.configure({ options: { remarkPlugins: [remarkGfm] } }), +]; + +/** + * Pull-based access to the document. + * + * Serializing the whole tree to markdown costs a full walk plus a + * remark-stringify pass, so the panel asks for it when it actually needs it — + * on the debounced save and on submit — rather than on every keystroke. + */ +export interface PlanReviewEditorHandle { + readonly getMarkdown: () => string; +} + +interface PlanReviewEditorProps { + /** Canonical markdown for the version being reviewed. */ + readonly markdown: string; + readonly readOnly: boolean; + readonly suggestionMode: boolean; + readonly handleRef: React.RefObject<PlanReviewEditorHandle | null>; + /** Cheap notification that the reviewer changed something. */ + readonly onChanged: () => void; + /** Fires when the reviewer comments on a selection. */ + readonly onAddComment: (quotedText: string, body: string) => void; + /** Reports whether Plate's markdown round trip reached a fixed point. */ + readonly onRoundTripUnstable: () => void; +} + +function PlanReviewEditorImpl({ + markdown, + readOnly, + suggestionMode, + handleRef, + onChanged, + onAddComment, + onRoundTripUnstable, +}: PlanReviewEditorProps) { + const editor = usePlateEditor({ plugins: PLAN_REVIEW_PLUGINS }); + const [pendingQuote, setPendingQuote] = useState<string | null>(null); + const [commentBody, setCommentBody] = useState(""); + const loadedMarkdownRef = useRef<string | null>(null); + // Loading a version fires Plate's onChange; that is not a reviewer edit. + const loadingRef = useRef(false); + const commentInputRef = useRef<HTMLTextAreaElement | null>(null); + const surfaceRef = useRef<HTMLDivElement | null>(null); + + // Load canonical markdown into the editor whenever the reviewed version + // changes. Guarded by the last-loaded value so our own edits do not reload. + useEffect(() => { + if (loadedMarkdownRef.current === markdown) return; + loadedMarkdownRef.current = markdown; + + try { + loadingRef.current = true; + const value = editor.api.markdown.deserialize(markdown); + editor.tf.setValue(value); + requestAnimationFrame(() => { + loadingRef.current = false; + }); + + // Round-trip check: an unstable document would make every later diff + // full of formatting noise the reviewer never typed. + const once = editor.api.markdown.serialize({ value }); + const twice = editor.api.markdown.serialize({ + value: editor.api.markdown.deserialize(once), + }); + if (once !== twice) onRoundTripUnstable(); + } catch { + loadingRef.current = false; + onRoundTripUnstable(); + } + }, [editor, markdown, onRoundTripUnstable]); + + useEffect(() => { + // Suggestion mode is a plugin option rather than editor state, so it can be + // toggled without rebuilding the editor and losing the selection. + editor.setOption(SuggestionPlugin, "isSuggesting", suggestionMode && !readOnly); + }, [editor, suggestionMode, readOnly]); + + useImperativeHandle( + handleRef, + () => ({ + getMarkdown: () => { + try { + return editor.api.markdown.serialize(); + } catch { + // A tree mid-edit can be transiently invalid; the caller falls back + // to the last known good document rather than saving nonsense. + return ""; + } + }, + }), + [editor], + ); + + const handleChange = useCallback(() => { + if (loadingRef.current) return; + onChanged(); + }, [onChanged]); + + const startComment = useCallback(() => { + const quote = normalizeQuotedText(window.getSelection()?.toString() ?? ""); + if (quote.length === 0) return; + setPendingQuote(quote); + setCommentBody(""); + requestAnimationFrame(() => commentInputRef.current?.focus()); + }, []); + + const submitComment = useCallback(() => { + if (pendingQuote === null) return; + const body = commentBody.trim(); + if (body.length === 0) return; + onAddComment(pendingQuote, body); + setPendingQuote(null); + setCommentBody(""); + }, [commentBody, onAddComment, pendingQuote]); + + return ( + <div className="relative flex min-h-0 flex-1 flex-col"> + <div + ref={surfaceRef} + className="relative min-h-0 flex-1 cursor-text select-text overflow-y-auto caret-primary selection:bg-primary/25" + > + <Plate editor={editor} onChange={handleChange}> + <PlateContent + className={cn( + "min-h-full px-5 pt-3 pb-24 text-[15px] text-foreground leading-relaxed outline-none", + )} + readOnly={readOnly} + placeholder="This plan is empty." + aria-label="Plan document" + /> + </Plate> + <PlanReviewFloatingToolbar + containerRef={surfaceRef} + onComment={startComment} + readOnly={readOnly} + /> + </div> + + {pendingQuote !== null ? ( + <div className="border-t bg-card p-3"> + <blockquote className="mb-2 border-primary/40 border-l-2 pl-2 text-muted-foreground text-xs italic"> + {pendingQuote} + </blockquote> + <textarea + ref={commentInputRef} + className="w-full resize-y rounded-md border bg-background p-2 text-sm" + rows={3} + value={commentBody} + placeholder="What should change here?" + aria-label="Comment body" + onChange={(event) => setCommentBody(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") setPendingQuote(null); + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) submitComment(); + }} + /> + <div className="mt-2 flex justify-end gap-2"> + <Button size="sm" variant="ghost" onClick={() => setPendingQuote(null)}> + Cancel + </Button> + <Button size="sm" onClick={submitComment} disabled={commentBody.trim().length === 0}> + Add comment + </Button> + </div> + </div> + ) : null} + </div> + ); +} + +export const PlanReviewEditor = memo(PlanReviewEditorImpl); diff --git a/apps/web/src/components/planreview/PlanReviewHtmlView.tsx b/apps/web/src/components/planreview/PlanReviewHtmlView.tsx new file mode 100644 index 00000000000..8233fb7e4d5 --- /dev/null +++ b/apps/web/src/components/planreview/PlanReviewHtmlView.tsx @@ -0,0 +1,51 @@ +/** + * T3-CUSTOM(expbkt3): read-only surface for an HTML plan. + * + * Providers sometimes emit the whole plan as a styled HTML document — charts, + * animations and all. Rendering that inside the editor is not possible and not + * desirable, so it goes into a sandboxed iframe instead. The reviewer can still + * approve or send feedback; anchored comments inside the HTML need a + * postMessage bridge and are deliberately not here yet. + */ +import { memo, useMemo } from "react"; + +interface PlanReviewHtmlViewProps { + readonly html: string; + readonly title: string; +} + +function PlanReviewHtmlViewImpl({ html, title }: PlanReviewHtmlViewProps) { + // `srcDoc` on an unmodified sandbox gives the document an opaque origin: its + // scripts run so charts and animations work, but it cannot reach this page, + // our cookies, or the network as us. + const srcDoc = useMemo(() => { + const hasDocumentShell = /<html[\s>]/i.test(html); + if (hasDocumentShell) return html; + return [ + "<!doctype html>", + '<html><head><meta charset="utf-8">', + '<meta name="viewport" content="width=device-width, initial-scale=1">', + "<style>body{margin:0;padding:16px;font-family:system-ui,sans-serif}</style>", + "</head><body>", + html, + "</body></html>", + ].join(""); + }, [html]); + + return ( + <div className="flex min-h-0 flex-1 flex-col"> + <iframe + // Keying on the content forces a fresh document when the version + // changes; iframes do not re-run scripts on a srcDoc mutation alone. + key={srcDoc.length} + title={`${title} (HTML plan)`} + srcDoc={srcDoc} + className="min-h-0 w-full flex-1 border-0 bg-white" + sandbox="allow-scripts" + referrerPolicy="no-referrer" + /> + </div> + ); +} + +export const PlanReviewHtmlView = memo(PlanReviewHtmlViewImpl); diff --git a/apps/web/src/components/planreview/PlanReviewPanel.tsx b/apps/web/src/components/planreview/PlanReviewPanel.tsx new file mode 100644 index 00000000000..c7c055cae0b --- /dev/null +++ b/apps/web/src/components/planreview/PlanReviewPanel.tsx @@ -0,0 +1,469 @@ +/** + * T3-CUSTOM(expbkt3): the native plan review panel. + * + * Default export so `ChatView` can `lazy()` it — Plate is ~200 kB gzip and must + * not land in the main chunk. Everything the panel needs arrives over the fork + * `planReview.*` RPCs; the live subscription keeps every open client converged. + */ +import type { + EnvironmentId, + PlanReviewSnapshotResult, + PlanReviewVersion, +} from "@t3tools/contracts"; +import { CheckIcon, HistoryIcon, MessageSquareIcon, SendIcon, Trash2Icon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { PlanReviewDiscussions } from "./PlanReviewDiscussions"; +import { PlanReviewEditor, type PlanReviewEditorHandle } from "./PlanReviewEditor"; +import { PlanReviewHtmlView } from "./PlanReviewHtmlView"; +import { PlanReviewVersions } from "./PlanReviewVersions"; +import { nextPlanDiscussionId } from "./planReviewMarkdown"; +import { Button } from "../ui/button"; +import { planReviewEnvironment } from "../../state/planReview"; +import { toastManager } from "../ui/toast"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useCurrentUserId } from "../../state/identity"; +import { useEnvironmentQuery } from "../../state/query"; + +interface PlanReviewPanelProps { + readonly environmentId: EnvironmentId; + readonly documentId: string; + readonly onClose: () => void; +} + +type PanelTab = "review" | "versions"; + +const DRAFT_SAVE_DEBOUNCE_MS = 500; + +export default function PlanReviewPanel({ + environmentId, + documentId, + onClose, +}: PlanReviewPanelProps) { + const [tab, setTab] = useState<PanelTab>("review"); + const [suggestionMode, setSuggestionMode] = useState(true); + const [globalComment, setGlobalComment] = useState(""); + // A boolean, not the document: keeping the markdown in state would re-render + // the panel — and the editor beneath it — on every keystroke. + const [hasLocalEdits, setHasLocalEdits] = useState(false); + const [roundTripWarning, setRoundTripWarning] = useState(false); + const [comparison, setComparison] = useState<{ from: string; to: string } | null>(null); + + const revisionTokenRef = useRef<string | null>(null); + const editedMarkdownRef = useRef<string | null>(null); + const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const editorHandleRef = useRef<PlanReviewEditorHandle | null>(null); + // Read inside the save callback without making it depend on every snapshot. + const latestDraftRef = useRef<PlanReviewSnapshotResult["draft"]>(null); + + const initial = useEnvironmentQuery( + planReviewEnvironment.review({ environmentId, input: { documentId } }), + ); + const live = useEnvironmentQuery( + planReviewEnvironment.subscription({ environmentId, input: { documentId } }), + ); + + // The subscription supersedes the one-shot read as soon as it produces a + // frame, so the panel never renders stale state after another client writes. + const snapshot: PlanReviewSnapshotResult | null = live.data ?? initial.data ?? null; + + const diffQuery = useEnvironmentQuery( + comparison === null + ? null + : planReviewEnvironment.versionDiff({ + environmentId, + input: { + documentId, + fromVersionId: comparison.from, + toVersionId: comparison.to, + }, + }), + ); + + const saveDraft = useAtomCommand(planReviewEnvironment.saveDraft, { reportFailure: false }); + const upsertDiscussion = useAtomCommand(planReviewEnvironment.upsertDiscussion); + const resolveDiscussion = useAtomCommand(planReviewEnvironment.resolveDiscussion); + const cutVersion = useAtomCommand(planReviewEnvironment.cutVersion); + const submit = useAtomCommand(planReviewEnvironment.submit); + + const latestVersion = useMemo(() => snapshot?.versions.at(-1) ?? null, [snapshot?.versions]); + + const viewerUserId = useCurrentUserId(); + + // Adopt a token only from our own save. Taking whatever the last writer + // produced would make the next save look valid and silently overwrite them. + useEffect(() => { + latestDraftRef.current = snapshot?.draft ?? null; + if (revisionTokenRef.current === null && snapshot?.draft) { + revisionTokenRef.current = snapshot.draft.revisionToken; + } + }, [snapshot?.draft]); + + // A resolved review is history: it stays readable, but nothing can be sent + // from it twice. + const isResolved = snapshot !== null && snapshot.document.status !== "open"; + const versionMarkdown = latestVersion?.contentMarkdown ?? ""; + + // A saved draft is what this reviewer was last working on, so it — not the + // committed version — is what the editor should reopen with. Without this the + // panel silently discards unsaved edits on every close or tab switch. + const draftMarkdown = useMemo(() => { + if (!snapshot?.draft || snapshot.draft.baseVersionId !== latestVersion?.versionId) return null; + try { + const parsed: unknown = JSON.parse(snapshot.draft.contentValueJson); + const markdown = (parsed as { markdown?: unknown }).markdown; + return typeof markdown === "string" && markdown.trim().length > 0 ? markdown : null; + } catch { + return null; + } + }, [snapshot?.draft, latestVersion?.versionId]); + + // Seeded once per version: re-seeding from the live draft on every frame + // would fight the reviewer's cursor. + const [seededDraft, setSeededDraft] = useState<string | null>(null); + const seededForVersionRef = useRef<string | null>(null); + useEffect(() => { + const versionId = latestVersion?.versionId ?? null; + if (seededForVersionRef.current === versionId) return; + seededForVersionRef.current = versionId; + setSeededDraft(draftMarkdown); + }, [draftMarkdown, latestVersion?.versionId]); + + const canonicalMarkdown = seededDraft ?? versionMarkdown; + // A restored draft is already ahead of the committed version, so the panel + // opens dirty even before the reviewer types. + const isDirty = + hasLocalEdits || (seededDraft?.trim() ?? versionMarkdown.trim()) !== versionMarkdown.trim(); + + /** + * Typing must not serialize the document or re-render the panel. It flips one + * boolean the first time and schedules the debounced save, which is the only + * place the markdown is actually pulled out of the editor. + */ + const handleEditorChanged = useCallback(() => { + setHasLocalEdits(true); + if (draftTimerRef.current !== null) clearTimeout(draftTimerRef.current); + draftTimerRef.current = setTimeout(() => { + const markdown = editorHandleRef.current?.getMarkdown() ?? ""; + if (markdown.trim().length === 0) return; + editedMarkdownRef.current = markdown; + + void saveDraft({ + environmentId, + input: { + documentId, + contentValueJson: JSON.stringify({ markdown }), + expectedRevisionToken: revisionTokenRef.current, + }, + }).then((result) => { + if (result._tag === "Success") { + revisionTokenRef.current = result.value.revisionToken; + return; + } + + // A rejected save only means somebody *else* is editing when the draft + // on the server belongs to somebody else. Our own saves can land out of + // order — that is a token to catch up on, not a conflict to report. + const draft = latestDraftRef.current; + const owner = draft?.updatedByUserId ?? null; + const isOurs = owner === null || owner === viewerUserId; + if (isOurs) { + if (draft) revisionTokenRef.current = draft.revisionToken; + return; + } + + toastManager.add({ + type: "error", + title: "Someone else edited this plan", + description: "Reload the panel to pick up their changes before saving again.", + }); + }); + }, DRAFT_SAVE_DEBOUNCE_MS); + }, [documentId, environmentId, saveDraft, viewerUserId]); + + // Stable identity: an inline arrow here would defeat the editor's `memo` and + // re-render the whole Plate tree on every panel state change. + const handleRoundTripUnstable = useCallback(() => setRoundTripWarning(true), []); + + /** The document as it stands, pulled from the editor only when needed. */ + const readCurrentMarkdown = useCallback( + () => editorHandleRef.current?.getMarkdown() || editedMarkdownRef.current || canonicalMarkdown, + [canonicalMarkdown], + ); + + useEffect( + () => () => { + if (draftTimerRef.current !== null) clearTimeout(draftTimerRef.current); + }, + [], + ); + + const handleAddComment = useCallback( + (quotedText: string, body: string) => { + void upsertDiscussion({ + environmentId, + input: { + documentId, + discussionId: nextPlanDiscussionId(), + quotedText, + bodyMarkdown: body, + }, + }); + }, + [documentId, environmentId, upsertDiscussion], + ); + + const handleResolve = useCallback( + (discussionId: string, isResolvedNext: boolean) => { + void resolveDiscussion({ + environmentId, + input: { documentId, discussionId, isResolved: isResolvedNext }, + }); + }, + [documentId, environmentId, resolveDiscussion], + ); + + const handleSaveVersion = useCallback(() => { + if (!isDirty) return; + const contentMarkdown = readCurrentMarkdown(); + void cutVersion({ + environmentId, + input: { + documentId, + contentMarkdown, + contentValueJson: null, + summary: null, + }, + }).then((result) => { + if (result._tag === "Success") { + setHasLocalEdits(false); + editedMarkdownRef.current = null; + toastManager.add({ type: "success", title: "Saved a new version of the plan" }); + } + }); + }, [cutVersion, documentId, environmentId, isDirty, readCurrentMarkdown]); + + const handleRestore = useCallback( + (version: PlanReviewVersion) => { + // Restoring appends rather than rewriting: history stays append-only. + void cutVersion({ + environmentId, + input: { + documentId, + contentMarkdown: version.contentMarkdown, + contentValueJson: null, + summary: `Restored v${version.revision}`, + }, + }).then((result) => { + if (result._tag === "Success") { + setHasLocalEdits(false); + editedMarkdownRef.current = null; + setTab("review"); + } + }); + }, + [cutVersion, documentId, environmentId], + ); + + const handleSubmit = useCallback( + (decision: "approved" | "changes-requested" | "discarded") => { + void submit({ + environmentId, + input: { + documentId, + decision, + globalComment, + editedMarkdown: isDirty ? readCurrentMarkdown() : null, + }, + }).then((result) => { + if (result._tag !== "Success") return; + setGlobalComment(""); + setHasLocalEdits(false); + editedMarkdownRef.current = null; + if (decision === "approved") { + toastManager.add({ + type: "success", + title: "Plan approved", + description: result.value.resentPlan + ? "The full plan was re-sent because this session lost it from context." + : "Implementation started.", + }); + } else if (decision === "changes-requested") { + toastManager.add({ type: "success", title: "Feedback sent to the planning agent" }); + } + onClose(); + }); + }, + [documentId, environmentId, globalComment, isDirty, onClose, readCurrentMarkdown, submit], + ); + + if (snapshot === null) { + return ( + <div className="flex min-h-0 flex-1 items-center justify-center p-4"> + <p className="text-muted-foreground text-sm"> + {initial.error ? "This plan review could not be loaded." : "Loading plan…"} + </p> + </div> + ); + } + + const openDiscussionCount = snapshot.discussions.filter( + (discussion) => !discussion.isResolved, + ).length; + + const isHtmlPlan = snapshot.document.format === "html"; + + return ( + /* + The document column owns the full height of the panel. Every control — + the view switch, the notes box and the decisions — lives in the rail, so + reading the plan is never traded against chrome. This is the layout the + reviewer asked for after living with a header and a footer eating ~150px + of a tall, narrow panel. + */ + <div className="flex min-h-0 min-w-0 flex-1 flex-row bg-background"> + <main className="flex min-h-0 min-w-0 flex-1 flex-col"> + {roundTripWarning && !isHtmlPlan ? ( + <p className="border-amber-500/40 border-b bg-amber-500/10 px-3 py-1.5 text-amber-800 text-xs dark:text-amber-300"> + This plan uses markdown the editor cannot reproduce exactly. Edits may reformat parts of + it — check the diff before sending. + </p> + ) : null} + + {tab === "versions" ? ( + <PlanReviewVersions + versions={snapshot.versions} + diff={diffQuery.data?.diff ?? null} + isDiffPending={comparison !== null && diffQuery.isPending} + canRestore={!isResolved && !isHtmlPlan} + onCompare={(from, to) => setComparison({ from, to })} + onRestore={handleRestore} + /> + ) : isHtmlPlan ? ( + <PlanReviewHtmlView html={canonicalMarkdown} title={snapshot.document.title} /> + ) : ( + <PlanReviewEditor + markdown={canonicalMarkdown} + readOnly={isResolved} + suggestionMode={suggestionMode} + handleRef={editorHandleRef} + onChanged={handleEditorChanged} + onAddComment={handleAddComment} + onRoundTripUnstable={handleRoundTripUnstable} + /> + )} + </main> + + <aside + className="flex min-h-0 w-72 shrink-0 flex-col border-l" + aria-label="Plan review controls" + > + <nav className="flex items-center gap-1 border-b px-2 py-1"> + <Button + size="sm" + variant={tab === "review" ? "secondary" : "ghost"} + onClick={() => setTab("review")} + title={snapshot.document.title} + > + <MessageSquareIcon className="size-3.5" aria-hidden /> Review + {openDiscussionCount > 0 ? ( + <span className="ml-1 rounded-full bg-primary/15 px-1.5 text-[11px] tabular-nums"> + {openDiscussionCount} + </span> + ) : null} + </Button> + <Button + size="sm" + variant={tab === "versions" ? "secondary" : "ghost"} + onClick={() => setTab("versions")} + > + <HistoryIcon className="size-3.5" aria-hidden /> v{snapshot.document.currentRevision} + </Button> + {isResolved ? ( + <span className="ml-auto shrink-0 rounded-full bg-muted px-2 py-0.5 text-muted-foreground text-[11px]"> + {snapshot.document.status === "approved" ? "Approved" : snapshot.document.status} + </span> + ) : null} + </nav> + + {!isResolved && !isHtmlPlan ? ( + <label + className="flex items-center gap-1.5 border-b px-3 py-1.5 text-xs" + title="Record your edits as tracked suggestions instead of editing in place" + > + <input + type="checkbox" + checked={suggestionMode} + onChange={(event) => setSuggestionMode(event.target.checked)} + /> + Suggest edits + </label> + ) : null} + + <div className="min-h-0 flex-1 overflow-auto"> + {isHtmlPlan ? ( + <p className="p-4 text-muted-foreground text-xs"> + This plan is an HTML document, so it is shown as the agent rendered it. Use the notes + below to send feedback. + </p> + ) : ( + <PlanReviewDiscussions + discussions={snapshot.discussions} + comments={snapshot.comments} + onResolve={handleResolve} + disabled={isResolved} + /> + )} + </div> + + {isResolved ? null : ( + <div className="border-t p-2"> + <textarea + className="mb-2 w-full resize-y rounded-md border bg-background p-2 text-sm" + rows={3} + value={globalComment} + placeholder="Overall notes for the agent (optional)" + aria-label="Overall review notes" + onChange={(event) => setGlobalComment(event.target.value)} + /> + <div className="flex flex-col gap-1.5"> + <Button size="sm" onClick={() => handleSubmit("approved")}> + <CheckIcon className="size-3.5" aria-hidden /> Approve + </Button> + <Button + size="sm" + variant="outline" + onClick={() => handleSubmit("changes-requested")} + disabled={ + openDiscussionCount === 0 && globalComment.trim().length === 0 && !isDirty + } + > + <SendIcon className="size-3.5" aria-hidden /> Send feedback + </Button> + <div className="flex items-center gap-1"> + <Button + size="sm" + variant="ghost" + className="flex-1" + onClick={handleSaveVersion} + disabled={!isDirty || isHtmlPlan} + > + Save version + </Button> + <Button + size="sm" + variant="ghost" + className="text-destructive" + onClick={() => handleSubmit("discarded")} + aria-label="Discard this review" + > + <Trash2Icon className="size-3.5" aria-hidden /> + </Button> + </div> + </div> + </div> + )} + </aside> + </div> + ); +} diff --git a/apps/web/src/components/planreview/PlanReviewVersions.tsx b/apps/web/src/components/planreview/PlanReviewVersions.tsx new file mode 100644 index 00000000000..e3f4fd6f9c8 --- /dev/null +++ b/apps/web/src/components/planreview/PlanReviewVersions.tsx @@ -0,0 +1,172 @@ +/** + * T3-CUSTOM(expbkt3): version history for a plan document. + * + * Agent and human revisions interleave in one list, each with its author, so + * "who changed this plan, and what did it look like before" is answerable + * without leaving the panel. + */ +import type { PlanReviewVersion } from "@t3tools/contracts"; +import { memo, useMemo, useState } from "react"; + +import { Avatar, userDisplayName } from "../ui/avatar"; +import { Button } from "../ui/button"; +import { cn } from "../../lib/utils"; +import { useCurrentUserId } from "../../state/identity"; +import { useOrgMembers } from "../../state/orgMembers"; + +interface PlanReviewVersionsProps { + readonly versions: ReadonlyArray<PlanReviewVersion>; + readonly diff: string | null; + readonly isDiffPending: boolean; + readonly onCompare: (fromVersionId: string, toVersionId: string) => void; + readonly onRestore: (version: PlanReviewVersion) => void; + readonly canRestore: boolean; +} + +const ORIGIN_LABEL: Record<PlanReviewVersion["origin"], string> = { + "agent-proposed": "Proposed", + "agent-revision": "Revised", + "human-edit": "Edited", +}; + +function formatTimestamp(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function PlanReviewVersionsImpl({ + versions, + diff, + isDiffPending, + onCompare, + onRestore, + canRestore, +}: PlanReviewVersionsProps) { + const { resolveUser } = useOrgMembers(); + const viewerUserId = useCurrentUserId(); + const [selected, setSelected] = useState<ReadonlyArray<string>>([]); + + const ordered = useMemo(() => versions.toReversed(), [versions]); + + const toggleSelection = (versionId: string) => { + setSelected((current) => { + if (current.includes(versionId)) return current.filter((id) => id !== versionId); + // Keep at most two selected; the older one is always the diff base. + const next = [...current, versionId].slice(-2); + if (next.length === 2) { + const fromIndex = versions.findIndex((version) => version.versionId === next[0]); + const toIndex = versions.findIndex((version) => version.versionId === next[1]); + const [from, to] = fromIndex <= toIndex ? [next[0]!, next[1]!] : [next[1]!, next[0]!]; + onCompare(from, to); + } + return next; + }); + }; + + if (versions.length === 0) { + return <p className="p-4 text-muted-foreground text-sm">This plan has no versions yet.</p>; + } + + return ( + <div className="flex min-h-0 flex-1 flex-col overflow-auto"> + <p className="px-4 pt-3 pb-1 text-muted-foreground text-xs"> + Select two versions to compare them. + </p> + <ul className="flex flex-col gap-1 px-2 pb-2"> + {ordered.map((version) => { + const isSelected = selected.includes(version.versionId); + const author = + version.authorKind === "agent" + ? "Agent" + : version.authorUserId === null || version.authorUserId === viewerUserId + ? "You" + : userDisplayName(resolveUser(version.authorUserId)); + + return ( + <li key={version.versionId}> + <div + className={cn( + "flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm", + isSelected ? "border-primary bg-accent/40" : "border-transparent", + )} + > + <button + type="button" + className="flex min-w-0 flex-1 items-center gap-2 text-left" + aria-pressed={isSelected} + onClick={() => toggleSelection(version.versionId)} + > + {version.authorKind === "user" && version.authorUserId !== null ? ( + <Avatar user={resolveUser(version.authorUserId)} size="xs" /> + ) : ( + <span + aria-hidden + className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] text-muted-foreground" + > + AI + </span> + )} + <span className="shrink-0 font-medium tabular-nums">v{version.revision}</span> + <span className="truncate text-muted-foreground"> + {ORIGIN_LABEL[version.origin]} by {author} + </span> + <span className="ml-auto shrink-0 text-muted-foreground text-xs"> + {formatTimestamp(version.createdAt)} + </span> + </button> + {canRestore ? ( + <Button + size="sm" + variant="ghost" + onClick={() => onRestore(version)} + aria-label={`Restore version ${version.revision}`} + > + Restore + </Button> + ) : null} + </div> + </li> + ); + })} + </ul> + + {isDiffPending ? ( + <p className="px-4 py-2 text-muted-foreground text-xs">Loading diff…</p> + ) : diff && diff.trim().length > 0 ? ( + <pre className="mx-2 mb-2 overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed"> + {diff + .split("\n") + .filter( + (line) => + !line.startsWith("diff --git") && + !line.startsWith("---") && + !line.startsWith("+++"), + ) + .map((line, index) => ({ line, key: `${index}:${line}` })) + .map(({ line, key }) => ( + <div + key={key} + className={cn( + line.startsWith("+") && "text-green-600 dark:text-green-400", + line.startsWith("-") && "text-red-600 dark:text-red-400", + line.startsWith("@@") && "text-muted-foreground", + )} + > + {line === "" ? " " : line} + </div> + ))} + </pre> + ) : selected.length === 2 ? ( + <p className="px-4 py-2 text-muted-foreground text-xs">These two versions are identical.</p> + ) : null} + </div> + ); +} + +export const PlanReviewVersions = memo(PlanReviewVersionsImpl); diff --git a/apps/web/src/components/planreview/planReviewMarkdown.ts b/apps/web/src/components/planreview/planReviewMarkdown.ts new file mode 100644 index 00000000000..8b2db4ba67e --- /dev/null +++ b/apps/web/src/components/planreview/planReviewMarkdown.ts @@ -0,0 +1,29 @@ +/** + * T3-CUSTOM(expbkt3): markdown helpers for the plan review editor. + * + * Markdown is the canonical form: it is what the agent reads, what versions + * store, and what diffs are computed from. The Plate value is a working cache. + * The round trip itself lives in `PlanReviewEditor`, where the typed editor + * instance is in scope. + */ + +let planReviewIdSequence = 0; + +/** + * Client-side id for a new discussion. Ids only need to be unique within a + * session — the server owns durable identity. + */ +export function nextPlanDiscussionId(): string { + planReviewIdSequence += 1; + return `plan-discussion:${Date.now()}-${planReviewIdSequence}`; +} + +/** Collapses whitespace so quoted anchors survive editor reformatting. */ +export function normalizeQuotedText(value: string): string { + return value + .replaceAll("\r\n", "\n") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join("\n"); +} diff --git a/apps/web/src/components/planreview/plate/PlanReviewFloatingToolbar.tsx b/apps/web/src/components/planreview/plate/PlanReviewFloatingToolbar.tsx new file mode 100644 index 00000000000..479f489fc35 --- /dev/null +++ b/apps/web/src/components/planreview/plate/PlanReviewFloatingToolbar.tsx @@ -0,0 +1,158 @@ +/** + * T3-CUSTOM(expbkt3): the selection toolbar that makes the panel feel like a + * document rather than a textarea. + * + * Anchored to the DOM selection rectangle rather than pulled in through + * `@platejs/floating`: the panel is the only consumer, and one `getBoundingRect` + * is far less weight than another positioning dependency. + */ +import { + BoldPlugin, + CodePlugin, + HighlightPlugin, + ItalicPlugin, + StrikethroughPlugin, + UnderlinePlugin, +} from "@platejs/basic-nodes/react"; +import { + BoldIcon, + CodeIcon, + HighlighterIcon, + ItalicIcon, + MessageSquarePlusIcon, + StrikethroughIcon, + UnderlineIcon, +} from "lucide-react"; +import { useMarkToolbarButton, useMarkToolbarButtonState } from "platejs/react"; +import { useCallback, useEffect, useState, type ComponentType } from "react"; + +import { cn } from "../../../lib/utils"; + +interface ToolbarPosition { + readonly top: number; + readonly left: number; +} + +function MarkButton({ + nodeType, + label, + icon: Icon, +}: { + readonly nodeType: string; + readonly label: string; + readonly icon: ComponentType<{ className?: string }>; +}) { + const state = useMarkToolbarButtonState({ nodeType }); + const { props } = useMarkToolbarButton(state); + + return ( + <button + type="button" + aria-label={label} + aria-pressed={props.pressed} + title={label} + onClick={props.onClick} + onMouseDown={props.onMouseDown} + className={cn( + "flex size-7 items-center justify-center rounded transition-colors hover:bg-accent", + props.pressed && "bg-accent text-accent-foreground", + )} + > + <Icon className="size-4" /> + </button> + ); +} + +export function PlanReviewFloatingToolbar({ + containerRef, + onComment, + readOnly, +}: { + readonly containerRef: React.RefObject<HTMLElement | null>; + readonly onComment: () => void; + readonly readOnly: boolean; +}) { + const [position, setPosition] = useState<ToolbarPosition | null>(null); + + const syncToSelection = useCallback(() => { + const container = containerRef.current; + if (container === null) return setPosition(null); + + const selection = window.getSelection(); + if ( + !selection || + selection.isCollapsed || + selection.rangeCount === 0 || + selection.toString().trim().length === 0 + ) { + return setPosition(null); + } + + const range = selection.getRangeAt(0); + if (!container.contains(range.commonAncestorContainer)) return setPosition(null); + + const rect = range.getBoundingClientRect(); + const bounds = container.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return setPosition(null); + + setPosition({ + // Sit just above the selection, clamped inside the panel so the toolbar + // never escapes a narrow right-hand panel. + top: Math.max(4, rect.top - bounds.top + container.scrollTop - 44), + left: Math.min( + Math.max(4, rect.left - bounds.left + rect.width / 2), + Math.max(4, bounds.width - 4), + ), + }); + }, [containerRef]); + + useEffect(() => { + document.addEventListener("selectionchange", syncToSelection); + window.addEventListener("resize", syncToSelection); + return () => { + document.removeEventListener("selectionchange", syncToSelection); + window.removeEventListener("resize", syncToSelection); + }; + }, [syncToSelection]); + + if (position === null) return null; + + return ( + <div + className="pointer-events-auto absolute z-30 -translate-x-1/2 rounded-lg border bg-popover p-0.5 shadow-lg" + style={{ top: position.top, left: position.left }} + // Keep the selection alive: losing it would clear the toolbar mid-click. + onMouseDown={(event) => event.preventDefault()} + role="toolbar" + aria-label="Formatting" + > + <div className="flex items-center gap-0.5"> + {readOnly ? null : ( + <> + <MarkButton nodeType={BoldPlugin.key} label="Bold" icon={BoldIcon} /> + <MarkButton nodeType={ItalicPlugin.key} label="Italic" icon={ItalicIcon} /> + <MarkButton nodeType={UnderlinePlugin.key} label="Underline" icon={UnderlineIcon} /> + <MarkButton + nodeType={StrikethroughPlugin.key} + label="Strikethrough" + icon={StrikethroughIcon} + /> + <MarkButton nodeType={HighlightPlugin.key} label="Highlight" icon={HighlighterIcon} /> + <MarkButton nodeType={CodePlugin.key} label="Inline code" icon={CodeIcon} /> + <span aria-hidden className="mx-0.5 h-5 w-px bg-border" /> + </> + )} + <button + type="button" + aria-label="Comment on selection" + title="Comment on selection" + onClick={onComment} + className="flex h-7 items-center gap-1.5 rounded px-2 text-xs transition-colors hover:bg-accent" + > + <MessageSquarePlusIcon className="size-4" /> + Comment + </button> + </div> + </div> + ); +} diff --git a/apps/web/src/components/planreview/plate/PlanReviewMermaid.tsx b/apps/web/src/components/planreview/plate/PlanReviewMermaid.tsx new file mode 100644 index 00000000000..d3c73c8d884 --- /dev/null +++ b/apps/web/src/components/planreview/plate/PlanReviewMermaid.tsx @@ -0,0 +1,113 @@ +/** + * T3-CUSTOM(expbkt3): render mermaid fences as diagrams inside the plan. + * + * Agents draw architecture and sequence diagrams constantly; reading them as + * source defeats the point. Mermaid is loaded lazily so the panel chunk does + * not carry a diagram engine for plans that have no diagrams, and rendering is + * cancel-guarded because a plan can be reloaded mid-render. + */ +import { AlertTriangleIcon, CodeIcon, WorkflowIcon } from "lucide-react"; +import { memo, useEffect, useId, useRef, useState } from "react"; + +import { Button } from "../../ui/button"; +import { cn } from "../../../lib/utils"; + +/** Mermaid mutates global state, so one initialization per page is enough. */ +let mermaidReady: Promise<typeof import("mermaid").default> | null = null; + +function loadMermaid(isDark: boolean) { + mermaidReady ??= import("mermaid").then((module) => { + module.default.initialize({ + startOnLoad: false, + // The plan is authored by an agent, not a trusted human, so never let a + // diagram inject markup or scripts. + securityLevel: "strict", + theme: isDark ? "dark" : "default", + flowchart: { htmlLabels: true, curve: "basis" }, + }); + return module.default; + }); + return mermaidReady; +} + +interface PlanReviewMermaidProps { + readonly code: string; + readonly isDark: boolean; +} + +function PlanReviewMermaidImpl({ code, isDark }: PlanReviewMermaidProps) { + const [svg, setSvg] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); + const [showSource, setShowSource] = useState(false); + const reactId = useId(); + // Mermaid needs a DOM-safe id; React's contains colons. + const renderId = useRef(`mermaid-${reactId.replace(/[^a-zA-Z0-9]/g, "")}`); + + useEffect(() => { + let cancelled = false; + const trimmed = code.trim(); + if (trimmed.length === 0) { + setSvg(null); + setError(null); + return; + } + + void loadMermaid(isDark) + .then((mermaid) => mermaid.render(renderId.current, trimmed)) + .then(({ svg: rendered }) => { + if (cancelled) return; + setSvg(rendered); + setError(null); + }) + .catch((cause: unknown) => { + if (cancelled) return; + setSvg(null); + setError(cause instanceof Error ? cause.message : "This diagram could not be rendered."); + }); + + return () => { + cancelled = true; + }; + }, [code, isDark]); + + return ( + <div className="my-3 overflow-hidden rounded-md border bg-muted/30"> + <div className="flex items-center gap-1 border-b bg-muted/40 px-2 py-1"> + <WorkflowIcon className="size-3.5 text-muted-foreground" aria-hidden /> + <span className="text-muted-foreground text-xs">Diagram</span> + <Button + size="sm" + variant="ghost" + className="ml-auto h-6 px-2 text-xs" + onClick={() => setShowSource((value) => !value)} + > + <CodeIcon className="size-3.5" aria-hidden /> + {showSource ? "Diagram" : "Source"} + </Button> + </div> + + {showSource || error !== null ? ( + <div className="p-3"> + {error !== null ? ( + <p className="mb-2 flex items-start gap-1.5 text-amber-600 text-xs dark:text-amber-400"> + <AlertTriangleIcon className="mt-px size-3.5 shrink-0" aria-hidden /> + {error} + </p> + ) : null} + <pre className="overflow-x-auto font-mono text-[13px] leading-relaxed">{code}</pre> + </div> + ) : svg === null ? ( + <p className="p-3 text-muted-foreground text-xs">Rendering diagram…</p> + ) : ( + <div + className={cn("overflow-x-auto p-3", "[&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full")} + // Mermaid runs with securityLevel "strict", which strips scripts and + // event handlers from the SVG it returns. + dangerouslySetInnerHTML={{ __html: svg }} + /> + )} + </div> + ); +} + +export const PlanReviewMermaid = memo(PlanReviewMermaidImpl); diff --git a/apps/web/src/components/planreview/plate/PlanReviewNodes.tsx b/apps/web/src/components/planreview/plate/PlanReviewNodes.tsx new file mode 100644 index 00000000000..de7f11144a2 --- /dev/null +++ b/apps/web/src/components/planreview/plate/PlanReviewNodes.tsx @@ -0,0 +1,303 @@ +/** + * T3-CUSTOM(expbkt3): rendered node components for the plan review editor. + * + * Plate registers node *types*; without a component per type every block falls + * back to an unstyled div, which is why the first cut rendered headings at body + * size. These are adapted from Plate's MIT registry components, restyled onto + * this app's design tokens rather than the registry's own theme — we have no + * `@tailwindcss/typography`, so the hierarchy is spelled out here. + */ +import { useTodoListElement, useTodoListElementState } from "@platejs/list-classic/react"; +import { type VariantProps, cva } from "class-variance-authority"; +import { + PlateElement, + PlateLeaf, + type PlateElementProps, + type PlateLeafProps, +} from "platejs/react"; +import { Children } from "react"; + +import { Checkbox } from "../../ui/checkbox"; +import { cn } from "../../../lib/utils"; +import { useTheme } from "../../../hooks/useTheme"; +import { PlanReviewMermaid } from "./PlanReviewMermaid"; + +/** Plate keeps a code block's fence language on the element as `lang`. */ +function codeBlockLanguage(element: unknown): string { + const lang = (element as { lang?: unknown }).lang; + return typeof lang === "string" ? lang.trim().toLowerCase() : ""; +} + +/** Slate stores text in leaves; a code block's source is its descendant text. */ +function codeBlockText(element: unknown): string { + const collect = (node: unknown): string => { + if (typeof (node as { text?: unknown }).text === "string") { + return (node as { text: string }).text; + } + const children = (node as { children?: unknown }).children; + return Array.isArray(children) ? children.map(collect).join("\n") : ""; + }; + const children = (element as { children?: unknown }).children; + return Array.isArray(children) ? children.map(collect).join("\n") : ""; +} + +const headingVariants = cva("relative font-semibold text-foreground", { + variants: { + variant: { + h1: "mt-6 mb-2 text-2xl leading-tight first:mt-0", + h2: "mt-6 mb-2 text-xl leading-tight first:mt-0", + h3: "mt-5 mb-1.5 text-lg leading-snug first:mt-0", + h4: "mt-4 mb-1 text-base first:mt-0", + h5: "mt-4 mb-1 text-sm first:mt-0", + h6: "mt-4 mb-1 text-muted-foreground text-sm uppercase tracking-wide first:mt-0", + }, + }, +}); + +function HeadingElement({ + variant = "h1", + ...props +}: PlateElementProps & VariantProps<typeof headingVariants>) { + return ( + <PlateElement as={variant ?? "h1"} className={headingVariants({ variant })} {...props}> + {props.children} + </PlateElement> + ); +} + +export const H1Element = (props: PlateElementProps) => <HeadingElement variant="h1" {...props} />; +export const H2Element = (props: PlateElementProps) => <HeadingElement variant="h2" {...props} />; +export const H3Element = (props: PlateElementProps) => <HeadingElement variant="h3" {...props} />; +export const H4Element = (props: PlateElementProps) => <HeadingElement variant="h4" {...props} />; +export const H5Element = (props: PlateElementProps) => <HeadingElement variant="h5" {...props} />; +export const H6Element = (props: PlateElementProps) => <HeadingElement variant="h6" {...props} />; + +export function ParagraphElement(props: PlateElementProps) { + return ( + <PlateElement className="my-1.5 text-[15px] leading-relaxed" {...props}> + {props.children} + </PlateElement> + ); +} + +export function BlockquoteElement(props: PlateElementProps) { + return ( + <PlateElement + as="blockquote" + className="my-3 border-primary/40 border-l-2 py-0.5 pl-4 text-muted-foreground italic" + {...props} + > + {props.children} + </PlateElement> + ); +} + +export function HorizontalRuleElement(props: PlateElementProps) { + return ( + <PlateElement className="my-5" {...props}> + <div contentEditable={false}> + <hr className="border-border border-t" /> + </div> + {props.children} + </PlateElement> + ); +} + +export function CodeBlockElement(props: PlateElementProps) { + const { resolvedTheme } = useTheme(); + const isMermaid = codeBlockLanguage(props.element) === "mermaid"; + + if (isMermaid) { + return ( + <PlateElement {...props}> + {/* The rendered diagram is decoration; the code lines stay in the + document so the text remains selectable, editable and serializable. */} + <div contentEditable={false} className="select-none"> + <PlanReviewMermaid + code={codeBlockText(props.element)} + isDark={resolvedTheme === "dark"} + /> + </div> + <div className="sr-only">{props.children}</div> + </PlateElement> + ); + } + + return ( + <PlateElement + className="my-3 overflow-x-auto rounded-md border bg-muted/50 py-2.5 font-mono text-[13px] leading-relaxed" + {...props} + > + <code>{props.children}</code> + </PlateElement> + ); +} + +export function CodeLineElement(props: PlateElementProps) { + return ( + <PlateElement as="div" className="px-3" {...props}> + {props.children} + </PlateElement> + ); +} + +export function LinkElement(props: PlateElementProps) { + return ( + <PlateElement + as="a" + className="cursor-pointer font-medium text-primary underline decoration-primary/40 underline-offset-2" + {...props} + attributes={{ + ...props.attributes, + // Plans reference issues and docs; a review should be able to follow them. + target: "_blank", + rel: "noreferrer noopener", + }} + > + {props.children} + </PlateElement> + ); +} + +const listVariants = cva("my-1.5 ps-6", { + variants: { + variant: { + ol: "list-decimal marker:text-muted-foreground", + ul: "list-disc marker:text-muted-foreground [&_ul]:list-[circle] [&_ul_ul]:list-[square]", + }, + }, +}); + +function ListElement({ variant, ...props }: PlateElementProps & VariantProps<typeof listVariants>) { + return ( + <PlateElement as={variant ?? "ul"} className={listVariants({ variant })} {...props}> + {props.children} + </PlateElement> + ); +} + +export const BulletedListElement = (props: PlateElementProps) => ( + <ListElement variant="ul" {...props} /> +); +export const NumberedListElement = (props: PlateElementProps) => ( + <ListElement variant="ol" {...props} /> +); + +export function TaskListElement(props: PlateElementProps) { + return ( + <PlateElement as="ul" className="my-1.5 list-none ps-1" {...props}> + {props.children} + </PlateElement> + ); +} + +function BaseListItemElement(props: PlateElementProps) { + return ( + <PlateElement as="li" className="text-[15px] leading-relaxed" {...props}> + {props.children} + </PlateElement> + ); +} + +function TaskListItemElement(props: PlateElementProps) { + const state = useTodoListElementState({ element: props.element }); + const { checkboxProps } = useTodoListElement(state); + const [firstChild, ...otherChildren] = Children.toArray(props.children); + + return ( + <BaseListItemElement {...props}> + <div className="flex items-start gap-2"> + <div contentEditable={false} className="mt-1 select-none"> + <Checkbox {...checkboxProps} /> + </div> + <span className={cn("flex-1", state.checked && "text-muted-foreground line-through")}> + {firstChild} + </span> + </div> + {otherChildren} + </BaseListItemElement> + ); +} + +export function ListItemElement(props: PlateElementProps) { + // The classic list model puts `checked` on the item itself for task lists. + return "checked" in props.element ? ( + <TaskListItemElement {...props} /> + ) : ( + <BaseListItemElement {...props} /> + ); +} + +export function TableElement(props: PlateElementProps) { + return ( + <PlateElement className="my-3 overflow-x-auto" {...props}> + <table className="w-full border-collapse text-sm"> + <tbody>{props.children}</tbody> + </table> + </PlateElement> + ); +} + +export function TableRowElement(props: PlateElementProps) { + return ( + <PlateElement as="tr" className="border-border border-b" {...props}> + {props.children} + </PlateElement> + ); +} + +export function TableCellElement(props: PlateElementProps) { + return ( + <PlateElement as="td" className="border border-border px-2.5 py-1.5 align-top" {...props}> + {props.children} + </PlateElement> + ); +} + +export function TableCellHeaderElement(props: PlateElementProps) { + return ( + <PlateElement + as="th" + className="border border-border bg-muted/50 px-2.5 py-1.5 text-left font-semibold" + {...props} + > + {props.children} + </PlateElement> + ); +} + +export function CodeLeaf(props: PlateLeafProps) { + return ( + <PlateLeaf + as="code" + className="rounded border bg-muted px-1 py-0.5 font-mono text-[0.9em]" + {...props} + > + {props.children} + </PlateLeaf> + ); +} + +export function HighlightLeaf(props: PlateLeafProps) { + return ( + <PlateLeaf + as="mark" + className="rounded-sm bg-amber-200/70 text-inherit dark:bg-amber-400/30" + {...props} + > + {props.children} + </PlateLeaf> + ); +} + +export function KbdLeaf(props: PlateLeafProps) { + return ( + <PlateLeaf + as="kbd" + className="rounded border border-border border-b-2 bg-muted px-1.5 py-0.5 font-mono text-[0.8em]" + {...props} + > + {props.children} + </PlateLeaf> + ); +} diff --git a/apps/web/src/components/planreview/plate/PlanReviewReviewMarks.tsx b/apps/web/src/components/planreview/plate/PlanReviewReviewMarks.tsx new file mode 100644 index 00000000000..baefc9ce0ea --- /dev/null +++ b/apps/web/src/components/planreview/plate/PlanReviewReviewMarks.tsx @@ -0,0 +1,47 @@ +/** + * T3-CUSTOM(expbkt3): the two marks that make a review readable at a glance. + * + * A commented span is highlighted so the reviewer can see what they annotated + * without opening the rail, and a tracked edit is coloured the way every diff + * in this app is: green for what an edit adds, red struck through for what it + * removes. + * + * Presentational only. Plate's hover/active comment focus rides on typed plugin + * options that the base `CommentPlugin` config does not expose, and the rail + * already carries the discussion; wiring focus can follow once we need it. + */ +import { PlateLeaf, type PlateLeafProps } from "platejs/react"; + +import { cn } from "../../../lib/utils"; + +export function CommentLeaf(props: PlateLeafProps) { + return ( + <PlateLeaf + {...props} + className="border-amber-400/50 border-b-2 bg-amber-300/15 transition-colors hover:bg-amber-300/30" + > + {props.children} + </PlateLeaf> + ); +} + +export function SuggestionLeaf(props: PlateLeafProps) { + // `type: "remove"` marks text the reviewer deleted while suggesting; it stays + // visible until the suggestion is accepted, which is the point of tracking. + const data = (props.leaf as { suggestion?: { type?: string } }).suggestion; + const isRemoval = data?.type === "remove"; + + return ( + <PlateLeaf + {...props} + className={cn( + "transition-colors", + isRemoval + ? "bg-red-500/15 text-red-700 line-through dark:text-red-300" + : "bg-emerald-500/15 text-emerald-700 no-underline dark:text-emerald-300", + )} + > + {props.children} + </PlateLeaf> + ); +} diff --git a/apps/web/src/components/sessionManager/SessionManagerPage.logic.test.ts b/apps/web/src/components/sessionManager/SessionManagerPage.logic.test.ts new file mode 100644 index 00000000000..123dfffdece --- /dev/null +++ b/apps/web/src/components/sessionManager/SessionManagerPage.logic.test.ts @@ -0,0 +1,479 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { ThreadShell } from "../../types"; +import { + DEFAULT_SESSION_MANAGER_FILTERS, + applyFrozenRowOrder, + buildSessionManagerCounts, + buildSessionManagerFilterChips, + buildSessionManagerSearchText, + clampWorkSummaryPercent, + compareSessionManagerRows, + filterSessionManagerRows, + hasActiveSessionManagerFilters, + matchesSessionManagerFilters, + nextSessionManagerSort, + planSessionManagerAction, + reconcileSessionManagerFilters, + sanitizeSessionManagerFilters, + sanitizeSessionManagerSort, + sortSessionManagerRows, + workSummaryPreview, + type SessionManagerFilters, + type SessionManagerRow, +} from "./SessionManagerPage.logic"; + +const NOW = "2026-08-08T12:00:00.000Z"; + +function daysAgo(days: number): string { + return new Date(Date.parse(NOW) - days * 24 * 60 * 60 * 1_000).toISOString(); +} + +function makeRow(overrides: Partial<SessionManagerRow> & { key: string }): SessionManagerRow { + const { thread: threadOverride, ...rest } = overrides; + const title = threadOverride?.title ?? `Session ${overrides.key}`; + const thread = { + id: overrides.key, + title, + createdAt: daysAgo(3), + updatedAt: daysAgo(1), + branch: null, + worktreePath: null, + priority: null, + linearIssueUrl: null, + ...threadOverride, + } as unknown as ThreadShell; + return { + thread, + lifecycle: "active", + phaseId: "ready", + attentionKind: null, + repositoryKey: "repo-a", + repositoryLabel: "Repo A", + providerKind: "codex", + providerName: "Codex", + modelLabel: "gpt-5.6-sol", + ownerUserId: "user_1", + ownerLabel: "Tushar", + priorityRank: 5, + lastActivityAt: daysAgo(1), + isUnreadCompletion: false, + isPinned: false, + workSummary: null, + capabilities: { + settlement: true, + snooze: true, + pinning: true, + priority: true, + titleRegeneration: true, + workSummary: true, + }, + canStop: false, + searchText: title.toLowerCase(), + ...rest, + }; +} + +describe("session manager filter matching", () => { + it("keeps a row only when every active facet admits it", () => { + const row = makeRow({ + key: "a", + repositoryKey: "repo-a", + phaseId: "implementing", + providerKind: "codex", + priorityRank: 1, + attentionKind: "approval", + ownerUserId: "user_1", + }); + const filters: SessionManagerFilters = { + ...DEFAULT_SESSION_MANAGER_FILTERS, + repositoryKeys: ["repo-a"], + phaseIds: ["implementing"], + providerKinds: ["codex"], + priorities: [1], + attentionKinds: ["approval"], + ownerUserIds: ["user_1"], + }; + expect(matchesSessionManagerFilters(row, filters, { now: NOW })).toBe(true); + expect( + matchesSessionManagerFilters(row, { ...filters, repositoryKeys: ["repo-b"] }, { now: NOW }), + ).toBe(false); + expect(matchesSessionManagerFilters(row, { ...filters, priorities: [0] }, { now: NOW })).toBe( + false, + ); + }); + + it("ORs values inside one facet", () => { + const row = makeRow({ key: "a", repositoryKey: "repo-b" }); + expect( + matchesSessionManagerFilters( + row, + { ...DEFAULT_SESSION_MANAGER_FILTERS, repositoryKeys: ["repo-a", "repo-b"] }, + { now: NOW }, + ), + ).toBe(true); + }); + + it("hides every lifecycle the filter does not list", () => { + const archived = makeRow({ key: "a", lifecycle: "archived" }); + expect( + matchesSessionManagerFilters(archived, DEFAULT_SESSION_MANAGER_FILTERS, { now: NOW }), + ).toBe(false); + expect( + matchesSessionManagerFilters( + archived, + { ...DEFAULT_SESSION_MANAGER_FILTERS, lifecycles: ["archived"] }, + { now: NOW }, + ), + ).toBe(true); + }); + + it("treats the unprioritised rank as a filterable value", () => { + const row = makeRow({ key: "a", priorityRank: 5 }); + expect( + matchesSessionManagerFilters( + row, + { ...DEFAULT_SESSION_MANAGER_FILTERS, priorities: [5] }, + { now: NOW }, + ), + ).toBe(true); + }); + + it("admits only rows idle for at least staleDays, and never hides rows with no activity", () => { + const fresh = makeRow({ key: "fresh", lastActivityAt: daysAgo(1) }); + const stale = makeRow({ key: "stale", lastActivityAt: daysAgo(9) }); + const never = makeRow({ key: "never", lastActivityAt: null }); + const filters = { ...DEFAULT_SESSION_MANAGER_FILTERS, staleDays: 7 }; + expect(matchesSessionManagerFilters(fresh, filters, { now: NOW })).toBe(false); + expect(matchesSessionManagerFilters(stale, filters, { now: NOW })).toBe(true); + expect(matchesSessionManagerFilters(never, filters, { now: NOW })).toBe(true); + }); + + it("matches the search box against the row's precomputed haystack", () => { + const row = makeRow({ key: "a", searchText: "converge execution state on resume bk-412" }); + const filters = { ...DEFAULT_SESSION_MANAGER_FILTERS, search: " BK-412 " }; + expect(matchesSessionManagerFilters(row, filters, { now: NOW })).toBe(true); + expect( + matchesSessionManagerFilters(row, { ...filters, search: "nonexistent" }, { now: NOW }), + ).toBe(false); + }); + + it("filters a list down to the matching rows", () => { + const rows = [ + makeRow({ key: "a", repositoryKey: "repo-a" }), + makeRow({ key: "b", repositoryKey: "repo-b" }), + ]; + const filtered = filterSessionManagerRows( + rows, + { ...DEFAULT_SESSION_MANAGER_FILTERS, repositoryKeys: ["repo-b"] }, + { now: NOW }, + ); + expect(filtered.map((row) => row.key)).toEqual(["b"]); + }); + + it("builds a search haystack that is lowercased and covers every searchable field", () => { + const haystack = buildSessionManagerSearchText({ + title: "Bulk Session Manager", + branch: "t3code/56e26545", + repositoryLabel: "t3code", + worktreePath: "/home/ubuntu/worktrees/x", + summary: "Client column landed", + remaining: "Run the fork marker check", + linearIssueUrl: "https://linear.app/x/issue/BK-9", + providerName: "Claude", + model: "opus-5", + }); + expect(haystack).toContain("bulk session manager"); + expect(haystack).toContain("t3code/56e26545"); + expect(haystack).toContain("fork marker"); + expect(haystack).toContain("bk-9"); + expect(haystack).toBe(haystack.toLowerCase()); + }); +}); + +describe("session manager sorting", () => { + it("sorts activity newest-first under asc and oldest-first under desc", () => { + const rows = [ + makeRow({ key: "old", lastActivityAt: daysAgo(9) }), + makeRow({ key: "new", lastActivityAt: daysAgo(1) }), + makeRow({ key: "mid", lastActivityAt: daysAgo(4) }), + ]; + expect( + sortSessionManagerRows(rows, { column: "activity", direction: "asc" }).map((row) => row.key), + ).toEqual(["new", "mid", "old"]); + expect( + sortSessionManagerRows(rows, { column: "activity", direction: "desc" }).map((row) => row.key), + ).toEqual(["old", "mid", "new"]); + }); + + it("sorts priority with P0 first and unprioritised last", () => { + const rows = [ + makeRow({ key: "none", priorityRank: 5 }), + makeRow({ key: "p2", priorityRank: 2 }), + makeRow({ key: "p0", priorityRank: 0 }), + ]; + expect( + sortSessionManagerRows(rows, { column: "priority", direction: "asc" }).map((row) => row.key), + ).toEqual(["p0", "p2", "none"]); + }); + + it("sorts rows with no work-summary stage after every staged row in both directions", () => { + const staged = makeRow({ + key: "staged", + workSummary: { + status: "ready", + summary: "x", + stage: "planning", + remaining: null, + percent: 10, + error: null, + requestId: null, + updatedAt: NOW, + } as SessionManagerRow["workSummary"], + }); + const bare = makeRow({ key: "bare" }); + expect( + sortSessionManagerRows([bare, staged], { column: "progress", direction: "asc" }).map( + (row) => row.key, + ), + ).toEqual(["staged", "bare"]); + }); + + it("breaks ties on the stable row key so equal rows never shuffle", () => { + const left = makeRow({ key: "aaa", priorityRank: 1 }); + const right = makeRow({ key: "bbb", priorityRank: 1 }); + expect( + compareSessionManagerRows(left, right, { column: "priority", direction: "asc" }), + ).toBeLessThan(0); + }); + + it("cycles a column asc → desc and resets direction when the column changes", () => { + const first = nextSessionManagerSort({ column: "activity", direction: "asc" }, "title"); + expect(first).toEqual({ column: "title", direction: "asc" }); + expect(nextSessionManagerSort(first, "title")).toEqual({ column: "title", direction: "desc" }); + }); +}); + +describe("frozen row order", () => { + const rows = [makeRow({ key: "a" }), makeRow({ key: "b" }), makeRow({ key: "c" })]; + + it("returns the live order when nothing is frozen", () => { + expect(applyFrozenRowOrder(rows, null).map((row) => row.key)).toEqual(["a", "b", "c"]); + expect(applyFrozenRowOrder(rows, []).map((row) => row.key)).toEqual(["a", "b", "c"]); + }); + + it("holds the frozen order even after the live sort changes", () => { + const reordered = [rows[2]!, rows[0]!, rows[1]!]; + expect(applyFrozenRowOrder(reordered, ["a", "b", "c"]).map((row) => row.key)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("drops vanished rows and appends new ones at the end", () => { + const live = [makeRow({ key: "a" }), makeRow({ key: "c" }), makeRow({ key: "d" })]; + expect(applyFrozenRowOrder(live, ["a", "b", "c"]).map((row) => row.key)).toEqual([ + "a", + "c", + "d", + ]); + }); + + it("never emits a row twice when the frozen list repeats a key", () => { + expect(applyFrozenRowOrder(rows, ["a", "a", "b"]).map((row) => row.key)).toEqual([ + "a", + "b", + "c", + ]); + }); +}); + +describe("session manager chips", () => { + const labels = { + repositories: new Map([["repo-a", "Repo A"]]), + providers: new Map([["codex", "Codex"]]), + owners: new Map([["user_1", "Tushar"]]), + }; + + it("emits nothing for the default filter set", () => { + expect(buildSessionManagerFilterChips(DEFAULT_SESSION_MANAGER_FILTERS, labels)).toEqual([]); + expect(hasActiveSessionManagerFilters(DEFAULT_SESSION_MANAGER_FILTERS)).toBe(false); + }); + + it("labels each facet value and carries the value needed to clear it", () => { + const chips = buildSessionManagerFilterChips( + { + ...DEFAULT_SESSION_MANAGER_FILTERS, + search: " resume ", + repositoryKeys: ["repo-a"], + phaseIds: ["implementing"], + providerKinds: ["codex"], + priorities: [0, 5], + attentionKinds: ["approval"], + ownerUserIds: ["user_1"], + staleDays: 7, + }, + labels, + ); + expect(chips.map((chip) => [chip.facet, chip.label, chip.value])).toEqual([ + ["search", "“resume”", null], + ["repository", "Repo A", "repo-a"], + ["phase", "Implementing", "implementing"], + ["attention", "Approval", "approval"], + ["priority", "P0", "0"], + ["priority", "No priority", "5"], + ["provider", "Codex", "codex"], + ["owner", "Tushar", "user_1"], + ["stale", "idle 7d+", null], + ]); + }); + + it("chips the lifecycle facet only once it differs from the default", () => { + expect( + buildSessionManagerFilterChips( + { ...DEFAULT_SESSION_MANAGER_FILTERS, lifecycles: ["active", "archived"] }, + labels, + ).map((chip) => chip.label), + ).toEqual(["Active", "Archived"]); + }); + + it("falls back to the raw key when a label is missing", () => { + const chips = buildSessionManagerFilterChips( + { ...DEFAULT_SESSION_MANAGER_FILTERS, repositoryKeys: ["repo-unknown"] }, + labels, + ); + expect(chips[0]?.label).toBe("repo-unknown"); + }); +}); + +describe("session manager counts", () => { + it("counts only active rows and buckets them by phase and staleness", () => { + const counts = buildSessionManagerCounts( + [ + makeRow({ key: "a", phaseId: "implementing", attentionKind: null }), + makeRow({ key: "b", phaseId: "needs_input", attentionKind: "input" }), + makeRow({ key: "c", phaseId: "plan_ready", lastActivityAt: daysAgo(30) }), + makeRow({ key: "d", lifecycle: "archived", phaseId: "implementing" }), + ], + { now: NOW, staleDays: 7 }, + ); + expect(counts).toEqual({ + total: 4, + active: 3, + attention: 1, + running: 1, + stale: 1, + blocked: 1, + review: 1, + }); + }); +}); + +describe("session manager persistence", () => { + it("drops unknown members and restores the default lifecycle set", () => { + expect( + sanitizeSessionManagerFilters({ + search: 42, + repositoryKeys: ["repo-a", 7], + phaseIds: ["implementing", "not-a-phase"], + priorities: [0, 9, "x", 5], + attentionKinds: ["approval", "nope"], + lifecycles: ["not-a-lifecycle"], + staleDays: -3, + }), + ).toEqual({ + search: "", + repositoryKeys: ["repo-a"], + phaseIds: ["implementing"], + providerKinds: [], + priorities: [0, 5], + attentionKinds: ["approval"], + ownerUserIds: [], + lifecycles: ["active"], + staleDays: null, + }); + }); + + it("falls back to the default filters for a non-object blob", () => { + expect(sanitizeSessionManagerFilters(null)).toEqual(DEFAULT_SESSION_MANAGER_FILTERS); + expect(sanitizeSessionManagerFilters("nope")).toEqual(DEFAULT_SESSION_MANAGER_FILTERS); + }); + + it("sanitizes a persisted sort and rejects unknown columns", () => { + expect(sanitizeSessionManagerSort({ column: "title", direction: "desc" })).toEqual({ + column: "title", + direction: "desc", + }); + expect(sanitizeSessionManagerSort({ column: "bogus", direction: "sideways" })).toEqual({ + column: "activity", + direction: "asc", + }); + }); + + it("reconciles away facet values whose option disappeared", () => { + const filters: SessionManagerFilters = { + ...DEFAULT_SESSION_MANAGER_FILTERS, + repositoryKeys: ["repo-a", "gone"], + providerKinds: ["codex"], + ownerUserIds: ["user_1", "left"], + }; + expect( + reconcileSessionManagerFilters(filters, { + repositoryKeys: new Set(["repo-a"]), + providerKinds: new Set(["codex"]), + ownerUserIds: new Set(["user_1"]), + }), + ).toMatchObject({ + repositoryKeys: ["repo-a"], + providerKinds: ["codex"], + ownerUserIds: ["user_1"], + }); + }); + + it("returns the same object when nothing needs reconciling", () => { + const filters = { ...DEFAULT_SESSION_MANAGER_FILTERS, repositoryKeys: ["repo-a"] }; + expect( + reconcileSessionManagerFilters(filters, { + repositoryKeys: new Set(["repo-a"]), + providerKinds: new Set<string>(), + ownerUserIds: new Set<string>(), + }), + ).toBe(filters); + }); +}); + +describe("session manager action planning", () => { + it("splits eligible from blocked rows and only reports a reason when nothing is eligible", () => { + const rows = [makeRow({ key: "a", canStop: true }), makeRow({ key: "b", canStop: false })]; + const mixed = planSessionManagerAction(rows, (row) => row.canStop, "nothing to stop"); + expect(mixed.eligible.map((row) => row.key)).toEqual(["a"]); + expect(mixed.blocked.map((row) => row.key)).toEqual(["b"]); + expect(mixed.disabledReason).toBeNull(); + + const none = planSessionManagerAction([rows[1]!], (row) => row.canStop, "nothing to stop"); + expect(none.disabledReason).toBe("nothing to stop"); + }); + + it("reports no reason for an empty selection", () => { + expect(planSessionManagerAction([], () => true, "reason").disabledReason).toBeNull(); + }); +}); + +describe("work summary presentation helpers", () => { + it("clamps a percent into 0..100 and rejects non-finite values", () => { + expect(clampWorkSummaryPercent(42.4)).toBe(42); + expect(clampWorkSummaryPercent(-5)).toBe(0); + expect(clampWorkSummaryPercent(180)).toBe(100); + expect(clampWorkSummaryPercent(null)).toBeNull(); + expect(clampWorkSummaryPercent(Number.NaN)).toBeNull(); + }); + + it("collapses whitespace into a single-line preview and treats blank text as absent", () => { + expect(workSummaryPreview(" landed the\n client column\n")).toBe( + "landed the client column", + ); + expect(workSummaryPreview(" ")).toBeNull(); + expect(workSummaryPreview(null)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/sessionManager/SessionManagerPage.logic.ts b/apps/web/src/components/sessionManager/SessionManagerPage.logic.ts new file mode 100644 index 00000000000..1190d4a9de9 --- /dev/null +++ b/apps/web/src/components/sessionManager/SessionManagerPage.logic.ts @@ -0,0 +1,748 @@ +/** + * T3-CUSTOM(expbkt3): Pure logic for the bulk session manager table. + * + * Everything here is DOM-free and deterministic so the table's real decisions — + * which rows a filter set admits, how a column sorts, what the chip bar says, + * and how row order freezes under a live selection — are unit-testable without + * mounting 100+ virtualized rows. The component keeps only React state. + * + * Row *derivation* deliberately reuses the phase sidebar's helpers + * (`resolvePhaseSidebarPhase`, `resolvePhaseSidebarAttentionKind`, + * `phaseSidebarPriorityRank`, …) rather than re-deriving phase/attention here: + * two answers to "what phase is this session in" is exactly the drift this page + * is supposed to remove. + */ +import type { ThreadWorkSummary, ThreadWorkSummaryStage } from "@t3tools/contracts"; + +import type { ThreadShell } from "../../types"; +import { + PHASE_SIDEBAR_PHASES, + PHASE_SIDEBAR_PHASE_IDS, + PHASE_SIDEBAR_UNPRIORITISED_RANK, + formatThreadPriority, + type PhaseSidebarAttentionKind, + type PhaseSidebarPhaseId, +} from "../sidebar/PhaseGroupedSidebar.logic"; + +/* --------------------------------- shape ---------------------------------- */ + +/** + * Where a session sits in the parking lifecycle. Mirrors the sidebar's + * active/snoozed/settled shelves and adds `archived`, which the sidebar never + * shows but a management table must. + */ +export type SessionManagerLifecycle = "active" | "snoozed" | "settled" | "archived"; + +export const SESSION_MANAGER_LIFECYCLES: ReadonlyArray<SessionManagerLifecycle> = [ + "active", + "snoozed", + "settled", + "archived", +]; + +export const SESSION_MANAGER_ATTENTION_KINDS: ReadonlyArray<PhaseSidebarAttentionKind> = [ + "approval", + "input", + "error", +]; + +export const SESSION_MANAGER_ATTENTION_LABELS: Record<PhaseSidebarAttentionKind, string> = { + approval: "Approval", + input: "Input", + error: "Error", +}; + +/** Per-environment capability gates, resolved once per row. */ +export interface SessionManagerRowCapabilities { + readonly settlement: boolean; + readonly snooze: boolean; + readonly pinning: boolean; + readonly priority: boolean; + readonly titleRegeneration: boolean; + readonly workSummary: boolean; +} + +export interface SessionManagerRow { + /** Scoped thread key — the identity used by selection, freezing and pruning. */ + readonly key: string; + readonly thread: ThreadShell; + readonly lifecycle: SessionManagerLifecycle; + readonly phaseId: PhaseSidebarPhaseId; + readonly attentionKind: PhaseSidebarAttentionKind | null; + readonly repositoryKey: string; + readonly repositoryLabel: string; + readonly providerKind: string; + readonly providerName: string; + readonly modelLabel: string; + readonly ownerUserId: string | null; + readonly ownerLabel: string; + /** `thread.priority ?? 5`; 5 sorts and filters as "unprioritised". */ + readonly priorityRank: number; + readonly lastActivityAt: string | null; + readonly isUnreadCompletion: boolean; + readonly isPinned: boolean; + readonly workSummary: ThreadWorkSummary | null; + readonly capabilities: SessionManagerRowCapabilities; + /** Whether a running agent turn can be stopped on this row right now. */ + readonly canStop: boolean; + /** Pre-lowercased haystack for the free-text search box. */ + readonly searchText: string; +} + +/* -------------------------------- filters --------------------------------- */ + +export interface SessionManagerFilters { + readonly search: string; + readonly repositoryKeys: ReadonlyArray<string>; + readonly phaseIds: ReadonlyArray<PhaseSidebarPhaseId>; + readonly providerKinds: ReadonlyArray<string>; + /** 0..4 plus `PHASE_SIDEBAR_UNPRIORITISED_RANK` (5) for "no priority". */ + readonly priorities: ReadonlyArray<number>; + readonly attentionKinds: ReadonlyArray<PhaseSidebarAttentionKind>; + readonly ownerUserIds: ReadonlyArray<string>; + readonly lifecycles: ReadonlyArray<SessionManagerLifecycle>; + /** Only rows idle for at least this many days. */ + readonly staleDays: number | null; +} + +export const DEFAULT_SESSION_MANAGER_FILTERS: SessionManagerFilters = { + search: "", + repositoryKeys: [], + phaseIds: [], + providerKinds: [], + priorities: [], + attentionKinds: [], + ownerUserIds: [], + lifecycles: ["active"], + staleDays: null, +}; + +export const SESSION_MANAGER_STALE_DAY_CHOICES: ReadonlyArray<number | null> = [null, 1, 3, 7, 14]; + +export const SESSION_MANAGER_PRIORITY_CHOICES: ReadonlyArray<{ + readonly value: number; + readonly label: string; +}> = [0, 1, 2, 3, 4].map((value) => ({ value, label: formatThreadPriority(value) })); + +const DAY_MS = 24 * 60 * 60 * 1_000; + +/** + * `true` when the row survives every active facet. Facets are AND-ed, values + * within one facet are OR-ed — the same contract as the sidebar's filter bar, + * so a chip means the same thing in both places. + */ +export function matchesSessionManagerFilters( + row: SessionManagerRow, + filters: SessionManagerFilters, + options: { readonly now: string }, +): boolean { + if (!filters.lifecycles.includes(row.lifecycle)) return false; + if (filters.repositoryKeys.length > 0 && !filters.repositoryKeys.includes(row.repositoryKey)) { + return false; + } + if (filters.phaseIds.length > 0 && !filters.phaseIds.includes(row.phaseId)) return false; + if (filters.providerKinds.length > 0 && !filters.providerKinds.includes(row.providerKind)) { + return false; + } + if (filters.priorities.length > 0 && !filters.priorities.includes(row.priorityRank)) return false; + if (filters.attentionKinds.length > 0) { + if (row.attentionKind === null || !filters.attentionKinds.includes(row.attentionKind)) { + return false; + } + } + if (filters.ownerUserIds.length > 0) { + if (row.ownerUserId === null || !filters.ownerUserIds.includes(row.ownerUserId)) return false; + } + if (filters.staleDays !== null) { + // A row with no activity timestamp at all has been idle since forever, so + // it always satisfies an idle-for filter rather than being hidden by it. + if (row.lastActivityAt !== null) { + const activityMs = Date.parse(row.lastActivityAt); + const nowMs = Date.parse(options.now); + if (Number.isNaN(activityMs) || Number.isNaN(nowMs)) return true; + if (nowMs - activityMs < filters.staleDays * DAY_MS) return false; + } + } + const query = filters.search.trim().toLowerCase(); + if (query.length > 0 && !row.searchText.includes(query)) return false; + return true; +} + +export function filterSessionManagerRows( + rows: ReadonlyArray<SessionManagerRow>, + filters: SessionManagerFilters, + options: { readonly now: string }, +): ReadonlyArray<SessionManagerRow> { + return rows.filter((row) => matchesSessionManagerFilters(row, filters, options)); +} + +/* --------------------------------- sorting -------------------------------- */ + +export type SessionManagerSortColumn = + | "title" + | "repository" + | "phase" + | "priority" + | "progress" + | "activity" + | "created"; + +export interface SessionManagerSort { + readonly column: SessionManagerSortColumn; + readonly direction: "asc" | "desc"; +} + +/** + * Most-recently-active first. `asc` reads as "closest to now" for the two time + * columns, which is what a table user expects from a first click on "Activity" + * even though the underlying timestamps sort the other way. + */ +export const DEFAULT_SESSION_MANAGER_SORT: SessionManagerSort = { + column: "activity", + direction: "asc", +}; + +export const SESSION_MANAGER_STAGE_ORDER: ReadonlyArray<ThreadWorkSummaryStage> = [ + "planning", + "implementing", + "blocked", + "awaiting-review", + "done", +]; + +export const SESSION_MANAGER_STAGE_LABELS: Record<ThreadWorkSummaryStage, string> = { + planning: "Planning", + implementing: "Implementing", + blocked: "Blocked", + "awaiting-review": "Awaiting review", + done: "Done", +}; + +const PHASE_ORDER = new Map<PhaseSidebarPhaseId, number>( + PHASE_SIDEBAR_PHASE_IDS.map((phaseId, index) => [phaseId, index]), +); + +export const SESSION_MANAGER_PHASE_LABELS: Record<PhaseSidebarPhaseId, string> = Object.fromEntries( + PHASE_SIDEBAR_PHASES.map((phase) => [phase.id, phase.label]), +) as Record<PhaseSidebarPhaseId, string>; + +function timestampValue(value: string | null | undefined): number { + if (value == null) return Number.NEGATIVE_INFINITY; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed; +} + +/** Rows with no summary sort after every staged row, in both directions. */ +function progressValue(row: SessionManagerRow): number { + const stage = row.workSummary?.stage ?? null; + if (stage === null) return Number.POSITIVE_INFINITY; + const index = SESSION_MANAGER_STAGE_ORDER.indexOf(stage); + return index === -1 ? Number.POSITIVE_INFINITY : index; +} + +export function compareSessionManagerRows( + left: SessionManagerRow, + right: SessionManagerRow, + sort: SessionManagerSort, +): number { + const factor = sort.direction === "asc" ? 1 : -1; + switch (sort.column) { + case "title": + return factor * left.thread.title.localeCompare(right.thread.title); + case "repository": + return ( + factor * + (left.repositoryLabel.localeCompare(right.repositoryLabel) || compareKeys(left, right)) + ); + case "phase": + return ( + factor * + ((PHASE_ORDER.get(left.phaseId) ?? 0) - (PHASE_ORDER.get(right.phaseId) ?? 0) || + compareKeys(left, right)) + ); + case "priority": + return factor * (left.priorityRank - right.priorityRank || compareKeys(left, right)); + case "progress": + return factor * (progressValue(left) - progressValue(right) || compareKeys(left, right)); + case "created": + // Newest first under `asc`, matching the activity column's reading. + return ( + factor * + (timestampValue(right.thread.createdAt) - timestampValue(left.thread.createdAt) || + compareKeys(left, right)) + ); + case "activity": + default: + return ( + factor * + (timestampValue(right.lastActivityAt) - timestampValue(left.lastActivityAt) || + compareKeys(left, right)) + ); + } +} + +/** Ties break on the stable row key so a re-sort never shuffles equal rows. */ +function compareKeys(left: SessionManagerRow, right: SessionManagerRow): number { + return left.key.localeCompare(right.key); +} + +export function sortSessionManagerRows( + rows: ReadonlyArray<SessionManagerRow>, + sort: SessionManagerSort, +): ReadonlyArray<SessionManagerRow> { + return [...rows].sort((left, right) => compareSessionManagerRows(left, right, sort)); +} + +export function nextSessionManagerSort( + current: SessionManagerSort, + column: SessionManagerSortColumn, +): SessionManagerSort { + if (current.column !== column) return { column, direction: "asc" }; + return { column, direction: current.direction === "asc" ? "desc" : "asc" }; +} + +/* ------------------------------ frozen order ------------------------------ */ + +/** + * Hold row ORDER still while a selection is live. + * + * Rows are live atoms: a bulk retitle or summarize rewrites the very fields the + * table sorts on, so an unfrozen table would reshuffle under the user's cursor + * mid-run and the next click would land on a different session. Freezing order + * (never content — rows still update in place) makes a bulk run safe. + * + * Rows that appear while frozen are appended in their sorted order rather than + * inserted, so nothing above the user's cursor ever moves. Rows that vanish are + * dropped. + */ +export function applyFrozenRowOrder( + rows: ReadonlyArray<SessionManagerRow>, + frozenKeys: ReadonlyArray<string> | null, +): ReadonlyArray<SessionManagerRow> { + if (frozenKeys === null || frozenKeys.length === 0) return rows; + const byKey = new Map(rows.map((row) => [row.key, row])); + const ordered: SessionManagerRow[] = []; + const seen = new Set<string>(); + for (const key of frozenKeys) { + const row = byKey.get(key); + if (row === undefined || seen.has(key)) continue; + ordered.push(row); + seen.add(key); + } + for (const row of rows) { + if (!seen.has(row.key)) ordered.push(row); + } + return ordered; +} + +/* --------------------------------- chips ---------------------------------- */ + +export type SessionManagerFilterFacet = + | "search" + | "repository" + | "phase" + | "provider" + | "priority" + | "attention" + | "owner" + | "lifecycle" + | "stale"; + +export interface SessionManagerFilterChip { + /** Stable identity for React keys and for the component's clear handler. */ + readonly id: string; + readonly facet: SessionManagerFilterFacet; + readonly label: string; + /** The facet value to remove; `null` for scalar facets (search, stale). */ + readonly value: string | null; +} + +export interface SessionManagerChipLabels { + readonly repositories: ReadonlyMap<string, string>; + readonly providers: ReadonlyMap<string, string>; + readonly owners: ReadonlyMap<string, string>; +} + +const LIFECYCLE_LABELS: Record<SessionManagerLifecycle, string> = { + active: "Active", + snoozed: "Snoozed", + settled: "Settled", + archived: "Archived", +}; + +export function formatPriorityRankLabel(rank: number): string { + return rank >= PHASE_SIDEBAR_UNPRIORITISED_RANK ? "No priority" : formatThreadPriority(rank); +} + +/** + * One removable chip per *deviation from the default*. The lifecycle facet only + * chips when it differs from the default (`active`), because a chip reading + * "Active" on a fresh page would be noise the user can't meaningfully clear. + */ +export function buildSessionManagerFilterChips( + filters: SessionManagerFilters, + labels: SessionManagerChipLabels, +): ReadonlyArray<SessionManagerFilterChip> { + const chips: SessionManagerFilterChip[] = []; + const search = filters.search.trim(); + if (search.length > 0) { + chips.push({ id: "search", facet: "search", label: `“${search}”`, value: null }); + } + for (const key of filters.repositoryKeys) { + chips.push({ + id: `repository:${key}`, + facet: "repository", + label: labels.repositories.get(key) ?? key, + value: key, + }); + } + for (const phaseId of filters.phaseIds) { + chips.push({ + id: `phase:${phaseId}`, + facet: "phase", + label: SESSION_MANAGER_PHASE_LABELS[phaseId] ?? phaseId, + value: phaseId, + }); + } + for (const kind of filters.attentionKinds) { + chips.push({ + id: `attention:${kind}`, + facet: "attention", + label: SESSION_MANAGER_ATTENTION_LABELS[kind], + value: kind, + }); + } + for (const rank of filters.priorities) { + chips.push({ + id: `priority:${rank}`, + facet: "priority", + label: formatPriorityRankLabel(rank), + value: String(rank), + }); + } + for (const providerKind of filters.providerKinds) { + chips.push({ + id: `provider:${providerKind}`, + facet: "provider", + label: labels.providers.get(providerKind) ?? providerKind, + value: providerKind, + }); + } + for (const ownerUserId of filters.ownerUserIds) { + chips.push({ + id: `owner:${ownerUserId}`, + facet: "owner", + label: labels.owners.get(ownerUserId) ?? ownerUserId, + value: ownerUserId, + }); + } + if (!isDefaultLifecycleSelection(filters.lifecycles)) { + for (const lifecycle of filters.lifecycles) { + chips.push({ + id: `lifecycle:${lifecycle}`, + facet: "lifecycle", + label: LIFECYCLE_LABELS[lifecycle], + value: lifecycle, + }); + } + } + if (filters.staleDays !== null) { + chips.push({ + id: "stale", + facet: "stale", + label: `idle ${filters.staleDays}d+`, + value: null, + }); + } + return chips; +} + +function isDefaultLifecycleSelection(lifecycles: ReadonlyArray<SessionManagerLifecycle>): boolean { + const defaults = DEFAULT_SESSION_MANAGER_FILTERS.lifecycles; + if (lifecycles.length !== defaults.length) return false; + return defaults.every((lifecycle) => lifecycles.includes(lifecycle)); +} + +export function hasActiveSessionManagerFilters(filters: SessionManagerFilters): boolean { + return ( + filters.search.trim().length > 0 || + filters.repositoryKeys.length > 0 || + filters.phaseIds.length > 0 || + filters.providerKinds.length > 0 || + filters.priorities.length > 0 || + filters.attentionKinds.length > 0 || + filters.ownerUserIds.length > 0 || + filters.staleDays !== null || + !isDefaultLifecycleSelection(filters.lifecycles) + ); +} + +/* ------------------------------- saved views ------------------------------ */ + +export interface SessionManagerSavedView { + readonly id: string; + readonly label: string; + readonly filters: SessionManagerFilters; + readonly sort?: SessionManagerSort; + /** Which live count to badge the pill with, if any. */ + readonly countKey?: SessionManagerCountKey; +} + +export type SessionManagerCountKey = "attention" | "running" | "stale" | "blocked" | "review"; + +export const SESSION_MANAGER_SAVED_VIEWS: ReadonlyArray<SessionManagerSavedView> = [ + { + id: "attention", + label: "Needs me", + countKey: "attention", + filters: { + ...DEFAULT_SESSION_MANAGER_FILTERS, + attentionKinds: ["approval", "input", "error"], + }, + sort: { column: "priority", direction: "asc" }, + }, + { + id: "running", + label: "Running now", + countKey: "running", + filters: { ...DEFAULT_SESSION_MANAGER_FILTERS, phaseIds: ["planning", "implementing"] }, + sort: { column: "activity", direction: "asc" }, + }, + { + id: "stale", + label: "Stale 7d+", + countKey: "stale", + filters: { ...DEFAULT_SESSION_MANAGER_FILTERS, staleDays: 7 }, + sort: { column: "activity", direction: "desc" }, + }, + { + id: "blocked", + label: "Needs input", + countKey: "blocked", + filters: { ...DEFAULT_SESSION_MANAGER_FILTERS, phaseIds: ["needs_input"] }, + }, + { + id: "review", + label: "Plan ready", + countKey: "review", + filters: { ...DEFAULT_SESSION_MANAGER_FILTERS, phaseIds: ["plan_ready"] }, + }, +]; + +export interface SessionManagerCounts { + readonly total: number; + readonly active: number; + readonly attention: number; + readonly running: number; + readonly stale: number; + readonly blocked: number; + readonly review: number; +} + +export function buildSessionManagerCounts( + rows: ReadonlyArray<SessionManagerRow>, + options: { readonly now: string; readonly staleDays: number }, +): SessionManagerCounts { + const nowMs = Date.parse(options.now); + let active = 0; + let attention = 0; + let running = 0; + let stale = 0; + let blocked = 0; + let review = 0; + for (const row of rows) { + if (row.lifecycle !== "active") continue; + active += 1; + if (row.attentionKind !== null) attention += 1; + if (row.phaseId === "planning" || row.phaseId === "implementing") running += 1; + if (row.phaseId === "needs_input") blocked += 1; + if (row.phaseId === "plan_ready") review += 1; + const activityMs = row.lastActivityAt === null ? null : Date.parse(row.lastActivityAt); + if (activityMs === null || Number.isNaN(activityMs)) { + stale += 1; + } else if (!Number.isNaN(nowMs) && nowMs - activityMs >= options.staleDays * DAY_MS) { + stale += 1; + } + } + return { total: rows.length, active, attention, running, stale, blocked, review }; +} + +/* -------------------------- persistence sanitizers ------------------------ */ + +function sanitizeStringArray(value: unknown): ReadonlyArray<string> { + if (!Array.isArray(value)) return []; + const seen = new Set<string>(); + for (const entry of value) { + if (typeof entry === "string" && entry.length > 0) seen.add(entry); + } + return [...seen]; +} + +function sanitizeMemberArray<T extends string>( + value: unknown, + allowed: ReadonlyArray<T>, +): ReadonlyArray<T> { + return sanitizeStringArray(value).filter((entry): entry is T => + (allowed as ReadonlyArray<string>).includes(entry), + ); +} + +function sanitizePriorities(value: unknown): ReadonlyArray<number> { + if (!Array.isArray(value)) return []; + const seen = new Set<number>(); + for (const entry of value) { + if (typeof entry !== "number" || !Number.isInteger(entry)) continue; + if (entry < 0 || entry > PHASE_SIDEBAR_UNPRIORITISED_RANK) continue; + seen.add(entry); + } + return [...seen].sort((left, right) => left - right); +} + +/** + * Persisted filter blobs outlive the code that wrote them. Anything + * unrecognised is dropped rather than trusted, and an empty lifecycle set falls + * back to the default — a persisted "no lifecycles" would render a permanently + * empty table with no obvious way out. + */ +export function sanitizeSessionManagerFilters(value: unknown): SessionManagerFilters { + if (value === null || typeof value !== "object") return DEFAULT_SESSION_MANAGER_FILTERS; + const raw = value as Record<string, unknown>; + const lifecycles = sanitizeMemberArray(raw.lifecycles, SESSION_MANAGER_LIFECYCLES); + const staleDaysRaw = raw.staleDays; + const staleDays = + typeof staleDaysRaw === "number" && Number.isFinite(staleDaysRaw) && staleDaysRaw > 0 + ? staleDaysRaw + : null; + return { + search: typeof raw.search === "string" ? raw.search : "", + repositoryKeys: sanitizeStringArray(raw.repositoryKeys), + phaseIds: sanitizeMemberArray(raw.phaseIds, PHASE_SIDEBAR_PHASE_IDS), + providerKinds: sanitizeStringArray(raw.providerKinds), + priorities: sanitizePriorities(raw.priorities), + attentionKinds: sanitizeMemberArray(raw.attentionKinds, SESSION_MANAGER_ATTENTION_KINDS), + ownerUserIds: sanitizeStringArray(raw.ownerUserIds), + lifecycles: lifecycles.length > 0 ? lifecycles : DEFAULT_SESSION_MANAGER_FILTERS.lifecycles, + staleDays, + }; +} + +export function sanitizeSessionManagerSort(value: unknown): SessionManagerSort { + if (value === null || typeof value !== "object") return DEFAULT_SESSION_MANAGER_SORT; + const raw = value as Record<string, unknown>; + const columns: ReadonlyArray<SessionManagerSortColumn> = [ + "title", + "repository", + "phase", + "priority", + "progress", + "activity", + "created", + ]; + const column = columns.includes(raw.column as SessionManagerSortColumn) + ? (raw.column as SessionManagerSortColumn) + : DEFAULT_SESSION_MANAGER_SORT.column; + const direction = raw.direction === "desc" ? "desc" : "asc"; + return { column, direction }; +} + +/** + * Drop facet values whose option disappeared (a project was deleted, a provider + * instance was removed, a teammate left). A filter nobody can see in the UI but + * that still hides rows is the worst kind of stale state. + */ +export function reconcileSessionManagerFilters( + filters: SessionManagerFilters, + options: { + readonly repositoryKeys: ReadonlySet<string>; + readonly providerKinds: ReadonlySet<string>; + readonly ownerUserIds: ReadonlySet<string>; + }, +): SessionManagerFilters { + const repositoryKeys = filters.repositoryKeys.filter((key) => options.repositoryKeys.has(key)); + const providerKinds = filters.providerKinds.filter((kind) => options.providerKinds.has(kind)); + const ownerUserIds = filters.ownerUserIds.filter((id) => options.ownerUserIds.has(id)); + if ( + repositoryKeys.length === filters.repositoryKeys.length && + providerKinds.length === filters.providerKinds.length && + ownerUserIds.length === filters.ownerUserIds.length + ) { + return filters; + } + return { ...filters, repositoryKeys, providerKinds, ownerUserIds }; +} + +/* -------------------------------- run plan -------------------------------- */ + +/** + * Split rows into the ones an action can actually run on and the ones it + * can't, with a single reason string for the disabled tooltip. The toolbar + * needs both halves: the count to show, and the reason to explain. + */ +export interface SessionManagerActionPlan { + readonly eligible: ReadonlyArray<SessionManagerRow>; + readonly blocked: ReadonlyArray<SessionManagerRow>; + readonly disabledReason: string | null; +} + +export function planSessionManagerAction( + rows: ReadonlyArray<SessionManagerRow>, + predicate: (row: SessionManagerRow) => boolean, + reason: string, +): SessionManagerActionPlan { + const eligible: SessionManagerRow[] = []; + const blocked: SessionManagerRow[] = []; + for (const row of rows) { + if (predicate(row)) eligible.push(row); + else blocked.push(row); + } + return { + eligible, + blocked, + disabledReason: eligible.length === 0 && rows.length > 0 ? reason : null, + }; +} + +/* ------------------------------ misc helpers ------------------------------ */ + +/** Clamp a model-reported percent into something a progress bar can render. */ +export function clampWorkSummaryPercent(percent: number | null | undefined): number | null { + if (percent == null || !Number.isFinite(percent)) return null; + return Math.max(0, Math.min(100, Math.round(percent))); +} + +/** + * One-line preview of a ready summary. The full text lives in the expandable + * row detail; the cell must not wrap or the table's row height stops being + * predictable for the virtualizer. + */ +export function workSummaryPreview(summary: string | null | undefined): string | null { + if (summary == null) return null; + const collapsed = summary.replace(/\s+/g, " ").trim(); + return collapsed.length === 0 ? null : collapsed; +} + +export function buildSessionManagerSearchText(input: { + readonly title: string; + readonly branch: string | null; + readonly repositoryLabel: string; + readonly worktreePath: string | null; + readonly summary: string | null; + readonly remaining: string | null; + readonly linearIssueUrl: string | null | undefined; + readonly providerName: string; + readonly model: string; +}): string { + return [ + input.title, + input.branch ?? "", + input.repositoryLabel, + input.worktreePath ?? "", + input.summary ?? "", + input.remaining ?? "", + input.linearIssueUrl ?? "", + input.providerName, + input.model, + ] + .join(" ") + .toLowerCase(); +} diff --git a/apps/web/src/components/sessionManager/SessionManagerPage.tsx b/apps/web/src/components/sessionManager/SessionManagerPage.tsx new file mode 100644 index 00000000000..52cd64cac72 --- /dev/null +++ b/apps/web/src/components/sessionManager/SessionManagerPage.tsx @@ -0,0 +1,2015 @@ +/** + * T3-CUSTOM(expbkt3): Bulk session manager — a full-screen table of every + * session with rich filters, multi-row selection and a bulk-action toolbar. + * + * The sidebar is built for *navigating* a handful of sessions; this page is + * built for *administering* 10–100+ of them at once. It therefore reuses the + * sidebar's row derivation wholesale (phase, attention, priority, repository + * and provider keys all come from `PhaseGroupedSidebar.logic`) and adds only + * what a table needs: column sorting, a selection model, and fan-out actions. + * + * All non-visual decisions live in `SessionManagerPage.logic.ts`. + */ +import { LegendList } from "@legendapp/list/react"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { + canSnooze, + effectiveSettled, + effectiveSnoozed, + threadLastActivityAt, +} from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentId, ScopedThreadRef, ThreadPriority } from "@t3tools/contracts"; +import { useRouter } from "@tanstack/react-router"; +import { + AlertTriangleIcon, + ArchiveIcon, + BellIcon, + BotIcon, + CheckCircle2Icon, + ChevronDownIcon, + ClockIcon, + ExternalLinkIcon, + FilterIcon, + FlagIcon, + GitBranchIcon, + MessageSquarePlusIcon, + MoreHorizontalIcon, + OctagonPauseIcon, + PinIcon, + RefreshCwIcon, + Rows3Icon, + SearchIcon, + SparklesIcon, + Trash2Icon, + UserRoundIcon, + XIcon, +} from "lucide-react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { useThreadActions } from "../../hooks/useThreadActions"; +import { useNowMinute } from "../../hooks/useNowMinute"; +import { useClientSettings } from "../../hooks/useSettings"; +import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; +import { cn } from "../../lib/utils"; +import { useSessionManagerFilterStore } from "../../sessionManagerFilterStore"; +import { useSessionManagerSelectionStore } from "../../sessionManagerSelectionStore"; +import { + useProjects, + useServerConfigs, + useAllEnvironmentShellsBootstrapped, + useThreadShells, +} from "../../state/entities"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { userManagementEnvironment } from "../../state/users"; +import { useEnvironmentQuery } from "../../state/query"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import type { Project, ThreadShell } from "../../types"; +import { useUiStateStore } from "../../uiStateStore"; +import { hasUnseenCompletion } from "../Sidebar.logic"; +import { resolveSnoozePresets } from "../Sidebar.snooze"; +import { + buildPhaseSidebarRepositoryOptions, + derivePhaseSidebarRepositoryKey, + formatThreadPriority, + phaseSidebarPriorityRank, + resolvePhaseSidebarAttentionKind, + resolvePhaseSidebarPhase, + PHASE_SIDEBAR_PHASES, + PHASE_SIDEBAR_UNPRIORITISED_RANK, + type PhaseSidebarAttentionKind, + type PhaseSidebarPhaseId, +} from "../sidebar/PhaseGroupedSidebar.logic"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Skeleton } from "../ui/skeleton"; +import { Spinner } from "../ui/spinner"; +import { toastManager } from "../ui/toast"; +import { stackedThreadToast } from "../ui/toastHelpers"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + SESSION_MANAGER_ATTENTION_KINDS, + SESSION_MANAGER_ATTENTION_LABELS, + SESSION_MANAGER_LIFECYCLES, + SESSION_MANAGER_PHASE_LABELS, + SESSION_MANAGER_PRIORITY_CHOICES, + SESSION_MANAGER_SAVED_VIEWS, + SESSION_MANAGER_STAGE_LABELS, + SESSION_MANAGER_STALE_DAY_CHOICES, + applyFrozenRowOrder, + buildSessionManagerCounts, + buildSessionManagerFilterChips, + buildSessionManagerSearchText, + clampWorkSummaryPercent, + filterSessionManagerRows, + hasActiveSessionManagerFilters, + planSessionManagerAction, + sortSessionManagerRows, + workSummaryPreview, + type SessionManagerFilters, + type SessionManagerLifecycle, + type SessionManagerRow, + type SessionManagerSortColumn, +} from "./SessionManagerPage.logic"; + +/** + * One grid template shared by the sticky header and every row. A real + * `<table>` cannot be virtualized without losing column sync, so the table is + * a CSS grid and this constant is the single source of column geometry. + */ +const COLUMN_TEMPLATE = + "34px minmax(260px,2.3fr) 140px 112px 44px 168px minmax(220px,2fr) 110px 76px 32px"; + +const STALE_VIEW_DAYS = 7; + +/** Concurrency for the two LLM-backed bulk actions. */ +const LLM_BULK_CONCURRENCY = 3; + +type RowPendingKind = "title" | "summary"; + +interface BulkRunState { + readonly label: string; + readonly done: number; + readonly total: number; +} + +/* ------------------------------- concurrency ------------------------------ */ + +/** + * Run `worker` over `items` with at most `limit` in flight. The repo has no + * shared concurrency helper (the runtime serializes per thread but not across + * threads), and firing 100 LLM requests at once would trip provider rate + * limits, so the two generative actions are bounded here. + */ +async function mapWithConcurrency<T, R>( + items: ReadonlyArray<T>, + limit: number, + worker: (item: T, index: number) => Promise<R>, +): Promise<R[]> { + const results = Array.from({ length: items.length }) as R[]; + let cursor = 0; + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const index = cursor; + cursor += 1; + const item = items[index]; + if (item === undefined) return; + results[index] = await worker(item, index); + } + }); + await Promise.all(runners); + return results; +} + +type CommandOutcome = + | { readonly status: "success" } + | { readonly status: "interrupted" } + | { readonly status: "failure"; readonly error: unknown }; + +function toOutcome(result: AtomCommandResult<unknown, unknown>): CommandOutcome { + if (result._tag !== "Failure") return { status: "success" }; + if (isAtomCommandInterrupted(result)) return { status: "interrupted" }; + return { status: "failure", error: squashAtomCommandFailure(result) }; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + +/** One aggregate toast at the end of a run, never one per row. */ +function reportBulkOutcome( + label: string, + outcomes: ReadonlyArray<CommandOutcome>, + attempted: number, +): void { + const succeeded = outcomes.filter((outcome) => outcome.status === "success").length; + const failures = outcomes.flatMap((outcome) => + outcome.status === "failure" ? [outcome.error] : [], + ); + if (succeeded === 0 && failures.length === 0) return; + if (succeeded === 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `${label} failed`, + description: describeError(failures[0]), + }), + ); + return; + } + toastManager.add( + stackedThreadToast({ + type: failures.length > 0 ? "warning" : "success", + title: + failures.length > 0 + ? `${label}: ${succeeded} of ${attempted} sessions` + : `${label} — ${succeeded} session${succeeded === 1 ? "" : "s"}`, + description: + failures.length > 0 + ? `${failures.length} session${failures.length === 1 ? "" : "s"} failed: ${describeError(failures[0])}` + : undefined, + timeout: 6_000, + }), + ); +} + +/* ---------------------------------- chips --------------------------------- */ + +const PHASE_TONE: Record<PhaseSidebarPhaseId, string> = { + needs_input: "text-warning-foreground bg-warning-surface", + plan_ready: "text-info-foreground bg-info/10", + ready: "text-muted-foreground bg-muted", + planning: "text-info-foreground bg-info/10", + implementing: "text-primary bg-primary/10", +}; + +function PhaseBadge({ phaseId }: { phaseId: PhaseSidebarPhaseId }) { + return ( + <span + className={cn( + "inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium whitespace-nowrap", + PHASE_TONE[phaseId], + )} + > + {SESSION_MANAGER_PHASE_LABELS[phaseId]} + </span> + ); +} + +const PRIORITY_TONE = [ + "text-error-foreground bg-error-surface", + "text-warning-foreground bg-warning-surface", + "text-foreground bg-muted", + "text-muted-foreground bg-muted", + "text-muted-foreground bg-transparent", +]; + +function PriorityBadge({ rank }: { rank: number }) { + if (rank >= PHASE_SIDEBAR_UNPRIORITISED_RANK) { + return <span className="text-muted-foreground/50 font-mono text-[10px]">—</span>; + } + return ( + <span + className={cn( + "inline-flex items-center rounded px-1 py-0.5 font-mono text-[10px] font-semibold", + PRIORITY_TONE[rank], + )} + > + {formatThreadPriority(rank)} + </span> + ); +} + +const STAGE_TONE: Record<string, string> = { + planning: "bg-info", + implementing: "bg-primary", + blocked: "bg-error", + "awaiting-review": "bg-warning", + done: "bg-success", +}; + +function LifecycleIcon({ row }: { row: SessionManagerRow }) { + if (row.lifecycle === "archived") { + return <ArchiveIcon aria-label="Archived" className="text-muted-foreground size-3.5" />; + } + if (row.lifecycle === "snoozed") { + return <ClockIcon aria-label="Snoozed" className="text-info size-3.5" />; + } + if (row.attentionKind === "error") { + return <AlertTriangleIcon aria-label="Error" className="text-error size-3.5" />; + } + if (row.attentionKind === "approval") { + return <BellIcon aria-label="Waiting for approval" className="text-warning size-3.5" />; + } + if (row.attentionKind === "input") { + return <MessageSquarePlusIcon aria-label="Waiting for input" className="text-info size-3.5" />; + } + if (row.phaseId === "planning" || row.phaseId === "implementing") { + return <Spinner className="text-primary size-3.5" />; + } + if (row.lifecycle === "settled") { + return <CheckCircle2Icon aria-label="Settled" className="text-success size-3.5" />; + } + return <span className="bg-muted-foreground/30 size-1.5 shrink-0 rounded-full" />; +} + +/* --------------------------------- facets --------------------------------- */ + +interface FacetOption<T extends string | number> { + readonly value: T; + readonly label: string; + readonly count?: number; +} + +function FacetPopover<T extends string | number>({ + label, + icon: Icon, + options, + selected, + onToggle, + onClear, +}: { + label: string; + icon: typeof FilterIcon; + options: ReadonlyArray<FacetOption<T>>; + selected: ReadonlyArray<T>; + onToggle: (value: T) => void; + onClear: () => void; +}) { + const [query, setQuery] = useState(""); + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) return options; + return options.filter((option) => option.label.toLowerCase().includes(needle)); + }, [options, query]); + + return ( + <Popover> + <PopoverTrigger + className={cn( + "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border px-2 text-xs whitespace-nowrap transition-colors", + selected.length > 0 + ? "border-primary/40 bg-primary/10 text-foreground" + : "border-border bg-card text-muted-foreground hover:text-foreground", + )} + > + <Icon className="size-3.5" /> + {label} + {selected.length > 0 ? ( + <span className="bg-primary text-primary-foreground rounded px-1 font-mono text-[10px]"> + {selected.length} + </span> + ) : ( + <ChevronDownIcon className="size-3 opacity-50" /> + )} + </PopoverTrigger> + <PopoverPopup align="start" className="w-64 p-0" viewportClassName="p-0"> + {options.length > 8 ? ( + <div className="border-border border-b p-1.5"> + <input + autoFocus + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder={`Filter ${label.toLowerCase()}…`} + className="placeholder:text-muted-foreground h-6 w-full bg-transparent px-1 text-xs outline-none" + /> + </div> + ) : null} + <div className="max-h-72 overflow-y-auto py-1"> + {visible.length === 0 ? ( + <p className="text-muted-foreground px-2.5 py-2 text-xs">No matches.</p> + ) : null} + {visible.map((option) => { + const active = selected.includes(option.value); + return ( + <button + key={String(option.value)} + type="button" + onClick={() => onToggle(option.value)} + className="hover:bg-accent flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs" + > + <Checkbox + checked={active} + aria-label={option.label} + onCheckedChange={() => onToggle(option.value)} + className="pointer-events-none" + /> + <span className="min-w-0 flex-1 truncate">{option.label}</span> + {option.count !== undefined ? ( + <span className="text-muted-foreground font-mono text-[10px]"> + {option.count} + </span> + ) : null} + </button> + ); + })} + </div> + {selected.length > 0 ? ( + <div className="border-border border-t p-1"> + <Button variant="ghost" size="xs" className="w-full justify-start" onClick={onClear}> + Clear {label.toLowerCase()} + </Button> + </div> + ) : null} + </PopoverPopup> + </Popover> + ); +} + +/* ---------------------------------- cells --------------------------------- */ + +function WorkSummaryProgressCell({ row, pending }: { row: SessionManagerRow; pending: boolean }) { + const summary = row.workSummary; + if (pending || summary?.status === "pending") { + return ( + <span className="text-muted-foreground flex items-center gap-1.5 text-[10px]"> + <Spinner className="size-3" /> Summarizing… + </span> + ); + } + if (summary?.status === "error") { + return <span className="text-muted-foreground/60 text-[11px]">—</span>; + } + if (summary == null || summary.stage === null) { + return <span className="text-muted-foreground/60 text-[11px]">—</span>; + } + const percent = clampWorkSummaryPercent(summary.percent); + return ( + <div className="flex min-w-0 flex-col gap-1"> + <div className="flex items-center gap-1.5"> + <span className="text-[10px] font-medium whitespace-nowrap"> + {SESSION_MANAGER_STAGE_LABELS[summary.stage]} + </span> + {percent !== null ? ( + <span className="text-muted-foreground font-mono text-[10px]">{percent}%</span> + ) : null} + </div> + <div className="bg-muted h-1 w-full overflow-hidden rounded-full"> + <div + className={cn("h-full rounded-full", STAGE_TONE[summary.stage])} + style={{ width: `${percent ?? 0}%` }} + /> + </div> + </div> + ); +} + +function WorkSummaryTextCell({ + row, + pending, + onToggleExpanded, +}: { + row: SessionManagerRow; + pending: boolean; + onToggleExpanded: () => void; +}) { + const summary = row.workSummary; + if (pending || summary?.status === "pending") { + return ( + <span className="text-muted-foreground flex items-center gap-1.5 text-[11px]"> + <Spinner className="size-3" /> Summarizing… + </span> + ); + } + if (summary?.status === "error") { + return ( + <Tooltip> + <TooltipTrigger + render={ + <span className="text-error-foreground bg-error-surface inline-flex max-w-full items-center gap-1 truncate rounded px-1.5 py-0.5 text-[10px]" /> + } + > + <AlertTriangleIcon className="size-3 shrink-0" /> + Summary failed + </TooltipTrigger> + <TooltipPopup side="top" className="max-w-80"> + {summary.error ?? "The server did not report a reason."} + </TooltipPopup> + </Tooltip> + ); + } + const preview = workSummaryPreview(summary?.summary); + if (preview === null) { + return <span className="text-muted-foreground/60 text-[11px]">no summary yet</span>; + } + return ( + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + onToggleExpanded(); + }} + className="text-muted-foreground hover:text-foreground w-full truncate text-left text-[11px]" + title={preview} + > + {preview} + </button> + ); +} + +/* ---------------------------------- rows ---------------------------------- */ + +interface RowActions { + readonly onOpen: (row: SessionManagerRow) => void; + readonly onToggleSelection: (row: SessionManagerRow, event: React.MouseEvent) => void; + readonly onToggleExpanded: (key: string) => void; + readonly onRetitle: (rows: ReadonlyArray<SessionManagerRow>) => void; + readonly onSummarize: (rows: ReadonlyArray<SessionManagerRow>) => void; + readonly onSettle: (rows: ReadonlyArray<SessionManagerRow>) => void; + readonly onArchive: (rows: ReadonlyArray<SessionManagerRow>) => void; + readonly onDelete: (rows: ReadonlyArray<SessionManagerRow>) => void; +} + +const SessionManagerTableRow = memo(function SessionManagerTableRow({ + row, + selected, + expanded, + dense, + pending, + actions, +}: { + row: SessionManagerRow; + selected: boolean; + expanded: boolean; + dense: boolean; + pending: RowPendingKind | undefined; + actions: RowActions; +}) { + const summary = row.workSummary; + const summaryFailed = summary?.status === "error"; + return ( + <div className="border-border/60 border-b"> + <div + onClick={(event) => { + if (event.metaKey || event.ctrlKey || event.shiftKey) { + actions.onToggleSelection(row, event); + return; + } + actions.onOpen(row); + }} + className={cn( + "group grid cursor-default items-center gap-2 px-4 transition-colors", + dense ? "py-1.5" : "py-2", + selected ? "bg-primary/10" : "hover:bg-accent/50", + )} + style={{ gridTemplateColumns: COLUMN_TEMPLATE }} + > + <span onClick={(event) => event.stopPropagation()}> + <Checkbox + checked={selected} + aria-label={`Select ${row.thread.title}`} + onCheckedChange={() => undefined} + onClick={(event) => actions.onToggleSelection(row, event)} + /> + </span> + + <div className="flex min-w-0 items-center gap-2"> + <LifecycleIcon row={row} /> + <div className="min-w-0"> + <div className="flex items-center gap-1.5"> + {row.isUnreadCompletion ? ( + <span className="bg-primary size-1.5 shrink-0 rounded-full" /> + ) : null} + {row.isPinned ? ( + <PinIcon className="text-muted-foreground size-3 shrink-0" aria-label="Pinned" /> + ) : null} + <span + className={cn( + "truncate text-xs", + row.isUnreadCompletion ? "font-semibold" : "font-medium", + pending === "title" && "text-muted-foreground italic", + )} + > + {pending === "title" ? "Regenerating title…" : row.thread.title} + </span> + {pending === "title" ? <Spinner className="text-primary size-3 shrink-0" /> : null} + </div> + {!dense ? ( + <div className="text-muted-foreground mt-0.5 flex items-center gap-2 text-[10px]"> + {row.thread.branch !== null ? ( + <span className="truncate font-mono">{row.thread.branch}</span> + ) : null} + {row.thread.linearIssueUrl ? ( + <span className="shrink-0 truncate">{row.thread.linearIssueUrl}</span> + ) : null} + </div> + ) : null} + </div> + </div> + + <span className="text-muted-foreground truncate text-[11px]">{row.repositoryLabel}</span> + <span> + <PhaseBadge phaseId={row.phaseId} /> + </span> + <span> + <PriorityBadge rank={row.priorityRank} /> + </span> + + <div className="min-w-0"> + <WorkSummaryProgressCell row={row} pending={pending === "summary"} /> + </div> + + <div className="min-w-0"> + <WorkSummaryTextCell + row={row} + pending={pending === "summary"} + onToggleExpanded={() => actions.onToggleExpanded(row.key)} + /> + </div> + + <span className="text-muted-foreground truncate text-[11px]">{row.modelLabel}</span> + <span className="text-muted-foreground font-mono text-[11px]"> + {row.lastActivityAt === null ? "—" : formatRelativeTimeLabel(row.lastActivityAt)} + </span> + + <div + className="flex justify-end opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100" + onClick={(event) => event.stopPropagation()} + > + <Menu> + <MenuTrigger + aria-label={`Actions for ${row.thread.title}`} + className="hover:bg-accent rounded p-0.5" + > + <MoreHorizontalIcon className="size-3.5" /> + </MenuTrigger> + <MenuPopup align="end" className="min-w-48"> + <MenuItem onClick={() => actions.onOpen(row)}> + <ExternalLinkIcon /> Open session + </MenuItem> + <MenuItem + disabled={!row.capabilities.titleRegeneration} + onClick={() => actions.onRetitle([row])} + > + <RefreshCwIcon /> Regenerate title + </MenuItem> + <MenuItem + disabled={!row.capabilities.workSummary} + onClick={() => actions.onSummarize([row])} + > + <SparklesIcon /> {summaryFailed ? "Retry summary" : "Summarize"} + </MenuItem> + <MenuItem + disabled={!row.capabilities.settlement} + onClick={() => actions.onSettle([row])} + > + <CheckCircle2Icon /> Settle + </MenuItem> + <MenuItem onClick={() => actions.onArchive([row])}> + <ArchiveIcon /> Archive + </MenuItem> + <MenuSeparator /> + <MenuItem variant="destructive" onClick={() => actions.onDelete([row])}> + <Trash2Icon /> Delete… + </MenuItem> + </MenuPopup> + </Menu> + </div> + </div> + + {expanded ? ( + <div className="bg-muted/40 border-border/60 grid gap-4 border-t px-4 py-3 text-[11px] md:grid-cols-[1.6fr_1fr]"> + <div> + <p className="text-muted-foreground mb-1 flex items-center gap-1.5 font-medium"> + <SparklesIcon className="size-3" /> AI work summary + {summary !== null ? ( + <span className="text-muted-foreground/70"> + · {formatRelativeTimeLabel(summary.updatedAt)} + </span> + ) : null} + </p> + <p className="leading-relaxed"> + {summary?.summary ?? summary?.error ?? "No summary has been generated yet."} + </p> + {summary?.remaining ? ( + <p className="mt-2"> + <span className="text-muted-foreground">Remaining: </span> + {summary.remaining} + </p> + ) : null} + </div> + <div className="text-muted-foreground space-y-1"> + <p> + <span className="inline-block w-20">Owner</span> + <span className="text-foreground">{row.ownerLabel}</span> + </p> + <p> + <span className="inline-block w-20">Created</span> + <span className="text-foreground"> + {formatRelativeTimeLabel(row.thread.createdAt)} + </span> + </p> + <p> + <span className="inline-block w-20">Worktree</span> + <span className="text-foreground font-mono text-[10px] break-all"> + {row.thread.worktreePath ?? "—"} + </span> + </p> + </div> + </div> + ) : null} + </div> + ); +}); + +/* ---------------------------------- page ---------------------------------- */ + +export function SessionManagerPage() { + const router = useRouter(); + const threads = useThreadShells(); + const projects = useProjects(); + const serverConfigs = useServerConfigs(); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const lastVisitedAtByThreadKey = useUiStateStore((state) => state.threadLastVisitedAtById); + const markThreadVisited = useUiStateStore((state) => state.markThreadVisited); + const timestampFormat = useClientSettings((settings) => settings.timestampFormat); + const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + const nowMinute = useNowMinute(); + // `useNowMinute` yields "YYYY-MM-DDTHH:MM", which Date.parse reads as LOCAL + // time. Re-anchor it to UTC so the idle-for filter measures real elapsed + // time rather than the viewer's offset. + const nowIso = useMemo(() => `${nowMinute}:00.000Z`, [nowMinute]); + + const filters = useSessionManagerFilterStore( + useShallow( + (state): SessionManagerFilters => ({ + search: state.search, + repositoryKeys: state.repositoryKeys, + phaseIds: state.phaseIds, + providerKinds: state.providerKinds, + priorities: state.priorities, + attentionKinds: state.attentionKinds, + ownerUserIds: state.ownerUserIds, + lifecycles: state.lifecycles, + staleDays: state.staleDays, + }), + ), + ); + const sort = useSessionManagerFilterStore((state) => state.sort); + const activeViewId = useSessionManagerFilterStore((state) => state.activeViewId); + const filterActions = useSessionManagerFilterStore( + useShallow((state) => ({ + setSearch: state.setSearch, + toggleRepository: state.toggleRepository, + togglePhase: state.togglePhase, + toggleProvider: state.toggleProvider, + togglePriority: state.togglePriority, + toggleAttention: state.toggleAttention, + toggleOwner: state.toggleOwner, + toggleLifecycle: state.toggleLifecycle, + setStaleDays: state.setStaleDays, + setFacet: state.setFacet, + cycleSort: state.cycleSort, + applyView: state.applyView, + clearAll: state.clearAll, + reconcile: state.reconcile, + })), + ); + + const selectedThreadKeys = useSessionManagerSelectionStore((state) => state.selectedThreadKeys); + const selectionActions = useSessionManagerSelectionStore( + useShallow((state) => ({ + toggleThread: state.toggleThread, + rangeSelectTo: state.rangeSelectTo, + replaceSelection: state.replaceSelection, + clearSelection: state.clearSelection, + removeFromSelection: state.removeFromSelection, + pruneSelection: state.pruneSelection, + })), + ); + + const [dense, setDense] = useState(false); + const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(() => new Set()); + const [pendingByKey, setPendingByKey] = useState<ReadonlyMap<string, RowPendingKind>>( + () => new Map(), + ); + const [bulkRun, setBulkRun] = useState<BulkRunState | null>(null); + const [deleteDialogRows, setDeleteDialogRows] = useState<ReadonlyArray<SessionManagerRow> | null>( + null, + ); + const frozenOrderRef = useRef<ReadonlyArray<string> | null>(null); + + const { archiveThread, settleThread, snoozeThread, pinThread, unpinThread, deleteThread } = + useThreadActions(); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const requestWorkSummary = useAtomCommand(threadEnvironment.requestWorkSummary, { + reportFailure: false, + }); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, { + reportFailure: false, + }); + + /* ------------------------------ archived rows ----------------------------- */ + + const includeArchived = filters.lifecycles.includes("archived"); + const archivedEnvironmentIds = useMemo<ReadonlyArray<EnvironmentId>>(() => { + if (!includeArchived) return EMPTY_ENVIRONMENT_IDS; + return [...new Set(projects.map((project) => project.environmentId))]; + }, [includeArchived, projects]); + const { snapshots: archivedSnapshots } = useArchivedThreadSnapshots(archivedEnvironmentIds); + const archivedThreads = useMemo<ReadonlyArray<ThreadShell>>( + () => + archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ ...thread, environmentId })), + ), + [archivedSnapshots], + ); + + /* -------------------------------- directory ------------------------------- */ + + // Owner labels are best-effort: the directory RPC only answers in team mode, + // and a raw Clerk id is still a usable (if ugly) facet value without it. + const directoryQuery = useEnvironmentQuery( + primaryEnvironmentId === null + ? null + : userManagementEnvironment.directory({ environmentId: primaryEnvironmentId, input: {} }), + ); + const ownerLabelById = useMemo(() => { + const labels = new Map<string, string>(); + for (const user of directoryQuery.data?.users ?? []) { + const label = user.displayName ?? user.primaryEmail ?? String(user.id); + labels.set(String(user.id), label); + labels.set(String(user.identity.subject), label); + } + return labels; + }, [directoryQuery.data]); + + /* --------------------------------- rows ---------------------------------- */ + + const projectByKey = useMemo( + () => + new Map<string, Project>( + projects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + project, + ]), + ), + [projects], + ); + const repositoryOptions = useMemo(() => buildPhaseSidebarRepositoryOptions(projects), [projects]); + const repositoryLabels = useMemo( + () => new Map(repositoryOptions.map((option) => [option.key, option.label])), + [repositoryOptions], + ); + + const allRows = useMemo<ReadonlyArray<SessionManagerRow>>(() => { + const source = includeArchived ? [...threads, ...archivedThreads] : threads; + const seen = new Set<string>(); + const rows: SessionManagerRow[] = []; + for (const thread of source) { + const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + // The archived snapshot and the live shell can both carry a thread that + // was archived a moment ago; the live one wins. + if (seen.has(key)) continue; + seen.add(key); + + const projectKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const project = projectByKey.get(projectKey); + const repositoryKey = project ? derivePhaseSidebarRepositoryKey(project) : projectKey; + const serverConfig = serverConfigs.get(thread.environmentId); + const capabilitiesRaw = serverConfig?.environment.capabilities; + // `threadWorkSummary` is newer than this page; a server that does not + // advertise it either way is assumed capable so the action is not dead + // on arrival, and a server that says `false` disables it explicitly. + const workSummaryCapable = + (capabilitiesRaw as { readonly threadWorkSummary?: boolean } | undefined) + ?.threadWorkSummary !== false; + const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const provider = serverConfig?.providers.find( + (candidate) => candidate.instanceId === instanceId, + ); + const providerKind = String(provider?.driver ?? instanceId); + const providerName = + provider?.displayName ?? thread.session?.providerName ?? String(instanceId); + const repositoryLabel = + project?.title ?? repositoryLabels.get(repositoryKey) ?? "Unknown repository"; + const workSummary = thread.workSummary ?? null; + const ownerUserId = thread.ownerUserId === null ? null : String(thread.ownerUserId); + + const snoozed = + capabilitiesRaw?.threadSnooze === true && effectiveSnoozed(thread, { now: nowIso }); + const settled = + capabilitiesRaw?.threadSettlement === true && + effectiveSettled(thread, { now: nowIso, autoSettleAfterDays }); + const lifecycle: SessionManagerLifecycle = + thread.archivedAt !== null + ? "archived" + : snoozed + ? "snoozed" + : settled + ? "settled" + : "active"; + + rows.push({ + key, + thread, + lifecycle, + phaseId: resolvePhaseSidebarPhase(thread, null), + attentionKind: resolvePhaseSidebarAttentionKind(thread), + repositoryKey, + repositoryLabel, + providerKind, + providerName, + modelLabel: String(thread.modelSelection.model), + ownerUserId, + ownerLabel: + ownerUserId === null ? "Unassigned" : (ownerLabelById.get(ownerUserId) ?? ownerUserId), + priorityRank: phaseSidebarPriorityRank(thread), + lastActivityAt: threadLastActivityAt(thread) ?? thread.updatedAt, + isUnreadCompletion: hasUnseenCompletion({ + ...thread, + lastVisitedAt: lastVisitedAtByThreadKey[key], + }), + isPinned: thread.pinnedAt != null, + workSummary, + capabilities: { + settlement: capabilitiesRaw?.threadSettlement === true, + snooze: capabilitiesRaw?.threadSnooze === true, + pinning: capabilitiesRaw?.threadPinning === true, + priority: capabilitiesRaw?.threadPriority === true, + titleRegeneration: capabilitiesRaw?.threadTitleRegeneration === true, + workSummary: workSummaryCapable, + }, + canStop: thread.execution?.canStop === true, + searchText: buildSessionManagerSearchText({ + title: thread.title, + branch: thread.branch, + repositoryLabel, + worktreePath: thread.worktreePath, + summary: workSummary?.summary ?? null, + remaining: workSummary?.remaining ?? null, + linearIssueUrl: thread.linearIssueUrl, + providerName, + model: String(thread.modelSelection.model), + }), + }); + } + return rows; + }, [ + archivedThreads, + autoSettleAfterDays, + includeArchived, + lastVisitedAtByThreadKey, + nowIso, + ownerLabelById, + projectByKey, + repositoryLabels, + serverConfigs, + threads, + ]); + + const counts = useMemo( + () => buildSessionManagerCounts(allRows, { now: nowIso, staleDays: STALE_VIEW_DAYS }), + [allRows, nowIso], + ); + + const sortedRows = useMemo( + () => sortSessionManagerRows(filterSessionManagerRows(allRows, filters, { now: nowIso }), sort), + [allRows, filters, nowIso, sort], + ); + + // Freeze order while a selection is live so a bulk run cannot move a row out + // from under the cursor. Cleared the moment the selection empties. + const hasSelection = selectedThreadKeys.size > 0; + const rows = useMemo(() => { + if (!hasSelection) { + frozenOrderRef.current = null; + return sortedRows; + } + // Snapshot the order the user selected against, then keep serving it until + // the selection empties. Content still updates live; only position is held. + frozenOrderRef.current ??= sortedRows.map((row) => row.key); + return applyFrozenRowOrder(sortedRows, frozenOrderRef.current); + }, [sortedRows, hasSelection]); + + const visibleKeys = useMemo(() => rows.map((row) => row.key), [rows]); + const selectedRows = useMemo( + () => rows.filter((row) => selectedThreadKeys.has(row.key)), + [rows, selectedThreadKeys], + ); + const allVisibleSelected = + visibleKeys.length > 0 && visibleKeys.every((key) => selectedThreadKeys.has(key)); + const someVisibleSelected = visibleKeys.some((key) => selectedThreadKeys.has(key)); + + // Everything a row renders that does not live in `rows` itself. Identity has + // to change whenever one of these does, or the virtualized rows keep showing + // stale checkboxes, spinners and density. + const rowRenderExtraData = useMemo( + () => ({ selectedThreadKeys, expandedKeys, pendingByKey, dense }), + [selectedThreadKeys, expandedKeys, pendingByKey, dense], + ); + + // Rows are live atoms; a session deleted elsewhere must not keep inflating + // the toolbar's count. + const liveKeySet = useMemo(() => new Set(allRows.map((row) => row.key)), [allRows]); + useEffect(() => { + selectionActions.pruneSelection(liveKeySet); + }, [liveKeySet, selectionActions]); + + // Facet options are derived from the rows themselves so a facet never offers + // a value that matches nothing. + const repositoryFacetOptions = useMemo(() => { + const counted = new Map<string, { label: string; count: number }>(); + for (const row of allRows) { + const existing = counted.get(row.repositoryKey); + if (existing) existing.count += 1; + else counted.set(row.repositoryKey, { label: row.repositoryLabel, count: 1 }); + } + return [...counted] + .map(([value, entry]) => ({ value, label: entry.label, count: entry.count })) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [allRows]); + + const providerFacetOptions = useMemo(() => { + const counted = new Map<string, { label: string; count: number }>(); + for (const row of allRows) { + const existing = counted.get(row.providerKind); + if (existing) existing.count += 1; + else counted.set(row.providerKind, { label: row.providerName, count: 1 }); + } + return [...counted] + .map(([value, entry]) => ({ value, label: entry.label, count: entry.count })) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [allRows]); + + const ownerFacetOptions = useMemo(() => { + const counted = new Map<string, { label: string; count: number }>(); + for (const row of allRows) { + if (row.ownerUserId === null) continue; + const existing = counted.get(row.ownerUserId); + if (existing) existing.count += 1; + else counted.set(row.ownerUserId, { label: row.ownerLabel, count: 1 }); + } + return [...counted] + .map(([value, entry]) => ({ value, label: entry.label, count: entry.count })) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [allRows]); + + const reconcile = filterActions.reconcile; + useEffect(() => { + reconcile({ + repositoryKeys: new Set(repositoryFacetOptions.map((option) => option.value)), + providerKinds: new Set(providerFacetOptions.map((option) => option.value)), + ownerUserIds: new Set(ownerFacetOptions.map((option) => option.value)), + }); + }, [ownerFacetOptions, providerFacetOptions, reconcile, repositoryFacetOptions]); + + const chips = useMemo( + () => + buildSessionManagerFilterChips(filters, { + repositories: repositoryLabels, + providers: new Map(providerFacetOptions.map((option) => [option.value, option.label])), + owners: new Map(ownerFacetOptions.map((option) => [option.value, option.label])), + }), + [filters, ownerFacetOptions, providerFacetOptions, repositoryLabels], + ); + + /* ------------------------------- navigation ------------------------------- */ + + const openRow = useCallback( + (row: SessionManagerRow) => { + const threadRef = scopeThreadRef(row.thread.environmentId, row.thread.id); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [router], + ); + + const toggleSelection = useCallback( + (row: SessionManagerRow, event: React.MouseEvent) => { + event.stopPropagation(); + if (event.shiftKey) { + selectionActions.rangeSelectTo(row.key, visibleKeys); + return; + } + selectionActions.toggleThread(row.key); + }, + [selectionActions, visibleKeys], + ); + + const toggleExpanded = useCallback((key: string) => { + setExpandedKeys((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + /* ------------------------------ bulk actions ------------------------------ */ + + const setRowPending = useCallback((keys: ReadonlyArray<string>, kind: RowPendingKind | null) => { + setPendingByKey((current) => { + const next = new Map(current); + for (const key of keys) { + if (kind === null) next.delete(key); + else next.set(key, kind); + } + return next; + }); + }, []); + + const runBulk = useCallback( + async (options: { + label: string; + rows: ReadonlyArray<SessionManagerRow>; + pendingKind?: RowPendingKind; + concurrency?: number; + clearSelectionWhenDone?: boolean; + run: (row: SessionManagerRow) => Promise<CommandOutcome>; + }): Promise<ReadonlyArray<CommandOutcome>> => { + const targets = options.rows; + if (targets.length === 0) return []; + setBulkRun({ label: options.label, done: 0, total: targets.length }); + if (options.pendingKind !== undefined) { + setRowPending( + targets.map((row) => row.key), + options.pendingKind, + ); + } + const outcomes = await mapWithConcurrency( + targets, + options.concurrency ?? targets.length, + async (row) => { + const outcome = await options.run(row); + if (options.pendingKind !== undefined) setRowPending([row.key], null); + setBulkRun((current) => + current === null + ? current + : { ...current, done: Math.min(current.done + 1, current.total) }, + ); + return outcome; + }, + ); + setBulkRun(null); + reportBulkOutcome(options.label, outcomes, targets.length); + if (options.clearSelectionWhenDone !== false) selectionActions.clearSelection(); + return outcomes; + }, + [selectionActions, setRowPending], + ); + + const handleRetitle = useCallback( + (targets: ReadonlyArray<SessionManagerRow>) => { + const eligible = targets.filter((row) => row.capabilities.titleRegeneration); + void runBulk({ + label: "Regenerated titles", + rows: eligible, + pendingKind: "title", + concurrency: LLM_BULK_CONCURRENCY, + // The retitle lands as a live shell update; keeping the selection lets + // the user follow it with another action on the same set. + clearSelectionWhenDone: false, + run: async (row) => + toOutcome( + await updateThreadMetadata({ + environmentId: row.thread.environmentId, + input: { threadId: row.thread.id, regenerateTitle: true }, + }), + ), + }); + }, + [runBulk, updateThreadMetadata], + ); + + const handleSummarize = useCallback( + (targets: ReadonlyArray<SessionManagerRow>) => { + const eligible = targets.filter((row) => row.capabilities.workSummary); + void runBulk({ + label: "Summarized", + rows: eligible, + pendingKind: "summary", + concurrency: LLM_BULK_CONCURRENCY, + clearSelectionWhenDone: false, + run: async (row) => + toOutcome( + await requestWorkSummary({ + environmentId: row.thread.environmentId, + input: { threadId: row.thread.id }, + }), + ), + }); + }, + [requestWorkSummary, runBulk], + ); + + const handleSetPriority = useCallback( + (targets: ReadonlyArray<SessionManagerRow>, priority: ThreadPriority) => { + void runBulk({ + label: `Set ${formatThreadPriority(priority)}`, + rows: targets.filter((row) => row.capabilities.priority), + run: async (row) => + toOutcome( + await updateThreadMetadata({ + environmentId: row.thread.environmentId, + input: { threadId: row.thread.id, priority }, + }), + ), + }); + }, + [runBulk, updateThreadMetadata], + ); + + const handleSnooze = useCallback( + (targets: ReadonlyArray<SessionManagerRow>, snoozedUntil: string, label: string) => { + void runBulk({ + label: `Snoozed until ${label}`, + rows: targets.filter( + (row) => row.capabilities.snooze && canSnooze(row.thread, { now: nowIso }), + ), + run: async (row) => + toOutcome( + await snoozeThread( + scopeThreadRef(row.thread.environmentId, row.thread.id), + snoozedUntil, + ), + ), + }); + }, + [nowIso, runBulk, snoozeThread], + ); + + const handleSettle = useCallback( + (targets: ReadonlyArray<SessionManagerRow>) => { + void runBulk({ + label: "Settled", + rows: targets.filter((row) => row.capabilities.settlement), + run: async (row) => + toOutcome(await settleThread(scopeThreadRef(row.thread.environmentId, row.thread.id))), + }); + }, + [runBulk, settleThread], + ); + + const handleArchive = useCallback( + (targets: ReadonlyArray<SessionManagerRow>) => { + void runBulk({ + label: "Archived", + rows: targets, + run: async (row) => + toOutcome(await archiveThread(scopeThreadRef(row.thread.environmentId, row.thread.id))), + }); + }, + [archiveThread, runBulk], + ); + + const handlePin = useCallback( + (targets: ReadonlyArray<SessionManagerRow>) => { + const eligible = targets.filter((row) => row.capabilities.pinning); + // Mixed selections resolve toward pinning: unpinning is the destructive + // reading of an ambiguous click. + const shouldPin = eligible.some((row) => !row.isPinned); + void runBulk({ + label: shouldPin ? "Pinned" : "Unpinned", + rows: eligible.filter((row) => row.isPinned !== shouldPin), + run: async (row) => { + const threadRef: ScopedThreadRef = scopeThreadRef( + row.thread.environmentId, + row.thread.id, + ); + return toOutcome(await (shouldPin ? pinThread(threadRef) : unpinThread(threadRef))); + }, + }); + }, + [pinThread, runBulk, unpinThread], + ); + + const handleMarkRead = useCallback( + (targets: ReadonlyArray<SessionManagerRow>) => { + const visitedAt = new Date().toISOString(); + for (const row of targets) markThreadVisited(row.key, visitedAt); + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Marked ${targets.length} session${targets.length === 1 ? "" : "s"} read`, + timeout: 4_000, + }), + ); + selectionActions.clearSelection(); + }, + [markThreadVisited, selectionActions], + ); + + const handleStopAgent = useCallback( + (targets: ReadonlyArray<SessionManagerRow>) => { + void runBulk({ + label: "Stopped agents", + rows: targets.filter((row) => row.canStop), + run: async (row) => + toOutcome( + await stopThreadSession({ + environmentId: row.thread.environmentId, + input: { threadId: row.thread.id }, + }), + ), + }); + }, + [runBulk, stopThreadSession], + ); + + const confirmDelete = useCallback(async () => { + const targets = deleteDialogRows ?? []; + setDeleteDialogRows(null); + if (targets.length === 0) return; + setBulkRun({ label: "Deleting", done: 0, total: targets.length }); + // Deletion stays sequential and grows `deletedThreadKeys` as it goes: + // orphaned-worktree detection must only discount threads that are really + // gone, or the first delete removes a worktree its batch mates still use. + const deletedThreadKeys = new Set<string>(); + const outcomes: CommandOutcome[] = []; + for (const row of targets) { + const outcome = toOutcome( + await deleteThread(scopeThreadRef(row.thread.environmentId, row.thread.id), { + deletedThreadKeys, + }), + ); + outcomes.push(outcome); + setBulkRun((current) => + current === null ? current : { ...current, done: current.done + 1 }, + ); + if (outcome.status !== "success") break; + deletedThreadKeys.add(row.key); + } + setBulkRun(null); + reportBulkOutcome("Deleted", outcomes, targets.length); + selectionActions.removeFromSelection([...deletedThreadKeys]); + }, [deleteDialogRows, deleteThread, selectionActions]); + + /* -------------------------------- keyboard -------------------------------- */ + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + const target = event.target as HTMLElement | null; + const inField = + target !== null && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable === true); + + if (event.key === "Escape") { + // Escape clears the selection first, and only leaves the page once + // nothing is selected — losing a 60-row selection to a stray Escape + // would be the single most expensive misfire on this page. + if (selectedThreadKeys.size > 0) { + event.preventDefault(); + selectionActions.clearSelection(); + } + return; + } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") { + if (inField) return; + event.preventDefault(); + selectionActions.replaceSelection(visibleKeys); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [selectedThreadKeys.size, selectionActions, visibleKeys]); + + /* --------------------------------- render --------------------------------- */ + + const rowActions = useMemo<RowActions>( + () => ({ + onOpen: openRow, + onToggleSelection: toggleSelection, + onToggleExpanded: toggleExpanded, + onRetitle: handleRetitle, + onSummarize: handleSummarize, + onSettle: handleSettle, + onArchive: handleArchive, + onDelete: (targets) => setDeleteDialogRows(targets), + }), + [ + handleArchive, + handleRetitle, + handleSettle, + handleSummarize, + openRow, + toggleExpanded, + toggleSelection, + ], + ); + + const headerCell = (column: SessionManagerSortColumn, label: string) => ( + <button + type="button" + onClick={() => filterActions.cycleSort(column)} + className={cn( + "hover:text-foreground flex items-center gap-1 truncate text-left transition-colors", + sort.column === column ? "text-foreground" : "text-muted-foreground", + )} + > + {label} + {sort.column === column ? ( + <ChevronDownIcon + className={cn("size-3 shrink-0", sort.direction === "desc" && "rotate-180")} + /> + ) : null} + </button> + ); + + const snoozePresets = useMemo(() => { + // Re-resolve each minute so "This evening" does not go stale on a page + // left open all afternoon; the tick is the dependency, not the clock read. + void nowMinute; + return resolveSnoozePresets(new Date(), timestampFormat); + }, [nowMinute, timestampFormat]); + + return ( + <div className="flex h-full min-h-0 flex-col"> + <div className="border-border flex h-[52px] shrink-0 items-center gap-3 border-b px-4"> + <div className="flex min-w-0 shrink-0 items-center gap-2"> + <Rows3Icon className="text-muted-foreground size-4" /> + <h1 className="text-sm font-semibold">Sessions</h1> + <span className="text-muted-foreground text-xs"> + {rows.length} of {counts.total} + </span> + </div> + <div className="flex min-w-0 items-center gap-1.5 overflow-x-auto"> + {SESSION_MANAGER_SAVED_VIEWS.map((view) => { + const active = activeViewId === view.id; + const badge = view.countKey === undefined ? undefined : counts[view.countKey]; + return ( + <button + key={view.id} + type="button" + onClick={() => { + if (active) { + filterActions.clearAll(); + return; + } + filterActions.applyView(view.id, view.filters, view.sort); + }} + className={cn( + "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border px-2.5 text-xs whitespace-nowrap transition-colors", + active + ? "border-primary bg-primary text-primary-foreground" + : "border-border bg-card text-muted-foreground hover:text-foreground", + )} + > + {view.label} + {badge !== undefined && badge > 0 ? ( + <span + className={cn( + "rounded px-1 font-mono text-[10px]", + active ? "bg-primary-foreground/20" : "bg-muted", + )} + > + {badge} + </span> + ) : null} + </button> + ); + })} + </div> + <div className="ms-auto flex shrink-0 items-center gap-1.5"> + <Button + size="xs" + variant="ghost" + onClick={() => setDense((value) => !value)} + aria-label="Toggle row density" + > + <Rows3Icon className="size-3.5" /> + {dense ? "Compact" : "Comfortable"} + </Button> + </div> + </div> + + <div className="border-border flex shrink-0 flex-wrap items-center gap-1.5 border-b px-4 py-2"> + <div className="border-border bg-card focus-within:border-ring flex h-7 w-56 shrink-0 items-center gap-1.5 rounded-md border px-2"> + <SearchIcon className="text-muted-foreground size-3.5 shrink-0" /> + <input + value={filters.search} + onChange={(event) => filterActions.setSearch(event.target.value)} + placeholder="Search title, branch, summary…" + aria-label="Search sessions" + className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-xs outline-none" + /> + {filters.search.length > 0 ? ( + <button + type="button" + aria-label="Clear search" + onClick={() => filterActions.setSearch("")} + > + <XIcon className="text-muted-foreground hover:text-foreground size-3" /> + </button> + ) : null} + </div> + + <FacetPopover + label="Repo" + icon={GitBranchIcon} + options={repositoryFacetOptions} + selected={filters.repositoryKeys} + onToggle={filterActions.toggleRepository} + onClear={() => filterActions.setFacet("repositoryKeys", [])} + /> + <FacetPopover + label="Phase" + icon={FilterIcon} + options={PHASE_SIDEBAR_PHASES.map((phase) => ({ value: phase.id, label: phase.label }))} + selected={filters.phaseIds} + onToggle={filterActions.togglePhase} + onClear={() => filterActions.setFacet("phaseIds", [])} + /> + <FacetPopover + label="Needs" + icon={BellIcon} + options={SESSION_MANAGER_ATTENTION_KINDS.map((kind) => ({ + value: kind, + label: SESSION_MANAGER_ATTENTION_LABELS[kind], + }))} + selected={filters.attentionKinds} + onToggle={filterActions.toggleAttention} + onClear={() => filterActions.setFacet("attentionKinds", [])} + /> + <FacetPopover + label="Priority" + icon={FlagIcon} + options={[ + ...SESSION_MANAGER_PRIORITY_CHOICES, + { value: PHASE_SIDEBAR_UNPRIORITISED_RANK, label: "No priority" }, + ]} + selected={filters.priorities} + onToggle={filterActions.togglePriority} + onClear={() => filterActions.setFacet("priorities", [])} + /> + <FacetPopover + label="Agent" + icon={BotIcon} + options={providerFacetOptions} + selected={filters.providerKinds} + onToggle={filterActions.toggleProvider} + onClear={() => filterActions.setFacet("providerKinds", [])} + /> + {ownerFacetOptions.length > 1 ? ( + <FacetPopover + label="Owner" + icon={UserRoundIcon} + options={ownerFacetOptions} + selected={filters.ownerUserIds} + onToggle={filterActions.toggleOwner} + onClear={() => filterActions.setFacet("ownerUserIds", [])} + /> + ) : null} + + <Menu> + <MenuTrigger + className={cn( + "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border px-2 text-xs whitespace-nowrap", + filters.staleDays !== null + ? "border-primary/40 bg-primary/10 text-foreground" + : "border-border bg-card text-muted-foreground hover:text-foreground", + )} + > + <ClockIcon className="size-3.5" /> + {filters.staleDays === null ? "Idle for" : `Idle ${filters.staleDays}d+`} + <ChevronDownIcon className="size-3 opacity-50" /> + </MenuTrigger> + <MenuPopup align="start" className="min-w-40"> + {SESSION_MANAGER_STALE_DAY_CHOICES.map((days) => ( + <MenuItem key={String(days)} onClick={() => filterActions.setStaleDays(days)}> + {days === null ? "Any" : `${days}d or longer`} + </MenuItem> + ))} + </MenuPopup> + </Menu> + + <div className="border-border bg-card flex h-7 shrink-0 items-center overflow-hidden rounded-md border"> + {SESSION_MANAGER_LIFECYCLES.map((lifecycle) => { + const active = filters.lifecycles.includes(lifecycle); + return ( + <button + key={lifecycle} + type="button" + aria-pressed={active} + onClick={() => filterActions.toggleLifecycle(lifecycle)} + className={cn( + "h-full px-2 text-[11px] capitalize transition-colors", + active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:text-foreground", + )} + > + {lifecycle} + </button> + ); + })} + </div> + + {chips.length > 0 ? ( + <div className="flex flex-wrap items-center gap-1"> + {chips.map((chip) => ( + <button + key={chip.id} + type="button" + onClick={() => { + switch (chip.facet) { + case "search": + filterActions.setSearch(""); + return; + case "stale": + filterActions.setStaleDays(null); + return; + case "repository": + filterActions.toggleRepository(chip.value ?? ""); + return; + case "phase": + filterActions.togglePhase(chip.value as PhaseSidebarPhaseId); + return; + case "provider": + filterActions.toggleProvider(chip.value ?? ""); + return; + case "priority": + filterActions.togglePriority(Number(chip.value)); + return; + case "attention": + filterActions.toggleAttention(chip.value as PhaseSidebarAttentionKind); + return; + case "owner": + filterActions.toggleOwner(chip.value ?? ""); + return; + case "lifecycle": + filterActions.toggleLifecycle(chip.value as SessionManagerLifecycle); + return; + } + }} + className="bg-muted text-muted-foreground hover:text-foreground inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px]" + > + {chip.label} + <XIcon className="size-2.5" /> + </button> + ))} + <Button variant="ghost" size="xs" onClick={filterActions.clearAll}> + Clear all + </Button> + </div> + ) : null} + </div> + + <div + className="border-border text-muted-foreground bg-background z-10 grid shrink-0 items-center gap-2 border-b px-4 py-1.5 text-[11px] font-medium" + style={{ gridTemplateColumns: COLUMN_TEMPLATE }} + > + <Checkbox + checked={allVisibleSelected} + indeterminate={!allVisibleSelected && someVisibleSelected} + aria-label="Select all filtered sessions" + onCheckedChange={() => + allVisibleSelected + ? selectionActions.clearSelection() + : selectionActions.replaceSelection(visibleKeys) + } + /> + {headerCell("title", "Session")} + {headerCell("repository", "Repo")} + {headerCell("phase", "Phase")} + {headerCell("priority", "P")} + {headerCell("progress", "Progress")} + <span className="flex items-center gap-1"> + <SparklesIcon className="size-3" /> AI work summary + </span> + <span>Model</span> + {headerCell("activity", "Activity")} + <span /> + </div> + + <div className="relative min-h-0 flex-1"> + {!bootstrapped && allRows.length === 0 ? ( + <div className="flex flex-col gap-2 p-4"> + {Array.from({ length: 8 }, (_, index) => ( + <Skeleton key={index} className="h-8 w-full" /> + ))} + </div> + ) : rows.length === 0 ? ( + <Empty className="h-full"> + <EmptyHeader> + <EmptyTitle className="text-sm"> + {allRows.length === 0 ? "No sessions yet" : "No sessions match these filters"} + </EmptyTitle> + <EmptyDescription className="text-xs"> + {allRows.length === 0 + ? "Start a thread and it will show up here." + : "Try widening a facet, or clear the filter set."} + </EmptyDescription> + </EmptyHeader> + {hasActiveSessionManagerFilters(filters) ? ( + <Button size="sm" variant="outline" onClick={filterActions.clearAll}> + Clear filters + </Button> + ) : null} + </Empty> + ) : ( + <LegendList<SessionManagerRow> + data={rows as SessionManagerRow[]} + keyExtractor={legendKeyExtractor} + // The list only re-invokes renderItem when `data` or `extraData` + // changes. Selection, expansion, pending state and density all live + // outside `rows`, so without this the header updates ("4 selected") + // while the rows keep rendering their stale checkboxes. + extraData={rowRenderExtraData} + estimatedItemSize={dense ? 34 : 52} + renderItem={({ item }) => ( + <SessionManagerTableRow + row={item} + selected={selectedThreadKeys.has(item.key)} + expanded={expandedKeys.has(item.key)} + dense={dense} + pending={pendingByKey.get(item.key)} + actions={rowActions} + /> + )} + className="h-full min-h-0 overflow-x-hidden" + /> + )} + </div> + + {selectedRows.length > 0 ? ( + <BulkToolbar + rows={selectedRows} + bulkRun={bulkRun} + snoozePresets={snoozePresets} + onRetitle={handleRetitle} + onSummarize={handleSummarize} + onSetPriority={handleSetPriority} + onSnooze={handleSnooze} + onSettle={handleSettle} + onArchive={handleArchive} + onPin={handlePin} + onMarkRead={handleMarkRead} + onStopAgent={handleStopAgent} + onDelete={() => setDeleteDialogRows(selectedRows)} + onClear={selectionActions.clearSelection} + /> + ) : null} + + <AlertDialog + open={deleteDialogRows !== null} + onOpenChange={(open) => { + if (!open) setDeleteDialogRows(null); + }} + > + <AlertDialogPopup> + <AlertDialogHeader> + <AlertDialogTitle> + Delete {deleteDialogRows?.length ?? 0} session + {(deleteDialogRows?.length ?? 0) === 1 ? "" : "s"}? + </AlertDialogTitle> + <AlertDialogDescription> + This permanently clears conversation history for these sessions. Worktrees left + orphaned by the deletion are confirmed separately. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> + <Button variant="destructive" onClick={() => void confirmDelete()}> + Delete + </Button> + </AlertDialogFooter> + </AlertDialogPopup> + </AlertDialog> + </div> + ); +} + +const EMPTY_ENVIRONMENT_IDS: ReadonlyArray<EnvironmentId> = []; + +function legendKeyExtractor(item: SessionManagerRow): string { + return item.key; +} + +/* ------------------------------- bulk toolbar ------------------------------ */ + +function BulkToolbar({ + rows, + bulkRun, + snoozePresets, + onRetitle, + onSummarize, + onSetPriority, + onSnooze, + onSettle, + onArchive, + onPin, + onMarkRead, + onStopAgent, + onDelete, + onClear, +}: { + rows: ReadonlyArray<SessionManagerRow>; + bulkRun: BulkRunState | null; + snoozePresets: ReadonlyArray<{ + id: string; + label: string; + whenLabel: string; + snoozedUntil: string; + }>; + onRetitle: (rows: ReadonlyArray<SessionManagerRow>) => void; + onSummarize: (rows: ReadonlyArray<SessionManagerRow>) => void; + onSetPriority: (rows: ReadonlyArray<SessionManagerRow>, priority: ThreadPriority) => void; + onSnooze: (rows: ReadonlyArray<SessionManagerRow>, snoozedUntil: string, label: string) => void; + onSettle: (rows: ReadonlyArray<SessionManagerRow>) => void; + onArchive: (rows: ReadonlyArray<SessionManagerRow>) => void; + onPin: (rows: ReadonlyArray<SessionManagerRow>) => void; + onMarkRead: (rows: ReadonlyArray<SessionManagerRow>) => void; + onStopAgent: (rows: ReadonlyArray<SessionManagerRow>) => void; + onDelete: () => void; + onClear: () => void; +}) { + const busy = bulkRun !== null; + const retitlePlan = planSessionManagerAction( + rows, + (row) => row.capabilities.titleRegeneration, + "No selected session's server supports title regeneration.", + ); + const summarizePlan = planSessionManagerAction( + rows, + (row) => row.capabilities.workSummary, + "No selected session's server supports work summaries.", + ); + const priorityPlan = planSessionManagerAction( + rows, + (row) => row.capabilities.priority, + "No selected session's server supports priority.", + ); + const snoozePlan = planSessionManagerAction( + rows, + (row) => row.capabilities.snooze, + "No selected session's server supports snooze.", + ); + const settlePlan = planSessionManagerAction( + rows, + (row) => row.capabilities.settlement, + "No selected session's server supports settling.", + ); + const pinPlan = planSessionManagerAction( + rows, + (row) => row.capabilities.pinning, + "No selected session's server supports pinning.", + ); + const stopPlan = planSessionManagerAction( + rows, + (row) => row.canStop, + "No selected session has a running agent to stop.", + ); + + return ( + <div className="pointer-events-none fixed inset-x-0 bottom-5 z-40 flex justify-center px-4"> + <div className="pointer-events-auto bg-popover border-border flex max-w-[calc(100vw-2rem)] items-center gap-1.5 overflow-x-auto rounded-xl border px-2.5 py-2 shadow-2xl"> + <span className="flex shrink-0 items-center gap-2 pr-1 text-xs font-medium"> + <span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-mono"> + {rows.length} + </span> + selected + </span> + {bulkRun !== null ? ( + <span className="text-muted-foreground flex shrink-0 items-center gap-1.5 text-[11px]"> + <Spinner className="size-3" /> + {bulkRun.label} {bulkRun.done}/{bulkRun.total} + </span> + ) : null} + <div className="bg-border mx-1 h-5 w-px shrink-0" /> + + <GatedButton + disabled={busy} + reason={retitlePlan.disabledReason} + onClick={() => onRetitle(retitlePlan.eligible)} + > + <RefreshCwIcon className="size-3.5" /> Retitle + </GatedButton> + <GatedButton + disabled={busy} + reason={summarizePlan.disabledReason} + onClick={() => onSummarize(summarizePlan.eligible)} + > + <SparklesIcon className="size-3.5" /> Summarize + </GatedButton> + + <Menu> + <MenuTrigger + disabled={busy || priorityPlan.disabledReason !== null} + render={<Button size="xs" variant="outline" />} + > + <FlagIcon className="size-3.5" /> Priority + </MenuTrigger> + <MenuPopup align="end" className="min-w-44"> + {SESSION_MANAGER_PRIORITY_CHOICES.map((choice) => ( + <MenuItem + key={choice.value} + onClick={() => onSetPriority(priorityPlan.eligible, choice.value as ThreadPriority)} + > + <PriorityBadge rank={choice.value} /> Set {choice.label} + </MenuItem> + ))} + </MenuPopup> + </Menu> + + <Menu> + <MenuTrigger + disabled={busy || snoozePlan.disabledReason !== null} + render={<Button size="xs" variant="outline" />} + > + <ClockIcon className="size-3.5" /> Snooze + </MenuTrigger> + <MenuPopup align="end" className="min-w-52"> + {snoozePresets.map((preset) => ( + <MenuItem + key={preset.id} + onClick={() => onSnooze(snoozePlan.eligible, preset.snoozedUntil, preset.label)} + > + <span className="flex-1">{preset.label}</span> + <span className="text-muted-foreground text-[10px]">{preset.whenLabel}</span> + </MenuItem> + ))} + </MenuPopup> + </Menu> + + <GatedButton + disabled={busy} + reason={settlePlan.disabledReason} + onClick={() => onSettle(settlePlan.eligible)} + > + <CheckCircle2Icon className="size-3.5" /> Settle + </GatedButton> + <GatedButton disabled={busy} reason={null} onClick={() => onArchive(rows)}> + <ArchiveIcon className="size-3.5" /> Archive + </GatedButton> + + <Menu> + <MenuTrigger disabled={busy} render={<Button size="xs" variant="outline" />}> + <MoreHorizontalIcon className="size-3.5" /> + </MenuTrigger> + <MenuPopup align="end" className="min-w-56"> + <MenuItem + disabled={pinPlan.disabledReason !== null} + onClick={() => onPin(pinPlan.eligible)} + > + <PinIcon /> Pin / unpin + </MenuItem> + <MenuItem onClick={() => onMarkRead(rows)}> + <CheckCircle2Icon /> Mark read + </MenuItem> + <MenuItem + disabled={stopPlan.disabledReason !== null} + onClick={() => onStopAgent(stopPlan.eligible)} + > + <OctagonPauseIcon /> Stop agent + </MenuItem> + <MenuSeparator /> + <MenuItem variant="destructive" onClick={onDelete}> + <Trash2Icon /> Delete… + </MenuItem> + </MenuPopup> + </Menu> + + <div className="bg-border mx-1 h-5 w-px shrink-0" /> + <Button size="xs" variant="ghost" onClick={onClear}> + <XIcon className="size-3.5" /> Clear + </Button> + </div> + </div> + ); +} + +/** + * A toolbar button that explains *why* it is disabled. A silently dead button + * on a bulk toolbar reads as a bug; a tooltip naming the missing capability + * reads as a version-skew message. + */ +function GatedButton({ + children, + disabled, + reason, + onClick, +}: { + children: React.ReactNode; + disabled: boolean; + reason: string | null; + onClick: () => void; +}) { + const button = ( + <Button size="xs" variant="outline" disabled={disabled || reason !== null} onClick={onClick}> + {children} + </Button> + ); + if (reason === null) return button; + return ( + <Tooltip> + <TooltipTrigger render={<span className="inline-flex" />}>{button}</TooltipTrigger> + <TooltipPopup side="top" className="max-w-64"> + {reason} + </TooltipPopup> + </Tooltip> + ); +} diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 4b96fb15398..cade1e9d752 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -62,10 +62,28 @@ export function BetaSettingsPanel() { ); const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); const updateSettings = useUpdateClientSettings(); + // T3-CUSTOM(expbkt3): BEGIN — native plan review. + const nativePlanReviewEnabled = useClientSettings((settings) => settings.nativePlanReviewEnabled); + // T3-CUSTOM(expbkt3): END return ( <SettingsPageContainer> <SettingsSection title="Beta features"> + {/* T3-CUSTOM(expbkt3): BEGIN — native plan review. */} + <SettingsRow + {...searchableSetting("native-plan-review")} + description="Review proposed plans in a side panel: comment on exact lines, edit the plan with tracked changes, and step through every version with its author. Approving sends a short acknowledgement instead of repeating the whole plan. While off, plan review goes through Plannotator only." + control={ + <Switch + checked={nativePlanReviewEnabled} + onCheckedChange={(checked) => + updateSettings({ nativePlanReviewEnabled: Boolean(checked) }) + } + aria-label="Native plan review" + /> + } + /> + {/* T3-CUSTOM(expbkt3): END */} <SettingsRow {...searchableSetting("sidebar-v2")} description="One flat thread list in creation order. Active work renders as rich cards; settled threads collapse to compact rows. Settling requires an up-to-date server — on older servers threads simply stay active. Switch back any time." diff --git a/apps/web/src/components/settings/ExperimentsSettingsPanel.tsx b/apps/web/src/components/settings/ExperimentsSettingsPanel.tsx index ba57f40da4a..ec09ef238ce 100644 --- a/apps/web/src/components/settings/ExperimentsSettingsPanel.tsx +++ b/apps/web/src/components/settings/ExperimentsSettingsPanel.tsx @@ -3,10 +3,14 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; import { Switch } from "../ui/switch"; import { SessionSummarySettingsSection } from "./SessionSummarySettingsSection"; +// T3-CUSTOM(expbkt3): session work summary + progress assessment. +import { SessionWorkSummarySettingsSection } from "./SessionWorkSummarySettingsSection"; import { ExternalMcpSettingsSection } from "./ExternalMcpSettingsSection"; import { T3ConductorSettingsSection } from "./T3ConductorSettingsSection"; // T3-CUSTOM(expbkt3): session title maintenance. import { ThreadTitleMaintenanceSettingsSection } from "./ThreadTitleMaintenanceSettingsSection"; +// T3-CUSTOM(expbkt3): archived-session worktree reclaim. +import { SessionArchiveSettingsSection } from "./SessionArchiveSettingsSection"; import { EXPERIMENTAL_CONTROL_CENTER_ENABLED, T3_CONDUCTOR_ENABLED, @@ -118,9 +122,15 @@ export function ExperimentsSettingsPanel() { ) : null} {/* T3-CUSTOM(expbkt3): END */} <SessionSummarySettingsSection /> + {/* T3-CUSTOM(expbkt3): BEGIN — session work summary + progress assessment. */} + <SessionWorkSummarySettingsSection /> + {/* T3-CUSTOM(expbkt3): END */} {/* T3-CUSTOM(expbkt3): BEGIN — session title maintenance. */} <ThreadTitleMaintenanceSettingsSection /> {/* T3-CUSTOM(expbkt3): END */} + {/* T3-CUSTOM(expbkt3): BEGIN — archived-session worktree reclaim. */} + <SessionArchiveSettingsSection /> + {/* T3-CUSTOM(expbkt3): END */} </SettingsPageContainer> ); } diff --git a/apps/web/src/components/settings/SessionArchiveReclaimSection.logic.test.ts b/apps/web/src/components/settings/SessionArchiveReclaimSection.logic.test.ts new file mode 100644 index 00000000000..00fbbc6ce96 --- /dev/null +++ b/apps/web/src/components/settings/SessionArchiveReclaimSection.logic.test.ts @@ -0,0 +1,389 @@ +/** + * T3-CUSTOM(expbkt3): Coverage for the reclaim panel's presentation logic. + * + * The selection summary is what enables the destructive buttons, so it gets the + * most attention here — a blocked entry must never make one clickable. + */ +import { + ProjectId, + ThreadId, + type SessionArchiveEntry, + type SessionArchiveScanResult, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + applySelectionScope, + describeReclaimResult, + describeReclaimState, + describeScanSummary, + formatBytes, + projectGroups, + selectionTargets, + sortEntriesForDisplay, + stateGroups, + summarizeSelection, +} from "./SessionArchiveReclaimSection.logic"; + +const entry = (overrides: Partial<SessionArchiveEntry> = {}): SessionArchiveEntry => + ({ + threadId: ThreadId.make("thread_1"), + projectId: ProjectId.make("project_1"), + projectName: "A project", + title: "A session", + branch: "feature", + worktreePath: "/worktrees/proj/feature", + archivedAt: "2026-07-01T00:00:00.000Z", + worktreeBytes: 1_000_000, + reclaimableBytes: 800_000, + reclaimState: "present", + blockedReason: null, + removeBlockedReason: null, + historyPath: null, + ...overrides, + }) as SessionArchiveEntry; + +describe("formatBytes", () => { + it("reports a null size as unknown rather than zero", () => { + expect(formatBytes(null)).toBe("size unknown"); + }); + + it("keeps small counts in bytes", () => { + expect(formatBytes(512)).toBe("512 B"); + }); + + it("uses one decimal below ten and none above", () => { + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(50 * 1024)).toBe("50 KB"); + }); + + it("scales into gigabytes", () => { + expect(formatBytes(3.6 * 1024 ** 3)).toBe("3.6 GB"); + }); +}); + +describe("summarizeSelection", () => { + it("counts only selected entries", () => { + const entries = [ + entry({ threadId: ThreadId.make("a") }), + entry({ threadId: ThreadId.make("b") }), + ]; + const summary = summarizeSelection(entries, new Set([ThreadId.make("a")])); + expect(summary.selectedCount).toBe(1); + expect(summary.reclaimableBytes).toBe(800_000); + }); + + it("never enables an action for a blocked entry", () => { + // `worktree-live` is mode-independent, so the server reports it for both. + const entries = [ + entry({ + threadId: ThreadId.make("a"), + blockedReason: "worktree-live", + removeBlockedReason: "worktree-live", + }), + ]; + const summary = summarizeSelection(entries, new Set([ThreadId.make("a")])); + expect(summary.blockedCount).toBe(1); + expect(summary.canSlim).toBe(false); + expect(summary.canRemove).toBe(false); + }); + + it("excludes a blocked entry's bytes from the total", () => { + const entries = [ + entry({ threadId: ThreadId.make("a"), reclaimableBytes: 100 }), + entry({ + threadId: ThreadId.make("b"), + blockedReason: "worktree-shared", + removeBlockedReason: "worktree-shared", + reclaimableBytes: 900, + }), + ]; + const summary = summarizeSelection(entries, new Set([ThreadId.make("a"), ThreadId.make("b")])); + expect(summary.reclaimableBytes).toBe(100); + expect(summary.eligibleCount).toBe(1); + }); + + it("keeps slim available for an already-slim worktree", () => { + const entries = [ + entry({ threadId: ThreadId.make("a"), reclaimState: "slimmed", reclaimableBytes: 0 }), + ]; + const summary = summarizeSelection(entries, new Set([ThreadId.make("a")])); + expect(summary.canSlim).toBe(true); + }); + + it("disables remove when nothing selected still has a worktree", () => { + const entries = [ + entry({ threadId: ThreadId.make("a"), worktreePath: null, reclaimState: "removed" }), + ]; + const summary = summarizeSelection(entries, new Set([ThreadId.make("a")])); + expect(summary.canRemove).toBe(false); + }); + + it("reports an empty selection as doing nothing", () => { + const summary = summarizeSelection([entry()], new Set()); + expect(summary).toMatchObject({ selectedCount: 0, canSlim: false, canRemove: false }); + }); +}); + +describe("sortEntriesForDisplay", () => { + it("puts the biggest reclaim first", () => { + const sorted = sortEntriesForDisplay([ + entry({ threadId: ThreadId.make("small"), reclaimableBytes: 10 }), + entry({ threadId: ThreadId.make("big"), reclaimableBytes: 1000 }), + ]); + expect(sorted[0]?.threadId).toBe("big"); + }); + + it("sorts unmeasured entries last", () => { + const sorted = sortEntriesForDisplay([ + entry({ threadId: ThreadId.make("unknown"), reclaimableBytes: null, worktreeBytes: null }), + entry({ threadId: ThreadId.make("known"), reclaimableBytes: 0, worktreeBytes: 0 }), + ]); + expect(sorted[0]?.threadId).toBe("known"); + }); +}); + +describe("describeReclaimState", () => { + it("names each state", () => { + expect(describeReclaimState(entry({ reclaimState: "present" }))).toBe("Worktree on disk"); + expect(describeReclaimState(entry({ reclaimState: "slimmed" }))).toBe("Already slim"); + expect(describeReclaimState(entry({ reclaimState: "removed" }))).toBe("Worktree removed"); + expect(describeReclaimState(entry({ reclaimState: "missing" }))).toBe("Worktree missing"); + }); +}); + +describe("describeReclaimResult", () => { + it("reports what was freed", () => { + expect( + describeReclaimResult({ + mode: "slim", + reclaimedCount: 3, + skippedCount: 0, + freedBytes: 2 * 1024 ** 3, + }), + ).toBe("Slimmed 3 sessions, freed 2.0 GB."); + }); + + it("mentions skipped sessions", () => { + expect( + describeReclaimResult({ mode: "remove", reclaimedCount: 1, skippedCount: 2, freedBytes: 0 }), + ).toBe("Removed 1 session, 2 skipped."); + }); +}); + +describe("describeScanSummary", () => { + it("summarizes the scan in one line", () => { + const result = { + scannedAt: "2026-08-07T00:00:00.000Z", + entries: [ + entry({ threadId: ThreadId.make("a") }), + entry({ threadId: ThreadId.make("b"), blockedReason: "worktree-live" }), + ], + orphanedWorktrees: [{ worktreePath: "/x", sizeBytes: null, lastModifiedAt: null }], + totalReclaimableBytes: 800_000, + historyDir: "/history", + sizingIncomplete: true, + } as SessionArchiveScanResult; + const summary = describeScanSummary(result); + expect(summary).toContain("2 archived sessions"); + expect(summary).toContain("1 reclaimable"); + expect(summary).toContain("1 orphaned worktrees"); + expect(summary).toContain("some sizes not measured"); + }); +}); + +describe("summarizeSelection — force remove", () => { + it("offers a plain remove only when the remove gate is clear", () => { + const entries = [ + entry({ threadId: ThreadId.make("a"), removeBlockedReason: "dirty-worktree" }), + ]; + const summary = summarizeSelection(entries, new Set([ThreadId.make("a")])); + expect(summary.canRemove).toBe(false); + expect(summary.canForceRemove).toBe(true); + expect(summary.forceableCount).toBe(1); + }); + + it("counts unpushed commits as forceable too", () => { + const entries = [ + entry({ threadId: ThreadId.make("a"), removeBlockedReason: "unpushed-commits" }), + ]; + expect(summarizeSelection(entries, new Set([ThreadId.make("a")])).canForceRemove).toBe(true); + }); + + it("never offers force for a live or shared worktree", () => { + for (const reason of ["worktree-live", "worktree-shared"] as const) { + const entries = [entry({ threadId: ThreadId.make("a"), removeBlockedReason: reason })]; + const summary = summarizeSelection(entries, new Set([ThreadId.make("a")])); + expect(summary.canForceRemove).toBe(false); + expect(summary.forceableCount).toBe(0); + } + }); + + it("counts cleanly removable plus forceable in the force total", () => { + const entries = [ + entry({ threadId: ThreadId.make("clean") }), + entry({ threadId: ThreadId.make("dirty"), removeBlockedReason: "dirty-worktree" }), + entry({ + threadId: ThreadId.make("live"), + blockedReason: "worktree-live", + removeBlockedReason: "worktree-live", + }), + ]; + const summary = summarizeSelection( + entries, + new Set([ThreadId.make("clean"), ThreadId.make("dirty"), ThreadId.make("live")]), + ); + expect(summary.forceRemoveCount).toBe(2); + }); +}); + +describe("selectionTargets", () => { + const entries = [ + entry({ threadId: ThreadId.make("clean") }), + entry({ threadId: ThreadId.make("dirty"), removeBlockedReason: "dirty-worktree" }), + entry({ + threadId: ThreadId.make("live"), + blockedReason: "worktree-live", + removeBlockedReason: "worktree-live", + }), + entry({ + threadId: ThreadId.make("noslim"), + blockedReason: "worktree-shared", + removeBlockedReason: "worktree-shared", + }), + ]; + const all = new Set(entries.map((e) => e.threadId)); + + it("sends only slim-eligible entries for a slim", () => { + // `live` and `noslim` are held by mode-independent gates, so a slim skips + // them too; `dirty` is fine to slim because slimming touches only + // regenerable, git-ignored directories. + expect(selectionTargets(entries, all, "slim")).toEqual(["clean", "dirty"]); + }); + + it("sends only cleanly removable entries for a plain remove", () => { + expect(selectionTargets(entries, all, "remove")).toEqual(["clean"]); + }); + + it("adds forceable entries for a forced remove, never the live one", () => { + const targets = selectionTargets(entries, all, "force-remove"); + expect(targets).toEqual(["clean", "dirty"]); + expect(targets).not.toContain("live"); + }); + + it("ignores entries that are not selected", () => { + expect(selectionTargets(entries, new Set([ThreadId.make("clean")]), "slim")).toEqual(["clean"]); + }); + + it("never targets an entry whose worktree is already gone", () => { + const gone = [entry({ threadId: ThreadId.make("gone"), worktreePath: null })]; + expect(selectionTargets(gone, new Set([ThreadId.make("gone")]), "force-remove")).toEqual([]); + }); +}); + +describe("applySelectionScope", () => { + const entries = [ + entry({ + threadId: ThreadId.make("a"), + projectId: ProjectId.make("p1"), + reclaimState: "present", + }), + entry({ + threadId: ThreadId.make("b"), + projectId: ProjectId.make("p2"), + reclaimState: "slimmed", + }), + entry({ + threadId: ThreadId.make("c"), + projectId: ProjectId.make("p1"), + reclaimState: "present", + blockedReason: "worktree-live", + }), + ] as ReadonlyArray<SessionArchiveEntry>; + + it("selects everything", () => { + expect(applySelectionScope(entries, { kind: "all" }).size).toBe(3); + }); + + it("deselects everything", () => { + expect(applySelectionScope(entries, { kind: "none" }).size).toBe(0); + }); + + it("selects only reclaimable entries", () => { + const selected = applySelectionScope(entries, { kind: "reclaimable" }); + expect([...selected].sort()).toEqual(["a", "b"]); + }); + + it("selects by project", () => { + const selected = applySelectionScope(entries, { + kind: "project", + projectId: ProjectId.make("p1"), + }); + expect([...selected].sort()).toEqual(["a", "c"]); + }); + + it("selects by reclaim state", () => { + const selected = applySelectionScope(entries, { kind: "state", state: "slimmed" }); + expect([...selected]).toEqual(["b"]); + }); + + it("replaces rather than accumulates", () => { + const first = applySelectionScope(entries, { + kind: "project", + projectId: ProjectId.make("p1"), + }); + const second = applySelectionScope(entries, { + kind: "project", + projectId: ProjectId.make("p2"), + }); + expect([...second]).toEqual(["b"]); + expect(first.has(ThreadId.make("a"))).toBe(true); + }); +}); + +describe("selection groups", () => { + const entries = [ + entry({ + threadId: ThreadId.make("a"), + projectId: ProjectId.make("p1"), + reclaimState: "present", + }), + entry({ + threadId: ThreadId.make("b"), + projectId: ProjectId.make("p1"), + reclaimState: "present", + }), + entry({ + threadId: ThreadId.make("c"), + projectId: ProjectId.make("p2"), + reclaimState: "slimmed", + }), + ] as ReadonlyArray<SessionArchiveEntry>; + + it("groups projects by count, biggest first, using the name on the entry", () => { + const named = [ + entry({ + threadId: ThreadId.make("a"), + projectId: ProjectId.make("p1"), + projectName: "t3code", + }), + entry({ + threadId: ThreadId.make("b"), + projectId: ProjectId.make("p1"), + projectName: "t3code", + }), + // Blank name: an older server that predates the field. + entry({ threadId: ThreadId.make("c"), projectId: ProjectId.make("p2"), projectName: "" }), + ]; + const groups = projectGroups(named); + expect(groups[0]).toMatchObject({ id: "p1", label: "t3code", count: 2 }); + // A nameless project falls back to its id rather than disappearing. + expect(groups[1]).toMatchObject({ id: "p2", label: "p2", count: 1 }); + }); + + it("groups reclaim states with human labels", () => { + const groups = stateGroups(entries); + expect(groups[0]).toMatchObject({ id: "present", label: "Worktree on disk", count: 2 }); + expect(groups[1]).toMatchObject({ id: "slimmed", label: "Already slim", count: 1 }); + }); +}); diff --git a/apps/web/src/components/settings/SessionArchiveReclaimSection.logic.ts b/apps/web/src/components/settings/SessionArchiveReclaimSection.logic.ts new file mode 100644 index 00000000000..b5c25dad4e2 --- /dev/null +++ b/apps/web/src/components/settings/SessionArchiveReclaimSection.logic.ts @@ -0,0 +1,280 @@ +/** + * T3-CUSTOM(expbkt3): Presentation logic for the archived-worktree reclaim panel. + * + * Kept out of the component so the interesting decisions — what a selection is + * allowed to do, what the totals say, how a byte count reads — are testable + * without rendering anything. + */ +import { + isForceableBlockedReason, + type SessionArchiveEntry, + type SessionArchiveReclaimMode, + type SessionArchiveScanResult, +} from "@t3tools/contracts"; + +/** Human-readable byte count. Null sizes render as an explicit unknown. */ +export function formatBytes(bytes: number | null): string { + if (bytes === null) { + return "size unknown"; + } + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + // One decimal below 10 keeps "1.4 GB" informative without "1.437 GB" noise. + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`; +} + +export interface EntrySelectionSummary { + readonly selectedCount: number; + readonly eligibleCount: number; + readonly blockedCount: number; + readonly reclaimableBytes: number; + readonly canSlim: boolean; + readonly canRemove: boolean; + /** Selected entries a plain remove refuses but a forced one would take. */ + readonly forceableCount: number; + /** Entries that would be removed by a forced remove, plain plus forceable. */ + readonly forceRemoveCount: number; + readonly canForceRemove: boolean; +} + +/** + * What the action buttons should do for the current selection. + * + * Blocked entries are counted but never enable a *plain* action: the server + * re-checks every gate anyway, and offering a button that will certainly be + * refused reads as a bug rather than as a safeguard. Entries held only by a + * forceable gate are counted separately, because those the operator can + * deliberately override. + */ +export function summarizeSelection( + entries: ReadonlyArray<SessionArchiveEntry>, + selectedThreadIds: ReadonlySet<string>, +): EntrySelectionSummary { + const selected = entries.filter((entry) => selectedThreadIds.has(entry.threadId)); + const eligible = selected.filter((entry) => entry.blockedReason === null); + const reclaimableBytes = eligible.reduce( + (total, entry) => total + (entry.reclaimableBytes ?? 0), + 0, + ); + + // Removal has its own gate, so a plain remove is offered only for entries the + // server evaluated as removable — not merely slimmable. + const removable = selected.filter( + (entry) => entry.removeBlockedReason === null && entry.worktreePath !== null, + ); + const forceable = selected.filter( + (entry) => isForceableBlockedReason(entry.removeBlockedReason) && entry.worktreePath !== null, + ); + + return { + selectedCount: selected.length, + eligibleCount: eligible.length, + blockedCount: selected.length - eligible.length, + reclaimableBytes, + // A slim of an already-slim worktree is a no-op, not an error, so it stays + // available; removing needs a worktree that is actually still there. + canSlim: eligible.length > 0, + canRemove: removable.length > 0, + forceableCount: forceable.length, + forceRemoveCount: removable.length + forceable.length, + canForceRemove: forceable.length > 0, + }; +} + +/** + * Whether any of the three actions would accept this entry. + * + * Drives whether its checkbox is usable — an entry nothing can act on is not + * worth selecting, but one that only a *forced* remove would take still is. + */ +export function isEntryActionable(entry: SessionArchiveEntry): boolean { + return ( + entry.blockedReason === null || + entry.removeBlockedReason === null || + isForceableBlockedReason(entry.removeBlockedReason) + ); +} + +/** + * Thread ids a given action would actually be sent for. + * + * Returns the branded id straight off the entry so the result can be handed to + * the RPC without a cast. + */ +export function selectionTargets( + entries: ReadonlyArray<SessionArchiveEntry>, + selectedThreadIds: ReadonlySet<string>, + action: "slim" | "remove" | "force-remove", +): ReadonlyArray<SessionArchiveEntry["threadId"]> { + return entries + .filter((entry) => { + if (!selectedThreadIds.has(entry.threadId)) return false; + if (action === "slim") return entry.blockedReason === null; + if (entry.worktreePath === null) return false; + if (action === "remove") return entry.removeBlockedReason === null; + // force-remove: cleanly removable, plus those held only by a forceable gate. + return ( + entry.removeBlockedReason === null || isForceableBlockedReason(entry.removeBlockedReason) + ); + }) + .map((entry) => entry.threadId); +} + +/** How a bulk-select control narrows the list. */ +export type SelectionScope = + | { readonly kind: "all" } + | { readonly kind: "none" } + | { readonly kind: "reclaimable" } + | { readonly kind: "project"; readonly projectId: string } + | { readonly kind: "state"; readonly state: SessionArchiveEntry["reclaimState"] }; + +/** + * Apply a bulk-select scope, returning the new selection. + * + * Replaces rather than unions: "select all in project X" reads as a jump to + * that set, and a control that silently accumulated across presses would make + * a destructive selection hard to reason about. + */ +export function applySelectionScope( + entries: ReadonlyArray<SessionArchiveEntry>, + scope: SelectionScope, +): ReadonlySet<string> { + if (scope.kind === "none") { + return new Set(); + } + const matches = entries.filter((entry) => { + switch (scope.kind) { + case "all": + return true; + case "reclaimable": + return entry.blockedReason === null; + case "project": + return entry.projectId === scope.projectId; + case "state": + return entry.reclaimState === scope.state; + } + }); + return new Set(matches.map((entry) => entry.threadId)); +} + +export interface SelectionGroup { + readonly id: string; + readonly label: string; + readonly count: number; +} + +/** Distinct projects in the scan, for the "select by project" control. */ +export function projectGroups( + entries: ReadonlyArray<SessionArchiveEntry>, +): ReadonlyArray<SelectionGroup> { + const counts = new Map<string, number>(); + const labels = new Map<string, string>(); + for (const entry of entries) { + counts.set(entry.projectId, (counts.get(entry.projectId) ?? 0) + 1); + // A blank name means an older server that predates the field; the id is a + // poor label but a working one. + if (entry.projectName.trim().length > 0) { + labels.set(entry.projectId, entry.projectName); + } + } + return [...counts.entries()] + .map(([projectId, count]) => ({ + id: projectId, + label: labels.get(projectId) ?? projectId, + count, + })) + .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label)); +} + +/** Distinct reclaim states in the scan, for the "select by state" control. */ +export function stateGroups( + entries: ReadonlyArray<SessionArchiveEntry>, +): ReadonlyArray<SelectionGroup> { + const counts = new Map<SessionArchiveEntry["reclaimState"], number>(); + for (const entry of entries) { + counts.set(entry.reclaimState, (counts.get(entry.reclaimState) ?? 0) + 1); + } + return [...counts.entries()] + .map(([state, count]) => ({ + id: state, + label: describeReclaimState({ reclaimState: state } as SessionArchiveEntry), + count, + })) + .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label)); +} + +/** Entries worth showing first: biggest reclaim, then biggest worktree. */ +export function sortEntriesForDisplay( + entries: ReadonlyArray<SessionArchiveEntry>, +): ReadonlyArray<SessionArchiveEntry> { + return [...entries].sort((left, right) => { + const leftReclaim = left.reclaimableBytes ?? -1; + const rightReclaim = right.reclaimableBytes ?? -1; + if (leftReclaim !== rightReclaim) { + return rightReclaim - leftReclaim; + } + const leftSize = left.worktreeBytes ?? -1; + const rightSize = right.worktreeBytes ?? -1; + if (leftSize !== rightSize) { + return rightSize - leftSize; + } + return (right.archivedAt ?? "").localeCompare(left.archivedAt ?? ""); + }); +} + +/** Short badge text for an entry's current state. */ +export function describeReclaimState(entry: SessionArchiveEntry): string { + switch (entry.reclaimState) { + case "present": + return "Worktree on disk"; + case "slimmed": + return "Already slim"; + case "removed": + return "Worktree removed"; + case "missing": + return "Worktree missing"; + } +} + +/** One line summarising a completed reclaim, for the toast. */ +export function describeReclaimResult(input: { + readonly mode: SessionArchiveReclaimMode; + readonly reclaimedCount: number; + readonly skippedCount: number; + readonly freedBytes: number; +}): string { + const verb = input.mode === "slim" ? "Slimmed" : "Removed"; + const parts = [`${verb} ${input.reclaimedCount} session${input.reclaimedCount === 1 ? "" : "s"}`]; + if (input.freedBytes > 0) { + parts.push(`freed ${formatBytes(input.freedBytes)}`); + } + if (input.skippedCount > 0) { + parts.push(`${input.skippedCount} skipped`); + } + return `${parts.join(", ")}.`; +} + +/** Header line above the list. */ +export function describeScanSummary(result: SessionArchiveScanResult): string { + const eligible = result.entries.filter((entry) => entry.blockedReason === null).length; + const parts = [ + `${result.entries.length} archived session${result.entries.length === 1 ? "" : "s"}`, + `${eligible} reclaimable`, + `${formatBytes(result.totalReclaimableBytes)} to free`, + ]; + if (result.orphanedWorktrees.length > 0) { + parts.push(`${result.orphanedWorktrees.length} orphaned worktrees`); + } + if (result.sizingIncomplete) { + parts.push("some sizes not measured"); + } + return parts.join(" · "); +} diff --git a/apps/web/src/components/settings/SessionArchiveReclaimSection.tsx b/apps/web/src/components/settings/SessionArchiveReclaimSection.tsx new file mode 100644 index 00000000000..2c8f8adb751 --- /dev/null +++ b/apps/web/src/components/settings/SessionArchiveReclaimSection.tsx @@ -0,0 +1,499 @@ +/** + * T3-CUSTOM(expbkt3): Reclaim disk from archived sessions' worktrees. + * + * Upstream removes a worktree only when a thread is *deleted*, so archived + * worktrees accumulate until the only way to free the disk is to destroy the + * history. This panel offers the middle path: every action exports the + * session's history first, then either slims the worktree (deleting only what a + * package manager can rebuild) or removes it outright. + * + * The scan is behind a button rather than run on mount because it walks the + * filesystem — on a busy host that is seconds of IO nobody asked for. + */ +import { + isForceableBlockedReason, + type EnvironmentId, + type SessionArchiveEntry, + type SessionArchiveScanResult, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { HardDriveIcon, LoaderIcon, RefreshCwIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { sessionArchiveEnvironment } from "../../state/sessionArchive"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { + applySelectionScope, + describeReclaimResult, + describeReclaimState, + describeScanSummary, + formatBytes, + isEntryActionable, + projectGroups, + selectionTargets, + sortEntriesForDisplay, + stateGroups, + summarizeSelection, + type SelectionScope, +} from "./SessionArchiveReclaimSection.logic"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; + +/** Entries rendered before the list is truncated. */ +const VISIBLE_ENTRY_LIMIT = 25; + +export function SessionArchiveReclaimSection({ + environmentIds, + onReclaimed, +}: { + readonly environmentIds: ReadonlyArray<EnvironmentId>; + readonly onReclaimed: () => void; +}) { + const [scanResult, setScanResult] = useState<SessionArchiveScanResult | null>(null); + const [selectedThreadIds, setSelectedThreadIds] = useState<ReadonlySet<string>>(new Set()); + const [isBusy, setIsBusy] = useState(false); + const [showOrphans, setShowOrphans] = useState(false); + + const scan = useAtomCommand(sessionArchiveEnvironment.scan, { + label: "session archive scan", + }); + const reclaim = useAtomCommand(sessionArchiveEnvironment.reclaim, { + label: "session archive reclaim", + }); + + // The panel is single-environment on purpose: reclaim is a disk operation on + // the machine hosting that server, and mixing hosts in one list would make + // "3.6 GB to free" mean nothing in particular. + const environmentId = environmentIds[0] ?? null; + + const entries = useMemo( + () => (scanResult === null ? [] : sortEntriesForDisplay(scanResult.entries)), + [scanResult], + ); + const selection = useMemo( + () => summarizeSelection(entries, selectedThreadIds), + [entries, selectedThreadIds], + ); + + const reportFailure = useCallback((title: string, result: unknown) => { + if (isAtomCommandInterrupted(result as never)) { + return; + } + const error = squashAtomCommandFailure(result as never); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, []); + + const runScan = useCallback(async () => { + if (environmentId === null) { + return; + } + setIsBusy(true); + const result = await scan({ environmentId, input: {} }); + setIsBusy(false); + if (result._tag === "Success") { + setScanResult(result.value); + // A stale selection would point at threads the new scan may not contain. + setSelectedThreadIds(new Set()); + return; + } + reportFailure("Could not scan archived sessions", result); + }, [environmentId, reportFailure, scan]); + + const runReclaim = useCallback( + async (action: "slim" | "remove" | "force-remove") => { + if (environmentId === null) { + return; + } + const targetIds = selectionTargets(entries, selectedThreadIds, action); + if (targetIds.length === 0) { + return; + } + const mode = action === "slim" ? "slim" : "remove"; + const force = action === "force-remove"; + + // A forced remove is the only action here that destroys work the operator + // cannot get back, so its confirmation names the count and what is lost + // rather than describing the action in general terms. + const forcedCount = force ? selection.forceableCount : 0; + const confirmed = globalThis.confirm( + action === "slim" + ? `Delete regenerable directories (node_modules, build output, caches) from ${targetIds.length} session worktree(s)?\n\nEach session's history is exported first. The checkouts and branches stay intact.` + : action === "remove" + ? `Remove ${targetIds.length} session worktree(s) entirely?\n\nEach session's history is exported first. Reopening one of these sessions will have to re-create its worktree.` + : `Force-remove ${targetIds.length} session worktree(s)?\n\n${forcedCount} of them have uncommitted changes, untracked files, or commits that are not on any remote. That work will be PERMANENTLY LOST — only the exported history digest and transcript will remain.\n\nWorktrees in use by a live or active session are never removed, forced or not.`, + ); + if (!confirmed) { + return; + } + + setIsBusy(true); + const result = await reclaim({ + environmentId, + input: { threadIds: targetIds, mode, force }, + }); + setIsBusy(false); + + if (result._tag !== "Success") { + reportFailure("Reclaim failed", result); + return; + } + + const reclaimedCount = result.value.outcomes.filter((outcome) => outcome.reclaimed).length; + const skipped = result.value.outcomes.filter((outcome) => !outcome.reclaimed); + toastManager.add( + stackedThreadToast({ + type: skipped.length > 0 && reclaimedCount === 0 ? "error" : "success", + title: "Archived sessions reclaimed", + description: [ + describeReclaimResult({ + mode, + reclaimedCount, + skippedCount: skipped.length, + freedBytes: result.value.totalFreedBytes, + }), + // The first skip reason is far more useful than a count alone. + skipped[0]?.skippedReason ?? "", + ] + .filter(Boolean) + .join(" "), + }), + ); + + onReclaimed(); + await runScan(); + }, + [ + entries, + environmentId, + onReclaimed, + reclaim, + reportFailure, + runScan, + selectedThreadIds, + selection.forceableCount, + ], + ); + + const toggleEntry = useCallback((threadId: string) => { + setSelectedThreadIds((current) => { + const next = new Set(current); + if (next.has(threadId)) { + next.delete(threadId); + } else { + next.add(threadId); + } + return next; + }); + }, []); + + const selectScope = useCallback( + (scope: SelectionScope) => { + setSelectedThreadIds(applySelectionScope(entries, scope)); + }, + [entries], + ); + + const projectOptions = useMemo(() => projectGroups(entries), [entries]); + const stateOptions = useMemo(() => stateGroups(entries), [entries]); + + if (environmentId === null) { + return null; + } + + const visibleEntries = entries.slice(0, VISIBLE_ENTRY_LIMIT); + + return ( + <SettingsSection + title="Worktree disk" + headerAction={ + <Button + type="button" + variant="outline" + size="sm" + className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" + disabled={isBusy} + onClick={() => void runScan()} + > + {isBusy ? ( + <LoaderIcon className="size-3.5 animate-spin" /> + ) : ( + <RefreshCwIcon className="size-3.5" /> + )} + <span>{scanResult === null ? "Scan" : "Rescan"}</span> + </Button> + } + > + {scanResult === null ? ( + <SettingsRow + title={ + <span className="inline-flex items-center gap-2"> + <HardDriveIcon className="size-3.5 text-muted-foreground" /> + Reclaim disk from archived sessions + </span> + } + description="Archived sessions keep their worktree on disk indefinitely. Scanning measures what each one still occupies and what can be given back. Every reclaim exports the session's history first, so nothing you did is lost." + /> + ) : ( + <> + <SettingsRow + title="Scan results" + description={describeScanSummary(scanResult)} + status={`History is written to ${scanResult.historyDir}`} + control={ + <div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5"> + <Button + type="button" + variant="outline" + size="sm" + className="h-7 cursor-pointer px-2.5" + disabled={isBusy || !selection.canSlim} + onClick={() => void runReclaim("slim")} + > + Slim ({formatBytes(selection.reclaimableBytes)}) + </Button> + <Button + type="button" + variant="destructive" + size="sm" + className="h-7 cursor-pointer px-2.5" + disabled={isBusy || !selection.canRemove} + onClick={() => void runReclaim("remove")} + > + Remove worktree + </Button> + <Button + type="button" + variant="destructive" + size="sm" + className="h-7 cursor-pointer px-2.5" + disabled={isBusy || !selection.canForceRemove} + onClick={() => void runReclaim("force-remove")} + title="Remove worktrees even when they hold uncommitted or unpushed work. Live and shared worktrees are still never removed." + > + Force remove ({selection.forceRemoveCount}) + </Button> + </div> + } + /> + + {/* Bulk selection lives on its own row: with hundreds of archived + sessions, picking a set is a distinct step from acting on it. */} + <SettingsRow + title="Select" + description={ + selection.selectedCount === 0 + ? "Nothing selected." + : `${selection.selectedCount} selected · ${selection.eligibleCount} slimmable · ${selection.canRemove ? selectionTargets(entries, selectedThreadIds, "remove").length : 0} removable${selection.forceableCount > 0 ? ` · ${selection.forceableCount} need force` : ""}` + } + control={ + <div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5"> + <Button + type="button" + variant="outline" + size="sm" + className="h-7 cursor-pointer px-2.5" + disabled={isBusy || entries.length === 0} + onClick={() => selectScope({ kind: "all" })} + > + All ({entries.length}) + </Button> + <Button + type="button" + variant="outline" + size="sm" + className="h-7 cursor-pointer px-2.5" + disabled={isBusy || selection.selectedCount === 0} + onClick={() => selectScope({ kind: "none" })} + > + None + </Button> + <Button + type="button" + variant="outline" + size="sm" + className="h-7 cursor-pointer px-2.5" + disabled={isBusy || entries.length === 0} + onClick={() => selectScope({ kind: "reclaimable" })} + > + Reclaimable + </Button> + <Select + aria-label="Select all worktrees in a project" + disabled={isBusy || projectOptions.length === 0} + value="" + onValueChange={(value) => { + if (typeof value === "string" && value.length > 0) { + selectScope({ kind: "project", projectId: value }); + } + }} + > + <SelectTrigger className="h-7 w-40"> + <SelectValue placeholder="By project…" /> + </SelectTrigger> + <SelectPopup> + {projectOptions.map((group) => ( + <SelectItem key={group.id} value={group.id}> + {group.label} ({group.count}) + </SelectItem> + ))} + </SelectPopup> + </Select> + <Select + aria-label="Select all worktrees in a state" + disabled={isBusy || stateOptions.length === 0} + value="" + onValueChange={(value) => { + if ( + value === "present" || + value === "slimmed" || + value === "removed" || + value === "missing" + ) { + selectScope({ kind: "state", state: value }); + } + }} + > + <SelectTrigger className="h-7 w-40"> + <SelectValue placeholder="By state…" /> + </SelectTrigger> + <SelectPopup> + {stateOptions.map((group) => ( + <SelectItem key={group.id} value={group.id}> + {group.label} ({group.count}) + </SelectItem> + ))} + </SelectPopup> + </Select> + </div> + } + /> + + {visibleEntries.map((entry) => ( + <SettingsRow + key={entry.threadId} + title={ + <span className="inline-flex items-center gap-2"> + <Checkbox + checked={selectedThreadIds.has(entry.threadId)} + // Selectable whenever *some* action applies. An entry a + // plain slim refuses may still be force-removable, and a + // checkbox that cannot be ticked would hide that. + disabled={isBusy || !isEntryActionable(entry)} + onCheckedChange={() => toggleEntry(entry.threadId)} + aria-label={`Select ${entry.title}`} + /> + <span className="truncate">{entry.title}</span> + </span> + } + description={ + <> + {formatBytes(entry.worktreeBytes)} on disk + {entry.reclaimableBytes !== null && entry.reclaimableBytes > 0 + ? ` · ${formatBytes(entry.reclaimableBytes)} reclaimable` + : ""} + {entry.branch ? ` · ${entry.branch}` : ""} + {entry.projectName ? ` · ${entry.projectName}` : ""} + </> + } + status={ + entry.blockedReason !== null + ? blockedText(entry.blockedReason) + : isForceableBlockedReason(entry.removeBlockedReason) + ? `${describeReclaimState(entry)} · ${blockedText(entry.removeBlockedReason)} Force remove overrides this.` + : describeReclaimState(entry) + } + control={<Badge {...entryBadge(entry)} />} + /> + ))} + + {entries.length > VISIBLE_ENTRY_LIMIT ? ( + <SettingsRow + title={`${entries.length - VISIBLE_ENTRY_LIMIT} more archived sessions`} + description="Only the largest are listed. Reclaim these, then rescan to see the rest." + /> + ) : null} + + {scanResult.orphanedWorktrees.length > 0 ? ( + <SettingsRow + title={`${scanResult.orphanedWorktrees.length} orphaned worktrees`} + description="Worktree directories on disk that no session points at. They are never reclaimed automatically, because nothing in the database can say what is safe about them — inspect and remove these by hand." + status={ + showOrphans + ? scanResult.orphanedWorktrees + .slice(0, 10) + .map((orphan) => orphan.worktreePath) + .join("\n") + : undefined + } + control={ + <Button + type="button" + variant="outline" + size="sm" + className="h-7 shrink-0 cursor-pointer px-2.5" + onClick={() => setShowOrphans((current) => !current)} + > + {showOrphans ? "Hide paths" : "Show paths"} + </Button> + } + /> + ) : null} + </> + )} + </SettingsSection> + ); +} + +/** + * Badge for one row. + * + * Three states, not two: an entry a plain remove refuses but a forced one would + * take reads as "Needs force", so the list distinguishes work-at-risk from a + * worktree that is genuinely untouchable. + */ +function entryBadge(entry: SessionArchiveEntry): { + readonly variant: "success" | "warning" | "error"; + readonly className: string; + readonly children: string; +} { + if (entry.blockedReason !== null) { + return { variant: "warning", className: "shrink-0", children: "Held" }; + } + if (isForceableBlockedReason(entry.removeBlockedReason)) { + return { variant: "error", className: "shrink-0", children: "Needs force" }; + } + return { variant: "success", className: "shrink-0", children: "Reclaimable" }; +} + +/** Mirrors the server's `describeBlockedReason`, phrased for this list. */ +function blockedText(reason: string): string { + switch (reason) { + case "worktree-shared": + return "Held — another active session uses this worktree."; + case "worktree-live": + return "Held — a session is running out of this worktree."; + case "dirty-worktree": + return "Held — uncommitted or untracked changes."; + case "unpushed-commits": + return "Held — commits are not on any remote."; + case "retention-window": + return "Held — archived too recently."; + case "no-worktree": + return "Nothing on disk to reclaim."; + default: + return "Held."; + } +} diff --git a/apps/web/src/components/settings/SessionArchiveSettingsSection.tsx b/apps/web/src/components/settings/SessionArchiveSettingsSection.tsx new file mode 100644 index 00000000000..5a2498c7978 --- /dev/null +++ b/apps/web/src/components/settings/SessionArchiveSettingsSection.tsx @@ -0,0 +1,195 @@ +/** + * T3-CUSTOM(expbkt3): Archived-session worktree reclaim settings. + * + * Server settings, because the sweep runs on the server against its own disk. + * Rendered in the Experiments tab; the manual panel lives on the Archived page. + * + * The auto-sweep is off by default and deliberately hard to turn on casually: + * it deletes from disk on a timer, and the retention window is the only thing + * between it and a session someone archived an hour ago. + */ +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; + +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; + +const DEFAULTS = DEFAULT_UNIFIED_SETTINGS.experimental.sessionArchive; +const MAX_RETENTION_DAYS = 365; + +export function SessionArchiveSettingsSection() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const archive = settings.experimental.sessionArchive; + + // `updateSettings` takes a whole-value patch, so merge onto current values. + const patch = (next: Partial<typeof archive>) => { + updateSettings({ + experimental: { + ...settings.experimental, + sessionArchive: { ...archive, ...next }, + }, + }); + }; + + const patchSweep = (next: Partial<typeof archive.autoSweep>) => { + patch({ autoSweep: { ...archive.autoSweep, ...next } }); + }; + + const sweepLabel = !archive.autoSweep.enabled + ? "Off — sessions are only reclaimed when you ask on the Archived page." + : archive.autoSweep.mode === "slim" + ? `Deletes regenerable directories from worktrees archived more than ${archive.autoSweep.minArchivedDays} days ago.` + : `Removes worktrees archived more than ${archive.autoSweep.minArchivedDays} days ago, when they are clean and pushed.`; + + return ( + <SettingsSection title="Archived session storage"> + <SettingsRow + title="Keep session history when reclaiming" + description="Archiving a session keeps its worktree on disk forever, so the only way to free the space is to delete the session. Turning this on lets the Archived page reclaim that disk after exporting the session's history — a digest plus a full transcript — to a directory outside the worktree." + resetAction={ + archive.enabled !== DEFAULTS.enabled ? ( + <SettingResetButton + label="session archive reclaim" + onClick={() => patch({ enabled: DEFAULTS.enabled })} + /> + ) : null + } + control={ + <Switch + aria-label="Enable archived session reclaim" + checked={archive.enabled} + onCheckedChange={(checked) => patch({ enabled: Boolean(checked) })} + /> + } + /> + <SettingsRow + title="Write full transcripts" + description="Alongside each digest, write a .jsonl of every message in the session. Complete, but large — turn this off if you only want the readable summary." + resetAction={ + archive.includeTranscriptSidecar !== DEFAULTS.includeTranscriptSidecar ? ( + <SettingResetButton + label="transcript sidecars" + onClick={() => patch({ includeTranscriptSidecar: DEFAULTS.includeTranscriptSidecar })} + /> + ) : null + } + control={ + <Switch + aria-label="Write full transcript sidecars" + checked={archive.includeTranscriptSidecar} + disabled={!archive.enabled} + onCheckedChange={(checked) => patch({ includeTranscriptSidecar: Boolean(checked) })} + /> + } + /> + <SettingsRow + title="Sweep automatically" + description="Reclaim old archived sessions on a timer instead of by hand. Sessions whose worktree is shared with an active session, or that something is running out of, are never touched." + status={sweepLabel} + resetAction={ + archive.autoSweep.enabled !== DEFAULTS.autoSweep.enabled ? ( + <SettingResetButton + label="automatic sweep" + onClick={() => patchSweep({ enabled: DEFAULTS.autoSweep.enabled })} + /> + ) : null + } + control={ + <Switch + aria-label="Sweep archived sessions automatically" + checked={archive.autoSweep.enabled} + disabled={!archive.enabled} + onCheckedChange={(checked) => patchSweep({ enabled: Boolean(checked) })} + /> + } + /> + <SettingsRow + title="Sweep action" + description="Slim deletes only regenerable directories and leaves a usable checkout. Remove runs git worktree remove, and refuses on anything dirty or unpushed." + resetAction={ + archive.autoSweep.mode !== DEFAULTS.autoSweep.mode ? ( + <SettingResetButton + label="sweep action" + onClick={() => patchSweep({ mode: DEFAULTS.autoSweep.mode })} + /> + ) : null + } + control={ + <Select + aria-label="Automatic sweep action" + disabled={!archive.enabled || !archive.autoSweep.enabled} + value={archive.autoSweep.mode} + onValueChange={(value) => { + if (value === "slim" || value === "remove") { + patchSweep({ mode: value }); + } + }} + > + <SelectTrigger className="h-7 w-40"> + <SelectValue /> + </SelectTrigger> + <SelectPopup> + <SelectItem value="slim">Slim worktree</SelectItem> + <SelectItem value="remove">Remove worktree</SelectItem> + </SelectPopup> + </Select> + } + /> + <SettingsRow + title="Reclaim after" + description="Days a session must sit archived before the sweep may touch it. This window is the only protection against reclaiming something you archived by mistake, so keep it generous." + status={`${archive.autoSweep.minArchivedDays} days`} + resetAction={ + archive.autoSweep.minArchivedDays !== DEFAULTS.autoSweep.minArchivedDays ? ( + <SettingResetButton + label="retention window" + onClick={() => patchSweep({ minArchivedDays: DEFAULTS.autoSweep.minArchivedDays })} + /> + ) : null + } + control={ + <NumberField + aria-label="Reclaim after N days" + className="w-32 gap-0" + disabled={!archive.enabled || !archive.autoSweep.enabled} + max={MAX_RETENTION_DAYS} + min={0} + onValueChange={(next) => { + if (typeof next === "number" && Number.isFinite(next)) { + patchSweep({ minArchivedDays: Math.round(next) }); + } + }} + size="sm" + step={1} + value={archive.autoSweep.minArchivedDays} + > + <NumberFieldGroup className="h-7 rounded-md"> + <NumberFieldDecrement + aria-label="Decrease retention window" + className="px-2 [&_svg]:size-3.5" + /> + <NumberFieldInput + aria-label="Reclaim after N days" + className="h-7 w-14 grow-0 px-0 text-xs leading-7" + inputMode="numeric" + /> + <NumberFieldIncrement + aria-label="Increase retention window" + className="px-2 [&_svg]:size-3.5" + /> + </NumberFieldGroup> + </NumberField> + } + /> + </SettingsSection> + ); +} diff --git a/apps/web/src/components/settings/SessionWorkSummarySettingsSection.tsx b/apps/web/src/components/settings/SessionWorkSummarySettingsSection.tsx new file mode 100644 index 00000000000..f34f813ecca --- /dev/null +++ b/apps/web/src/components/settings/SessionWorkSummarySettingsSection.tsx @@ -0,0 +1,269 @@ +/** + * T3-CUSTOM(expbkt3): AI work summary + progress assessment controls. + * + * These power the "Work summary" and "Progress" columns of the bulk sessions + * manager. Generation runs on the server, so every knob is a server setting and + * the section lives in the Experiments tab beside the catch-up summary. + */ +import { useAtomValue } from "@effect/atom-react"; +import { useState } from "react"; +import * as Equal from "effect/Equal"; +import { + DEFAULT_UNIFIED_SETTINGS, + MAX_SESSION_SUMMARY_DATA_LIMIT_CHARS, + MIN_SESSION_SUMMARY_DATA_LIMIT_CHARS, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; + +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { + getCustomModelOptionsByInstance, + resolveAppModelSelectionState, +} from "../../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { primaryServerProvidersAtom } from "../../state/server"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; + +const DEFAULT_WORK_SUMMARY = DEFAULT_UNIFIED_SETTINGS.experimental.sessionWorkSummary; + +/** Compact numeric control, matching the catch-up summary section. */ +function SettingsNumberField({ + ariaLabel, + value, + min, + max, + step, + disabled, + onValueChange, +}: { + ariaLabel: string; + value: number; + min: number; + max: number; + step: number; + disabled: boolean; + onValueChange: (value: number) => void; +}) { + return ( + <NumberField + aria-label={ariaLabel} + className="w-32 gap-0" + disabled={disabled} + max={max} + min={min} + onValueChange={(next) => { + if (typeof next === "number" && Number.isFinite(next)) { + onValueChange(next); + } + }} + size="sm" + step={step} + value={value} + > + <NumberFieldGroup className="h-7 rounded-md"> + <NumberFieldDecrement + aria-label={`Decrease ${ariaLabel}`} + className="px-2 [&_svg]:size-3.5" + /> + <NumberFieldInput + aria-label={ariaLabel} + className="h-7 w-14 grow-0 px-0 text-xs leading-7" + inputMode="numeric" + /> + <NumberFieldIncrement + aria-label={`Increase ${ariaLabel}`} + className="px-2 [&_svg]:size-3.5" + /> + </NumberFieldGroup> + </NumberField> + ); +} + +/** + * Work summary controls, rendered inside the Experiments settings tab. + * These are server settings because the assessment runs on the server. + */ +export function SessionWorkSummarySettingsSection() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + + const workSummary = settings.experimental.sessionWorkSummary; + const enabled = workSummary.enabled; + + // Reuse the text-generation resolver by pointing it at this feature's own + // selection, so fallback behavior for disabled/unavailable instances matches. + const workSummaryModelSelection = resolveAppModelSelectionState( + { ...settings, textGenerationModelSelection: workSummary.modelSelection }, + serverProviders, + ); + const workSummaryInstanceId = workSummaryModelSelection.instanceId; + const workSummaryModel = workSummaryModelSelection.model; + const instanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + ); + const modelOptionsByInstance = getCustomModelOptionsByInstance( + settings, + serverProviders, + workSummaryInstanceId, + workSummaryModel, + ); + + const isEnabledDirty = enabled !== DEFAULT_WORK_SUMMARY.enabled; + const isModelDirty = !Equal.equals( + workSummary.modelSelection ?? null, + DEFAULT_WORK_SUMMARY.modelSelection ?? null, + ); + const isDataLimitDirty = workSummary.dataLimitChars !== DEFAULT_WORK_SUMMARY.dataLimitChars; + const isPromptDirty = workSummary.promptInstructions !== DEFAULT_WORK_SUMMARY.promptInstructions; + + // `updateSettings` takes a whole-value patch, so merge onto current values. + const patchWorkSummary = (patch: Partial<typeof workSummary>) => { + updateSettings({ + experimental: { + ...settings.experimental, + sessionWorkSummary: { ...workSummary, ...patch }, + }, + }); + }; + + // Buffer keystrokes so each character does not round-trip to the server. + // Commits on blur only — Enter must insert a newline in a prompt field. + const [promptDraft, setPromptDraft] = useState<string | null>(null); + + return ( + <SettingsSection title="Session work summary"> + <SettingsRow + title="Generate work summaries" + description="Fill the AI work summary and progress columns in the sessions manager: what each session actually did, and how far along it looks. Generated on the server, so the columns are the same for everyone." + resetAction={ + isEnabledDirty ? ( + <SettingResetButton + label="work summaries" + onClick={() => patchWorkSummary({ enabled: DEFAULT_WORK_SUMMARY.enabled })} + /> + ) : null + } + control={ + <Switch + checked={enabled} + onCheckedChange={(checked) => patchWorkSummary({ enabled: Boolean(checked) })} + aria-label="Generate work summaries" + /> + } + /> + + <SettingsRow + title="Work summary model" + description="Model used to write the work summary and judge progress. Codex instances use your ChatGPT subscription; OpenCode instances can reach OpenRouter models." + resetAction={ + isModelDirty ? ( + <SettingResetButton + label="work summary model" + onClick={() => + patchWorkSummary({ modelSelection: DEFAULT_WORK_SUMMARY.modelSelection }) + } + /> + ) : null + } + control={ + <div className="flex flex-wrap items-center justify-end gap-1.5"> + <ProviderModelPicker + activeInstanceId={workSummaryInstanceId} + model={workSummaryModel} + lockedProvider={null} + instanceEntries={instanceEntries} + modelOptionsByInstance={modelOptionsByInstance} + triggerVariant="outline" + triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" + disabled={!enabled} + onInstanceModelChange={(instanceId, model) => { + patchWorkSummary({ + modelSelection: createModelSelection(instanceId, model), + }); + }} + /> + </div> + } + /> + + <SettingsRow + title="Session data limit" + description="Maximum characters of transcript read per session when writing its work summary. Lower values cost fewer tokens across a full table; higher values give the model more context." + resetAction={ + isDataLimitDirty ? ( + <SettingResetButton + label="session data limit" + onClick={() => + patchWorkSummary({ dataLimitChars: DEFAULT_WORK_SUMMARY.dataLimitChars }) + } + /> + ) : null + } + control={ + <div className="flex items-center gap-2"> + <SettingsNumberField + ariaLabel="Work summary session data limit in characters" + disabled={!enabled} + max={MAX_SESSION_SUMMARY_DATA_LIMIT_CHARS} + min={MIN_SESSION_SUMMARY_DATA_LIMIT_CHARS} + onValueChange={(value) => patchWorkSummary({ dataLimitChars: value })} + step={1_000} + value={workSummary.dataLimitChars} + /> + <span className="text-muted-foreground text-xs">characters</span> + </div> + } + /> + + <SettingsRow + title="Extra prompt instructions" + description="Appended to the work summary prompt. Use it to steer what the columns emphasize — for example: always name the ticket, or judge progress against the PR being merged." + resetAction={ + isPromptDirty ? ( + <SettingResetButton + label="prompt instructions" + onClick={() => + patchWorkSummary({ + promptInstructions: DEFAULT_WORK_SUMMARY.promptInstructions, + }) + } + /> + ) : null + } + > + <Textarea + value={promptDraft ?? workSummary.promptInstructions} + onChange={(event) => setPromptDraft(event.target.value)} + onFocus={() => setPromptDraft(workSummary.promptInstructions)} + onBlur={() => { + const next = promptDraft ?? workSummary.promptInstructions; + setPromptDraft(null); + if (next !== workSummary.promptInstructions) { + patchWorkSummary({ promptInstructions: next }); + } + }} + className="mb-3.5 min-h-20 w-full text-[13px]" + disabled={!enabled} + placeholder="Optional. Leave empty to use the default prompt." + spellCheck={false} + aria-label="Extra work summary prompt instructions" + /> + </SettingsRow> + </SettingsSection> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5cfd773971f..d11f0dbebba 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -136,6 +136,9 @@ import { useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; +// T3-CUSTOM(expbkt3): BEGIN — archived-session worktree reclaim. +import { SessionArchiveReclaimSection } from "./SessionArchiveReclaimSection"; +// T3-CUSTOM(expbkt3): END import { ProjectFavicon } from "../ProjectFavicon"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = { @@ -2347,6 +2350,12 @@ export function ArchivedThreadsPanel() { return ( <SettingsPageContainer> + {/* T3-CUSTOM(expbkt3): BEGIN — reclaim disk from archived sessions' worktrees. */} + <SessionArchiveReclaimSection + environmentIds={environmentIds} + onReclaimed={refreshArchivedThreads} + /> + {/* T3-CUSTOM(expbkt3): END */} {archivedGroups.length === 0 ? ( <SettingsSection id={isLoadingArchive ? undefined : searchableSetting("archive").id} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 9bcf60ae391..cf0c4fbd37a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -209,6 +209,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Restore plan mode (legacy)", to: "/settings/beta", }, + // T3-CUSTOM(expbkt3): native plan review. + { + id: "native-plan-review", + title: "Native plan review", + to: "/settings/beta", + }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.test.ts b/apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.test.ts new file mode 100644 index 00000000000..6ca251ae4f8 --- /dev/null +++ b/apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.test.ts @@ -0,0 +1,125 @@ +// T3-CUSTOM(expbkt3): "move under session" candidate coverage. +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { ThreadShell } from "../../types"; +import { + collectDescendantThreadIds, + resolveMoveUnderCandidates, +} from "./MoveUnderSessionDialog.logic"; + +const environmentId = EnvironmentId.make("environment-local"); +const otherEnvironmentId = EnvironmentId.make("environment-remote"); + +function makeThread( + id: string, + options: { + readonly parent?: string | null; + readonly archived?: boolean; + readonly updatedAt?: string; + readonly environment?: EnvironmentId; + readonly title?: string; + } = {}, +): ThreadShell { + return { + id: ThreadId.make(id), + environmentId: options.environment ?? environmentId, + projectId: ProjectId.make("project-1"), + title: options.title ?? `Thread ${id}`, + parentThreadId: options.parent == null ? null : ThreadId.make(options.parent), + archivedAt: options.archived ? "2026-01-01T00:00:00.000Z" : null, + updatedAt: options.updatedAt ?? "2026-01-01T00:00:00.000Z", + } as unknown as ThreadShell; +} + +const repositoryLabelFor = () => "repo-one"; + +describe("collectDescendantThreadIds", () => { + it("walks the whole subtree", () => { + const threads = [ + makeThread("root"), + makeThread("child", { parent: "root" }), + makeThread("grandchild", { parent: "child" }), + ]; + + expect([...collectDescendantThreadIds(threads, "root")].toSorted()).toEqual([ + "child", + "grandchild", + ]); + }); + + it("terminates on a corrupt cycle", () => { + const threads = [makeThread("a", { parent: "b" }), makeThread("b", { parent: "a" })]; + + expect([...collectDescendantThreadIds(threads, "a")].toSorted()).toEqual(["b"]); + }); +}); + +describe("resolveMoveUnderCandidates", () => { + const subject = makeThread("subject", { parent: "current-parent" }); + const threads = [ + subject, + makeThread("current-parent"), + makeThread("child", { parent: "subject" }), + makeThread("grandchild", { parent: "child" }), + makeThread("eligible", { updatedAt: "2026-02-01T00:00:00.000Z" }), + makeThread("older", { updatedAt: "2026-01-05T00:00:00.000Z" }), + makeThread("archived", { archived: true }), + makeThread("elsewhere", { environment: otherEnvironmentId }), + ]; + + const ids = (query = "") => + resolveMoveUnderCandidates({ threads, subject, query, repositoryLabelFor }).map( + (candidate) => candidate.thread.id, + ); + + it("never offers the thread itself", () => { + expect(ids()).not.toContain("subject"); + }); + + it("never offers a descendant, because the server would reject the cycle", () => { + expect(ids()).not.toContain("child"); + expect(ids()).not.toContain("grandchild"); + }); + + it("omits the current parent, which is already where the thread sits", () => { + expect(ids()).not.toContain("current-parent"); + }); + + it("omits archived threads", () => { + expect(ids()).not.toContain("archived"); + }); + + it("omits threads from another environment, since lineage is environment-local", () => { + expect(ids()).not.toContain("elsewhere"); + }); + + it("orders the most recently touched session first", () => { + expect(ids()).toEqual(["eligible", "older"]); + }); + + it("filters by title, case-insensitively", () => { + const matches = resolveMoveUnderCandidates({ + threads: [subject, makeThread("hit", { title: "Migrate Billing" }), makeThread("miss")], + subject, + query: "billing", + repositoryLabelFor, + }); + + expect(matches.map((candidate) => candidate.thread.id)).toEqual(["hit"]); + }); + + it("honours the result limit", () => { + const many = Array.from({ length: 80 }, (_, index) => makeThread(`t-${index}`)); + + expect( + resolveMoveUnderCandidates({ + threads: [subject, ...many], + subject, + query: "", + repositoryLabelFor, + limit: 10, + }), + ).toHaveLength(10); + }); +}); diff --git a/apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.ts b/apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.ts new file mode 100644 index 00000000000..e056055ad9a --- /dev/null +++ b/apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.ts @@ -0,0 +1,79 @@ +// T3-CUSTOM(expbkt3): candidate resolution for the "move under session" picker. +// +// Kept separate from the dialog so the rule that decides which sessions may +// become a parent is testable on its own — it is the client-side mirror of the +// server's cycle guard, and the two must not drift. +import type { ThreadShell } from "../../types"; + +export interface MoveUnderCandidate { + readonly thread: ThreadShell; + readonly label: string; + readonly repositoryLabel: string; +} + +/** + * Every thread reachable downwards from `threadId`, excluding itself. Bounded + * by the thread count: each id is enqueued at most once, so a corrupt cycle in + * the projection cannot make this loop forever. + */ +export function collectDescendantThreadIds( + threads: ReadonlyArray<ThreadShell>, + threadId: string, +): ReadonlySet<string> { + const descendants = new Set<string>(); + const queue: string[] = [threadId]; + while (queue.length > 0) { + const current = queue.pop() as string; + for (const thread of threads) { + if ((thread.parentThreadId ?? null) !== current) continue; + if (thread.id === threadId || descendants.has(thread.id)) continue; + descendants.add(thread.id); + queue.push(thread.id); + } + } + return descendants; +} + +/** + * Candidate parents for `subject`, newest first, filtered by `query`. + * + * Excluded: the thread itself, its descendants (the server would reject those + * as cycles, so offering them would only produce a confusing failure toast), + * archived threads, its current parent (already there), and — because lineage + * is a bare thread id resolved within one environment — anything from a + * different environment. + */ +export function resolveMoveUnderCandidates(input: { + readonly threads: ReadonlyArray<ThreadShell>; + readonly subject: ThreadShell; + readonly query: string; + readonly repositoryLabelFor: (thread: ThreadShell) => string; + readonly limit?: number; +}): ReadonlyArray<MoveUnderCandidate> { + const sameEnvironment = input.threads.filter( + (thread) => thread.environmentId === input.subject.environmentId, + ); + const blocked = collectDescendantThreadIds(sameEnvironment, input.subject.id); + const needle = input.query.trim().toLowerCase(); + + return sameEnvironment + .filter( + (thread) => + thread.id !== input.subject.id && + !blocked.has(thread.id) && + thread.archivedAt === null && + thread.id !== (input.subject.parentThreadId ?? null) && + (needle.length === 0 || thread.title.toLowerCase().includes(needle)), + ) + .toSorted( + (left, right) => + Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || + String(left.id).localeCompare(String(right.id)), + ) + .slice(0, input.limit ?? 50) + .map((thread) => ({ + thread, + label: thread.title, + repositoryLabel: input.repositoryLabelFor(thread), + })); +} diff --git a/apps/web/src/components/sidebar/MoveUnderSessionDialog.tsx b/apps/web/src/components/sidebar/MoveUnderSessionDialog.tsx new file mode 100644 index 00000000000..1dad019f1c9 --- /dev/null +++ b/apps/web/src/components/sidebar/MoveUnderSessionDialog.tsx @@ -0,0 +1,106 @@ +/** T3-CUSTOM(expbkt3): pick the session a thread should be filed under. */ +import type { ThreadId } from "@t3tools/contracts"; +import { CornerDownRightIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import type { ThreadShell } from "../../types"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { resolveMoveUnderCandidates } from "./MoveUnderSessionDialog.logic"; + +export function MoveUnderSessionDialog({ + subject, + threads, + repositoryLabelFor, + onOpenChange, + onSelect, +}: { + /** Null closes the dialog; the row being moved is the dialog's identity. */ + readonly subject: ThreadShell | null; + readonly threads: ReadonlyArray<ThreadShell>; + readonly repositoryLabelFor: (thread: ThreadShell) => string; + readonly onOpenChange: (open: boolean) => void; + readonly onSelect: (parentThreadId: ThreadId) => void; +}) { + const [query, setQuery] = useState(""); + + useEffect(() => { + if (subject) setQuery(""); + }, [subject]); + + const candidates = useMemo( + () => + subject ? resolveMoveUnderCandidates({ threads, subject, query, repositoryLabelFor }) : [], + [query, repositoryLabelFor, subject, threads], + ); + + return ( + <Dialog open={subject !== null} onOpenChange={onOpenChange}> + <DialogPopup> + <DialogHeader> + <DialogTitle>Move under session</DialogTitle> + <DialogDescription> + File “{subject?.title}” under another session. It will render nested beneath its parent + in the sidebar. Its own child sessions move with it. + </DialogDescription> + </DialogHeader> + <DialogPanel className="space-y-2"> + <Input + autoFocus + value={query} + placeholder="Search sessions…" + aria-label="Search sessions" + onChange={(event) => setQuery(event.target.value)} + /> + <ul + className="max-h-72 space-y-0.5 overflow-y-auto" + data-testid="move-under-session-candidates" + > + {candidates.map((candidate) => ( + <li key={candidate.thread.id}> + <button + type="button" + className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-row-hover" + onClick={() => { + onSelect(candidate.thread.id); + onOpenChange(false); + }} + > + <CornerDownRightIcon + aria-hidden + className="size-3 shrink-0 text-muted-foreground/60" + /> + <span className="min-w-0 flex-1 truncate text-xs">{candidate.label}</span> + <span className="shrink-0 truncate text-[10px] text-muted-foreground/70"> + {candidate.repositoryLabel} + </span> + </button> + </li> + ))} + {candidates.length === 0 ? ( + <li className="px-2 py-6 text-center text-xs text-muted-foreground"> + {/* Descendants are absent by design: the server rejects them as + cycles, so offering them would only produce a failure. */} + No eligible sessions + </li> + ) : null} + </ul> + </DialogPanel> + <DialogFooter> + <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}> + Cancel + </Button> + </DialogFooter> + </DialogPopup> + </Dialog> + ); +} diff --git a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts index d94af21415f..9bed3861c92 100644 --- a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts +++ b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts @@ -22,6 +22,7 @@ import { buildPhaseSidebarRepositoryOptions, derivePhaseSidebarRepositoryKey, isThreadAssignedToUser, + phaseSidebarThreadParticipantIds, filterVisiblePhaseSidebarRows, matchesPhaseSidebarFilters, partitionPhaseSidebarRows, @@ -308,6 +309,8 @@ function makeRow(overrides: Partial<PhaseSidebarRow> = {}): PhaseSidebarRow { providerKind: "codex", providerName: "Codex", isAssignedToMe: false, + isOwnedByMe: false, + participantUserIds: [], attentionPriority: 5, isUnreadCompletion: false, // Settlement and snooze default ON: most cases exercise the partition, @@ -704,7 +707,8 @@ describe("phase sidebar metadata and filters", () => { repositoryKeys: ["repo-2", "repo-1"], phaseIds: ["ready", "planning"], providerKinds: ["codex"], - assignedToMe: false, + ownedByMe: false, + participantUserIds: [], }), ).toBe(true); expect( @@ -712,13 +716,20 @@ describe("phase sidebar metadata and filters", () => { repositoryKeys: ["repo-1"], phaseIds: ["ready"], providerKinds: ["opencode"], - assignedToMe: false, + ownedByMe: false, + participantUserIds: [], }), ).toBe(false); expect( buildPhaseSidebarGroups( [row], - { repositoryKeys: ["missing"], phaseIds: [], providerKinds: [], assignedToMe: false }, + { + repositoryKeys: ["missing"], + phaseIds: [], + providerKinds: [], + ownedByMe: false, + participantUserIds: [], + }, "updated_at", ), ).toEqual([]); @@ -731,7 +742,8 @@ describe("phase sidebar metadata and filters", () => { repositoryKeys: ["repo-1"], phaseIds: ["plan_ready"], providerKinds: ["codex"], - assignedToMe: false, + ownedByMe: false, + participantUserIds: [], }, { repositories: new Map([["repo-1", "T3 Code"]]), @@ -757,7 +769,8 @@ describe("phase sidebar metadata and filters", () => { repositoryKeys: ["repo-1"], phaseIds: ["ready"], providerKinds: [], - assignedToMe: false, + ownedByMe: false, + participantUserIds: [], }); expect( @@ -766,7 +779,8 @@ describe("phase sidebar metadata and filters", () => { repositoryKeys: ["repo-1", "stale-repo"], phaseIds: ["ready"], providerKinds: ["codex", "stale-provider"], - assignedToMe: false, + ownedByMe: false, + participantUserIds: [], }, { repositoryKeys: new Set(["repo-1"]), @@ -778,7 +792,8 @@ describe("phase sidebar metadata and filters", () => { repositoryKeys: ["repo-1"], phaseIds: ["ready"], providerKinds: ["codex"], - assignedToMe: false, + ownedByMe: false, + participantUserIds: [], }); }); @@ -796,46 +811,87 @@ describe("phase sidebar metadata and filters", () => { ).toBe(false); }); - it("keeps only assigned rows when assignedToMe is on and all rows when off", () => { - const assignedRow = makeRow({ isAssignedToMe: true }); - const unassignedRow = makeRow({ isAssignedToMe: false }); - const assignedFilters = { ...EMPTY_PHASE_SIDEBAR_FILTERS, assignedToMe: true }; + // T3-CUSTOM(expbkt3): ownership, not "owner or tagged". The old filter matched + // the server's visibility rule, so every thread you could see satisfied it and + // turning it on changed nothing. + it("keeps only sessions this operator started when ownedByMe is on", () => { + const mine = makeRow({ isOwnedByMe: true, isAssignedToMe: true }); + // Tagged into someone else's session: visible, assigned, but not mine. + const theirs = makeRow({ isOwnedByMe: false, isAssignedToMe: true }); + const ownedFilters = { ...EMPTY_PHASE_SIDEBAR_FILTERS, ownedByMe: true }; + + expect(matchesPhaseSidebarFilters(mine, ownedFilters)).toBe(true); + expect(matchesPhaseSidebarFilters(theirs, ownedFilters)).toBe(false); + expect(matchesPhaseSidebarFilters(theirs, EMPTY_PHASE_SIDEBAR_FILTERS)).toBe(true); + }); - expect(matchesPhaseSidebarFilters(assignedRow, assignedFilters)).toBe(true); - expect(matchesPhaseSidebarFilters(unassignedRow, assignedFilters)).toBe(false); - expect(matchesPhaseSidebarFilters(assignedRow, EMPTY_PHASE_SIDEBAR_FILTERS)).toBe(true); - expect(matchesPhaseSidebarFilters(unassignedRow, EMPTY_PHASE_SIDEBAR_FILTERS)).toBe(true); + it("requires every selected person to be on the session", () => { + const withBoth = makeRow({ participantUserIds: ["user-a", "user-b", "user-me"] }); + const withOne = makeRow({ participantUserIds: ["user-a", "user-me"] }); + const onePerson = { ...EMPTY_PHASE_SIDEBAR_FILTERS, participantUserIds: ["user-a"] }; + const twoPeople = { ...EMPTY_PHASE_SIDEBAR_FILTERS, participantUserIds: ["user-a", "user-b"] }; + + expect(matchesPhaseSidebarFilters(withOne, onePerson)).toBe(true); + expect(matchesPhaseSidebarFilters(withBoth, onePerson)).toBe(true); + // Two people selected asks for their shared sessions, not the union. + expect(matchesPhaseSidebarFilters(withBoth, twoPeople)).toBe(true); + expect(matchesPhaseSidebarFilters(withOne, twoPeople)).toBe(false); }); - it("defaults assignedToMe to false when missing and reads it when present", () => { + it("counts the owner as a participant alongside tagged members", () => { + const owner = UserId.make("user_owner"); + const member = UserId.make("user_member"); + expect( - sanitizePhaseSidebarFilters({ - repositoryKeys: [], - phaseIds: [], - providerKinds: [], - }).assignedToMe, - ).toBe(false); + phaseSidebarThreadParticipantIds(makeThread({ ownerUserId: owner, memberUserIds: [member] })), + ).toEqual([owner, member]); + // An owner who is also tagged appears once. + expect( + phaseSidebarThreadParticipantIds(makeThread({ ownerUserId: owner, memberUserIds: [owner] })), + ).toEqual([owner]); + expect( + phaseSidebarThreadParticipantIds(makeThread({ ownerUserId: null, memberUserIds: [member] })), + ).toEqual([member]); + }); + + it("defaults the new facets off when missing and reads them when present", () => { + const legacyBlob = { repositoryKeys: [], phaseIds: [], providerKinds: [] }; + + expect(sanitizePhaseSidebarFilters(legacyBlob).ownedByMe).toBe(false); + expect(sanitizePhaseSidebarFilters(legacyBlob).participantUserIds).toEqual([]); expect( sanitizePhaseSidebarFilters({ - repositoryKeys: [], - phaseIds: [], - providerKinds: [], - assignedToMe: true, - }).assignedToMe, - ).toBe(true); + ...legacyBlob, + ownedByMe: true, + participantUserIds: ["user-a", "user-a", ""], + }), + ).toMatchObject({ ownedByMe: true, participantUserIds: ["user-a"] }); }); - it("forces assignedToMe off when assignment is unavailable and preserves it otherwise", () => { - const filters = { ...EMPTY_PHASE_SIDEBAR_FILTERS, assignedToMe: true }; + it("clears people filters that outlive the directory or the operator identity", () => { + const filters = { + ...EMPTY_PHASE_SIDEBAR_FILTERS, + ownedByMe: true, + participantUserIds: ["user-a", "user-departed"], + }; const options = { repositoryKeys: new Set<string>(), providerKinds: new Set<string>() }; expect( - reconcilePhaseSidebarFilters(filters, { ...options, assignmentAvailable: false }) - .assignedToMe, - ).toBe(false); + reconcilePhaseSidebarFilters(filters, { ...options, assignmentAvailable: false }), + ).toMatchObject({ ownedByMe: false, participantUserIds: [] }); + // Without a directory set the selection is left alone: an empty list while + // the directory loads must not wipe a good filter. expect( - reconcilePhaseSidebarFilters(filters, { ...options, assignmentAvailable: true }).assignedToMe, - ).toBe(true); + reconcilePhaseSidebarFilters(filters, { ...options, assignmentAvailable: true }) + .participantUserIds, + ).toEqual(["user-a", "user-departed"]); + expect( + reconcilePhaseSidebarFilters(filters, { + ...options, + assignmentAvailable: true, + participantUserIds: new Set(["user-a"]), + }), + ).toMatchObject({ ownedByMe: true, participantUserIds: ["user-a"] }); }); it("traverses only visible filtered rows and starts at an edge when the active row is hidden", () => { diff --git a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts index 84d50ade115..c2eefeb77e5 100644 --- a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts +++ b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts @@ -179,16 +179,40 @@ export interface PhaseSidebarFilters { readonly repositoryKeys: ReadonlyArray<string>; readonly phaseIds: ReadonlyArray<PhaseSidebarPhaseId>; readonly providerKinds: ReadonlyArray<string>; - readonly assignedToMe: boolean; + /** + * T3-CUSTOM(expbkt3): sessions this operator started. + * + * NOT "owner or tagged": that is the server's own visibility rule, so every + * thread you can see already satisfies it and the filter selected everything. + * Ownership is the distinction that means something — my sessions versus the + * ones I was pulled into. + */ + readonly ownedByMe: boolean; + /** + * T3-CUSTOM(expbkt3): show only sessions ALL of these people are on. Anyone + * listed here is a co-participant on threads you can already see, since + * visibility never widens — the filter narrows to shared work. + */ + readonly participantUserIds: ReadonlyArray<string>; } export const EMPTY_PHASE_SIDEBAR_FILTERS: PhaseSidebarFilters = { repositoryKeys: [], phaseIds: [], providerKinds: [], - assignedToMe: false, + ownedByMe: false, + participantUserIds: [], }; +/** T3-CUSTOM(expbkt3): everyone on a thread, owner included. */ +export function phaseSidebarThreadParticipantIds( + thread: Pick<ThreadShell, "ownerUserId" | "memberUserIds">, +): ReadonlyArray<string> { + return thread.ownerUserId === null + ? thread.memberUserIds + : [thread.ownerUserId, ...thread.memberUserIds.filter((id) => id !== thread.ownerUserId)]; +} + /** * "Assigned to me" = owned by, or directly tagged on, the thread. A thread made * visible only by a project tag is not "assigned" (matches the server rule). @@ -208,6 +232,10 @@ export interface PhaseSidebarRow { readonly providerKind: string; readonly providerName: string; readonly isAssignedToMe: boolean; + // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant facets. + readonly isOwnedByMe: boolean; + readonly participantUserIds: ReadonlyArray<string>; + // T3-CUSTOM(expbkt3): END readonly attentionPriority: number; readonly isUnreadCompletion: boolean; /** False on environments whose server predates thread.settle/unsettle: @@ -369,7 +397,8 @@ export interface PhaseSidebarGroup extends PhaseSidebarPhaseDefinition { } export interface PhaseSidebarFilterChip { - readonly facet: "repository" | "phase" | "provider" | "assignment"; + // T3-CUSTOM(expbkt3): "person" is the co-participant facet. + readonly facet: "repository" | "phase" | "provider" | "assignment" | "person"; readonly value: string; readonly label: string; } @@ -570,7 +599,13 @@ export function matchesPhaseSidebarFilters( (filters.repositoryKeys.length === 0 || filters.repositoryKeys.includes(row.repositoryKey)) && (filters.phaseIds.length === 0 || filters.phaseIds.includes(row.phaseId)) && (filters.providerKinds.length === 0 || filters.providerKinds.includes(row.providerKind)) && - (!filters.assignedToMe || row.isAssignedToMe) + // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant facets. + (!filters.ownedByMe || row.isOwnedByMe) && + // Every selected person must be on the thread: selecting two people asks + // for their shared sessions, not the union of their work. + (filters.participantUserIds.length === 0 || + filters.participantUserIds.every((userId) => row.participantUserIds.includes(userId))) + // T3-CUSTOM(expbkt3): END ); } @@ -737,8 +772,10 @@ export function sanitizePhaseSidebarFilters(value: unknown): PhaseSidebarFilters (phaseId): phaseId is PhaseSidebarPhaseId => PHASE_ID_SET.has(phaseId), ), providerKinds: sanitizeStringArray(candidate.providerKinds), - // Missing on older persisted (v1) blobs ⇒ default off; storage stays v1. - assignedToMe: candidate.assignedToMe === true, + // T3-CUSTOM(expbkt3): missing on blobs written before these facets existed, + // so both default off; storage stays v1. + ownedByMe: candidate.ownedByMe === true, + participantUserIds: sanitizeStringArray(candidate.participantUserIds), }; } @@ -748,15 +785,24 @@ export function reconcilePhaseSidebarFilters( readonly repositoryKeys: ReadonlySet<string>; readonly providerKinds: ReadonlySet<string>; // False on single-user builds (no operator identity): a persisted - // "assigned to me" filter would otherwise hide every thread. + // ownership filter would otherwise hide every thread. readonly assignmentAvailable: boolean; + // T3-CUSTOM(expbkt3): people still present in the directory. A teammate who + // leaves must not keep an invisible filter pinned over the sidebar. + readonly participantUserIds?: ReadonlySet<string>; }, ): PhaseSidebarFilters { + const knownParticipants = options.participantUserIds; return { repositoryKeys: filters.repositoryKeys.filter((key) => options.repositoryKeys.has(key)), phaseIds: filters.phaseIds.filter((phaseId) => PHASE_ID_SET.has(phaseId)), providerKinds: filters.providerKinds.filter((kind) => options.providerKinds.has(kind)), - assignedToMe: options.assignmentAvailable ? filters.assignedToMe : false, + ownedByMe: options.assignmentAvailable ? filters.ownedByMe : false, + participantUserIds: !options.assignmentAvailable + ? [] + : knownParticipants === undefined + ? filters.participantUserIds + : filters.participantUserIds.filter((userId) => knownParticipants.has(userId)), }; } @@ -765,6 +811,8 @@ export function buildPhaseSidebarFilterChips( labels: { readonly repositories: ReadonlyMap<string, string>; readonly providers: ReadonlyMap<string, string>; + // T3-CUSTOM(expbkt3): display names for the co-participant facet. + readonly people?: ReadonlyMap<string, string>; }, ): ReadonlyArray<PhaseSidebarFilterChip> { const phaseLabels = new Map(PHASE_SIDEBAR_PHASES.map((phase) => [phase.id, phase.label])); @@ -784,9 +832,16 @@ export function buildPhaseSidebarFilterChips( value, label: labels.providers.get(value) ?? value, })), - ...(filters.assignedToMe - ? [{ facet: "assignment" as const, value: "assigned-to-me", label: "Assigned to me" }] + // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant chips. + ...(filters.ownedByMe + ? [{ facet: "assignment" as const, value: "owned-by-me", label: "Started by me" }] : []), + ...filters.participantUserIds.map((value) => ({ + facet: "person" as const, + value, + label: labels.people?.get(value) ?? "Teammate", + })), + // T3-CUSTOM(expbkt3): END ]; } diff --git a/apps/web/src/components/sidebar/PhaseSidebarTree.logic.test.ts b/apps/web/src/components/sidebar/PhaseSidebarTree.logic.test.ts new file mode 100644 index 00000000000..83db6281992 --- /dev/null +++ b/apps/web/src/components/sidebar/PhaseSidebarTree.logic.test.ts @@ -0,0 +1,455 @@ +// T3-CUSTOM(expbkt3): session tree coverage for the experimental sidebar. +import { + DEFAULT_RUNTIME_MODE, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type ThreadExecutionSnapshot, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { ThreadShell } from "../../types"; +import { + EMPTY_PHASE_SIDEBAR_FILTERS, + type PhaseSidebarFilters, + type PhaseSidebarPhaseId, + type PhaseSidebarRow, +} from "./PhaseGroupedSidebar.logic"; +import { + buildPhaseSidebarTree, + buildPhaseSidebarTreeGroups, + collectPhaseSidebarSubtreeKeys, + flattenPhaseSidebarTree, + phaseSidebarRowKey, + phaseSidebarTreeIndent, + resolvePhaseSidebarTreePhase, + PHASE_SIDEBAR_TREE_MAX_INDENT_DEPTH, +} from "./PhaseSidebarTree.logic"; + +const environmentId = EnvironmentId.make("environment-local"); +const projectId = ProjectId.make("project-1"); +const now = "2026-07-16T10:00:00.000Z"; + +function makeExecution(): ThreadExecutionSnapshot { + return { + threadId: ThreadId.make("thread-x"), + activity: "idle", + intent: null, + updatedAt: now, + } as unknown as ThreadExecutionSnapshot; +} + +function makeThread(id: string, overrides: Partial<ThreadShell> = {}): ThreadShell { + return { + id: ThreadId.make(id), + environmentId, + projectId, + ownerUserId: null, + memberUserIds: [], + title: `Thread ${id}`, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: "default", + branch: null, + worktreePath: null, + sourceControlProfileId: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + execution: makeExecution(), + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + } as ThreadShell; +} + +function makeRow( + id: string, + options: { + readonly parent?: string | null; + readonly phaseId?: PhaseSidebarPhaseId; + readonly repositoryKey?: string; + readonly archived?: boolean; + readonly pendingApproval?: boolean; + } = {}, +): PhaseSidebarRow { + const thread = makeThread(id, { + ...(options.parent !== undefined + ? { parentThreadId: options.parent === null ? null : ThreadId.make(options.parent) } + : {}), + ...(options.archived ? { archivedAt: now } : {}), + ...(options.pendingApproval ? { hasPendingApprovals: true } : {}), + }); + return { + thread, + phaseId: options.phaseId ?? "ready", + repositoryKey: options.repositoryKey ?? "repo-1", + repositoryLabel: options.repositoryKey ?? "repo-one", + providerKind: "codex", + providerName: "Codex", + isAssignedToMe: false, + isOwnedByMe: false, + participantUserIds: [], + attentionPriority: 5, + isUnreadCompletion: false, + settlementSupported: true, + snoozeSupported: true, + prioritySupported: true, + changeRequestState: null, + }; +} + +const byId = (left: PhaseSidebarRow, right: PhaseSidebarRow) => + String(left.thread.id).localeCompare(String(right.thread.id)); + +const key = (id: string) => phaseSidebarRowKey(makeRow(id)); + +describe("buildPhaseSidebarTree", () => { + it("nests a session under the session that spawned it", () => { + const tree = buildPhaseSidebarTree( + [makeRow("parent"), makeRow("child", { parent: "parent" })], + { compareSiblings: byId }, + ); + + expect(tree).toHaveLength(1); + expect(tree[0]?.row.thread.id).toBe("parent"); + expect(tree[0]?.children.map((node) => node.row.thread.id)).toEqual(["child"]); + expect(tree[0]?.descendantCount).toBe(1); + expect(tree[0]?.children[0]?.depth).toBe(1); + }); + + it("counts the whole subtree, not just direct children", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("root"), + makeRow("child-a", { parent: "root" }), + makeRow("child-b", { parent: "root" }), + makeRow("grandchild", { parent: "child-a" }), + ], + { compareSiblings: byId }, + ); + + expect(tree[0]?.descendantCount).toBe(3); + expect(tree[0]?.children[0]?.descendantCount).toBe(1); + expect(tree[0]?.children[0]?.children[0]?.depth).toBe(2); + }); + + it("promotes a row to the top level when its parent is not in this section", () => { + // The parent is settled (a different section) — the child must still render. + const tree = buildPhaseSidebarTree([makeRow("orphan", { parent: "elsewhere" })], { + compareSiblings: byId, + titleForKey: (candidate) => (candidate === key("elsewhere") ? "Settled parent" : null), + }); + + expect(tree).toHaveLength(1); + expect(tree[0]?.depth).toBe(0); + expect(tree[0]?.orphanedFrom).toEqual({ key: key("elsewhere"), title: "Settled parent" }); + }); + + it("leaves no breadcrumb when the parent cannot be named at all", () => { + const tree = buildPhaseSidebarTree([makeRow("orphan", { parent: "deleted" })], { + compareSiblings: byId, + }); + + expect(tree[0]?.orphanedFrom).toBeNull(); + }); + + it("promotes both rows to roots rather than looping on a corrupt cycle", () => { + const tree = buildPhaseSidebarTree( + [makeRow("a", { parent: "b" }), makeRow("b", { parent: "a" })], + { compareSiblings: byId }, + ); + + expect(tree.map((node) => node.row.thread.id).toSorted()).toEqual(["a", "b"]); + expect(tree.every((node) => node.children.length === 0)).toBe(true); + }); + + it("never treats a self-parented row as its own child", () => { + const tree = buildPhaseSidebarTree([makeRow("self", { parent: "self" })], { + compareSiblings: byId, + }); + + expect(tree).toHaveLength(1); + expect(tree[0]?.children).toHaveLength(0); + }); + + it("orders siblings with the same comparator used for roots", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("root"), + makeRow("child-z", { parent: "root" }), + makeRow("child-a", { parent: "root" }), + ], + { compareSiblings: byId }, + ); + + expect(tree[0]?.children.map((node) => node.row.thread.id)).toEqual(["child-a", "child-z"]); + }); +}); + +describe("resolvePhaseSidebarTreePhase", () => { + it("pulls a parent into Implementing while any descendant is working", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("parent", { phaseId: "ready" }), + makeRow("child", { parent: "parent", phaseId: "implementing" }), + ], + { compareSiblings: byId }, + ); + + expect(resolvePhaseSidebarTreePhase(tree[0] as never)).toBe("implementing"); + }); + + it("treats a planning child as working too", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("parent", { phaseId: "ready" }), + makeRow("child", { parent: "parent", phaseId: "planning" }), + ], + { compareSiblings: byId }, + ); + + expect(resolvePhaseSidebarTreePhase(tree[0] as never)).toBe("implementing"); + }); + + it("rolls up through a grandchild", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("root", { phaseId: "ready" }), + makeRow("mid", { parent: "root", phaseId: "ready" }), + makeRow("leaf", { parent: "mid", phaseId: "implementing" }), + ], + { compareSiblings: byId }, + ); + + expect(resolvePhaseSidebarTreePhase(tree[0] as never)).toBe("implementing"); + }); + + it("hoists a parent into Needs Input when a child is waiting on a human", () => { + // Reversal of the original rule. In practice a stuck child two levels down + // under a parent filed as "Implementing" was invisible: nothing surfaced it + // until someone expanded the right row. + const tree = buildPhaseSidebarTree( + [ + makeRow("parent", { phaseId: "implementing" }), + makeRow("child", { parent: "parent", phaseId: "needs_input" }), + ], + { compareSiblings: byId }, + ); + + expect(resolvePhaseSidebarTreePhase(tree[0] as never)).toBe("needs_input"); + expect(tree[0]?.descendantAttention).toBe("input"); + }); + + it("hoists on a pending approval, which never changes a child's own phase", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("parent", { phaseId: "ready" }), + makeRow("child", { parent: "parent", phaseId: "implementing", pendingApproval: true }), + ], + { compareSiblings: byId }, + ); + + expect(resolvePhaseSidebarTreePhase(tree[0] as never)).toBe("needs_input"); + expect(tree[0]?.descendantAttention).toBe("approval"); + }); + + it("reports the most blocking descendant when several are stuck", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("parent", { phaseId: "ready" }), + makeRow("waiting", { parent: "parent", phaseId: "needs_input" }), + makeRow("approving", { parent: "parent", phaseId: "ready", pendingApproval: true }), + ], + { compareSiblings: byId }, + ); + + expect(tree[0]?.descendantAttention).toBe("input"); + }); + + it("rolls attention up through a grandchild", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("root", { phaseId: "ready" }), + makeRow("mid", { parent: "root", phaseId: "ready" }), + makeRow("leaf", { parent: "mid", phaseId: "needs_input" }), + ], + { compareSiblings: byId }, + ); + + expect(resolvePhaseSidebarTreePhase(tree[0] as never)).toBe("needs_input"); + }); + + it("prefers attention over work when the subtree has both", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("parent", { phaseId: "ready" }), + makeRow("busy", { parent: "parent", phaseId: "implementing" }), + makeRow("stuck", { parent: "parent", phaseId: "needs_input" }), + ], + { compareSiblings: byId }, + ); + + expect(resolvePhaseSidebarTreePhase(tree[0] as never)).toBe("needs_input"); + }); + + it("leaves a childless row in its own phase", () => { + const tree = buildPhaseSidebarTree([makeRow("solo", { phaseId: "plan_ready" })], { + compareSiblings: byId, + }); + + expect(resolvePhaseSidebarTreePhase(tree[0] as never)).toBe("plan_ready"); + }); +}); + +describe("flattenPhaseSidebarTree", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("root"), + makeRow("child", { parent: "root" }), + makeRow("grandchild", { parent: "child" }), + makeRow("other"), + ], + { compareSiblings: byId }, + ); + + it("omits collapsed subtrees so keyboard traversal skips hidden rows", () => { + const flattened = flattenPhaseSidebarTree(tree, () => false); + + expect(flattened.map((node) => node.row.thread.id)).toEqual(["other", "root"]); + }); + + it("walks an expanded subtree in render order", () => { + const flattened = flattenPhaseSidebarTree(tree, () => true); + + expect(flattened.map((node) => node.row.thread.id)).toEqual([ + "other", + "root", + "child", + "grandchild", + ]); + }); + + it("stops descending at the first collapsed ancestor", () => { + const flattened = flattenPhaseSidebarTree(tree, (candidate) => candidate === key("root")); + + expect(flattened.map((node) => node.row.thread.id)).toEqual(["other", "root", "child"]); + }); +}); + +describe("buildPhaseSidebarTreeGroups", () => { + const filtersFor = (overrides: Partial<PhaseSidebarFilters>): PhaseSidebarFilters => ({ + ...EMPTY_PHASE_SIDEBAR_FILTERS, + ...overrides, + }); + + it("groups a parent by its rolled-up phase, keeping children nested", () => { + const { groups } = buildPhaseSidebarTreeGroups({ + rows: [ + makeRow("parent", { phaseId: "ready" }), + makeRow("child", { parent: "parent", phaseId: "implementing" }), + ], + filters: EMPTY_PHASE_SIDEBAR_FILTERS, + compareSiblings: byId, + }); + + expect(groups.map((group) => group.id)).toEqual(["implementing"]); + expect(groups[0]?.nodes).toHaveLength(1); + expect(groups[0]?.nodes[0]?.children).toHaveLength(1); + }); + + it("drops archived rows before nesting", () => { + const { groups } = buildPhaseSidebarTreeGroups({ + rows: [makeRow("parent"), makeRow("child", { parent: "parent", archived: true })], + filters: EMPTY_PHASE_SIDEBAR_FILTERS, + compareSiblings: byId, + }); + + expect(groups[0]?.nodes[0]?.descendantCount).toBe(0); + }); + + it("keeps a non-matching parent so a matching child stays reachable", () => { + const { groups, forcedExpansionKeys } = buildPhaseSidebarTreeGroups({ + rows: [ + makeRow("parent", { repositoryKey: "repo-a" }), + makeRow("child", { parent: "parent", repositoryKey: "repo-b" }), + ], + filters: filtersFor({ repositoryKeys: ["repo-b"] }), + compareSiblings: byId, + }); + + const roots = groups.flatMap((group) => group.nodes); + expect(roots.map((node) => node.row.thread.id)).toEqual(["parent"]); + expect(roots[0]?.children.map((node) => node.row.thread.id)).toEqual(["child"]); + // The cross-repo match must not stay hidden inside a collapsed parent. + expect(forcedExpansionKeys.has(key("parent"))).toBe(true); + }); + + it("recomputes the child count over what survives the filter", () => { + const { groups } = buildPhaseSidebarTreeGroups({ + rows: [ + makeRow("parent", { repositoryKey: "repo-a" }), + makeRow("kept", { parent: "parent", repositoryKey: "repo-b" }), + makeRow("dropped", { parent: "parent", repositoryKey: "repo-c" }), + ], + filters: filtersFor({ repositoryKeys: ["repo-b"] }), + compareSiblings: byId, + }); + + expect(groups.flatMap((group) => group.nodes)[0]?.descendantCount).toBe(1); + }); + + it("forces no expansion when no filter is active", () => { + const { forcedExpansionKeys } = buildPhaseSidebarTreeGroups({ + rows: [makeRow("parent"), makeRow("child", { parent: "parent" })], + filters: EMPTY_PHASE_SIDEBAR_FILTERS, + compareSiblings: byId, + }); + + expect(forcedExpansionKeys.size).toBe(0); + }); + + it("omits phases with no roots", () => { + const { groups } = buildPhaseSidebarTreeGroups({ + rows: [makeRow("solo", { phaseId: "ready" })], + filters: EMPTY_PHASE_SIDEBAR_FILTERS, + compareSiblings: byId, + }); + + expect(groups.map((group) => group.id)).toEqual(["ready"]); + }); +}); + +describe("collectPhaseSidebarSubtreeKeys", () => { + it("returns every descendant key and never the root itself", () => { + const tree = buildPhaseSidebarTree( + [ + makeRow("root"), + makeRow("child", { parent: "root" }), + makeRow("grandchild", { parent: "child" }), + ], + { compareSiblings: byId }, + ); + + expect(collectPhaseSidebarSubtreeKeys(tree[0] as never).toSorted()).toEqual( + [key("child"), key("grandchild")].toSorted(), + ); + }); +}); + +describe("phaseSidebarTreeIndent", () => { + it("grows with depth and then stops so deep chains keep their titles", () => { + expect(phaseSidebarTreeIndent(0)).toBe(0); + expect(phaseSidebarTreeIndent(1)).toBeGreaterThan(phaseSidebarTreeIndent(0)); + expect(phaseSidebarTreeIndent(9)).toBe( + phaseSidebarTreeIndent(PHASE_SIDEBAR_TREE_MAX_INDENT_DEPTH), + ); + }); +}); diff --git a/apps/web/src/components/sidebar/PhaseSidebarTree.logic.ts b/apps/web/src/components/sidebar/PhaseSidebarTree.logic.ts new file mode 100644 index 00000000000..396159b0397 --- /dev/null +++ b/apps/web/src/components/sidebar/PhaseSidebarTree.logic.ts @@ -0,0 +1,403 @@ +// T3-CUSTOM(expbkt3): session trees for the experimental sidebar. +// +// A session that fans work out — typically cross-repo, via the t3_create_session +// MCP tool — records the session that spawned it. This module turns that flat +// `parentThreadId` link into the nested rows the sidebar renders, and decides +// which lifecycle group a parent belongs in once its children are folded into it. +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; + +import { + matchesPhaseSidebarFilters, + resolvePhaseSidebarAttentionKind, + PHASE_SIDEBAR_PHASES, + type PhaseSidebarAttentionKind, + type PhaseSidebarFilters, + type PhaseSidebarPhaseDefinition, + type PhaseSidebarPhaseId, + type PhaseSidebarRow, +} from "./PhaseGroupedSidebar.logic"; + +/** + * Indentation stops growing past this depth. Deep chains still nest logically — + * traversal, counts and the phase override all keep working — but the sidebar is + * ~260px wide, so past three levels the indent costs more title than it buys in + * legibility. + */ +export const PHASE_SIDEBAR_TREE_MAX_INDENT_DEPTH = 3; + +/** + * Backstop for a projection that already contains a cycle. The server rejects + * commands that would create one, but a client must never hang on bad data. + */ +export const PHASE_SIDEBAR_TREE_MAX_DEPTH = 16; + +/** + * A descendant counts as "busy" when its own phase says an agent is actively + * working. This is the single input to the parent's phase override. + */ +const BUSY_PHASE_IDS: ReadonlySet<PhaseSidebarPhaseId> = new Set<PhaseSidebarPhaseId>([ + "planning", + "implementing", +]); + +/** + * Most-blocking first. A subtree can hold several stuck sessions at once, and + * the parent has room for exactly one derived badge, so it reports the worst. + */ +const ATTENTION_RANK: ReadonlyArray<PhaseSidebarAttentionKind> = ["input", "approval", "error"]; + +/** + * A descendant needs a human when it is parked in the Needs Input phase or is + * flying an attention badge of its own — a pending approval does not change a + * session's phase, so both signals matter. + */ +function attentionKindOf(row: PhaseSidebarRow): PhaseSidebarAttentionKind | null { + const kind = resolvePhaseSidebarAttentionKind(row.thread); + if (kind !== null) return kind; + return row.phaseId === "needs_input" ? "input" : null; +} + +function moreUrgent( + left: PhaseSidebarAttentionKind | null, + right: PhaseSidebarAttentionKind | null, +): PhaseSidebarAttentionKind | null { + if (left === null) return right; + if (right === null) return left; + return ATTENTION_RANK.indexOf(left) <= ATTENTION_RANK.indexOf(right) ? left : right; +} + +export interface PhaseSidebarTreeNode { + readonly row: PhaseSidebarRow; + readonly key: string; + readonly children: ReadonlyArray<PhaseSidebarTreeNode>; + /** 0 for a root row; used for indentation and for the aria tree semantics. */ + readonly depth: number; + /** Every descendant, not just direct children — this is the count the pill shows. */ + readonly descendantCount: number; + /** True when any descendant is planning or implementing (see BUSY_PHASE_IDS). */ + readonly hasBusyDescendant: boolean; + /** + * The most blocking thing any descendant is waiting on, or null. Drives both + * the parent's group placement and its derived badge: work buried in a + * collapsed subtree is invisible, so the parent has to raise its hand. + */ + readonly descendantAttention: PhaseSidebarAttentionKind | null; + /** + * Set only on a row whose recorded parent is not rendering in this section — + * archived, settled, filtered out, in another environment, or deleted. The row + * renders at the top level with this breadcrumb instead of silently losing its + * lineage. + */ + readonly orphanedFrom: { readonly key: string; readonly title: string } | null; +} + +export function phaseSidebarRowKey(row: PhaseSidebarRow): string { + return scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)); +} + +/** + * The parent link is a bare thread id: a session can only be created by a caller + * on the same server, so parent and child always share an environment. Scoping + * the lookup by the child's environment is therefore both correct and the only + * way to avoid colliding ids across connected environments. + */ +function parentKeyOf(row: PhaseSidebarRow): string | null { + const parentThreadId = row.thread.parentThreadId; + if (parentThreadId == null) return null; + return scopedThreadKey(scopeThreadRef(row.thread.environmentId, parentThreadId)); +} + +interface MutableNode { + readonly row: PhaseSidebarRow; + readonly key: string; + readonly children: MutableNode[]; + depth: number; + descendantCount: number; + hasBusyDescendant: boolean; + descendantAttention: PhaseSidebarAttentionKind | null; + orphanedFrom: { readonly key: string; readonly title: string } | null; +} + +function isBusy(row: PhaseSidebarRow): boolean { + return BUSY_PHASE_IDS.has(row.phaseId); +} + +/** + * Resolve the row's effective parent, or null when it should render as a root. + * + * A row nests only if its parent is present in the SAME row set. That one rule + * absorbs every edge case — parent archived, settled, snoozed, filtered out, + * deleted, or in another environment — without special-casing any of them, and + * guarantees the result is a forest rooted in rows that actually render. + */ +function resolveParent( + node: MutableNode, + byKey: ReadonlyMap<string, MutableNode>, +): MutableNode | null { + const parentKey = parentKeyOf(node.row); + if (parentKey === null) return null; + const parent = byKey.get(parentKey); + if (parent === undefined || parent.key === node.key) return null; + + // Walk to the root before accepting the link. A cycle here means the stored + // data is already corrupt; promoting the row to a root keeps the sidebar + // usable instead of dropping the row or looping forever. + const seen = new Set<string>([node.key]); + let cursor: MutableNode | undefined = parent; + for (let depth = 0; cursor !== undefined && depth < PHASE_SIDEBAR_TREE_MAX_DEPTH; depth += 1) { + if (seen.has(cursor.key)) return null; + seen.add(cursor.key); + const nextKey = parentKeyOf(cursor.row); + cursor = nextKey === null ? undefined : byKey.get(nextKey); + } + return cursor === undefined ? parent : null; +} + +/** + * Bottom-up rollup of the two derived facts a parent row renders: how many + * sessions live under it, and whether any of them is doing work. + */ +function finalize(node: MutableNode, depth: number): void { + node.depth = depth; + let descendantCount = 0; + let hasBusyDescendant = false; + let descendantAttention: PhaseSidebarAttentionKind | null = null; + for (const child of node.children) { + finalize(child, depth + 1); + descendantCount += 1 + child.descendantCount; + hasBusyDescendant = hasBusyDescendant || isBusy(child.row) || child.hasBusyDescendant; + descendantAttention = moreUrgent( + descendantAttention, + moreUrgent(attentionKindOf(child.row), child.descendantAttention), + ); + } + node.descendantCount = descendantCount; + node.hasBusyDescendant = hasBusyDescendant; + node.descendantAttention = descendantAttention; +} + +function freeze(node: MutableNode): PhaseSidebarTreeNode { + return { + row: node.row, + key: node.key, + children: node.children.map(freeze), + depth: node.depth, + descendantCount: node.descendantCount, + hasBusyDescendant: node.hasBusyDescendant, + descendantAttention: node.descendantAttention, + orphanedFrom: node.orphanedFrom, + }; +} + +/** + * Build the forest for one sidebar section (active / snoozed / settled). + * + * `compareSiblings` orders both the returned roots and every child list, so a + * subtree reads with the same ordering rules as the list it sits in. + * `titleForKey` resolves orphan breadcrumbs against the full thread set, not + * just this section, so "↳ Parent title" still names a settled or filtered + * parent. + */ +export function buildPhaseSidebarTree( + rows: ReadonlyArray<PhaseSidebarRow>, + options: { + readonly compareSiblings: (left: PhaseSidebarRow, right: PhaseSidebarRow) => number; + readonly titleForKey?: (key: string) => string | null; + }, +): ReadonlyArray<PhaseSidebarTreeNode> { + const nodes: MutableNode[] = rows.map((row) => ({ + row, + key: phaseSidebarRowKey(row), + children: [], + depth: 0, + descendantCount: 0, + hasBusyDescendant: false, + descendantAttention: null, + orphanedFrom: null, + })); + const byKey = new Map(nodes.map((node) => [node.key, node])); + + const roots: MutableNode[] = []; + for (const node of nodes) { + const parent = resolveParent(node, byKey); + if (parent === null) { + const parentKey = parentKeyOf(node.row); + if (parentKey !== null) { + const title = options.titleForKey?.(parentKey) ?? null; + if (title !== null) node.orphanedFrom = { key: parentKey, title }; + } + roots.push(node); + continue; + } + parent.children.push(node); + } + + const sortRecursively = (list: MutableNode[]): void => { + list.sort((left, right) => options.compareSiblings(left.row, right.row)); + for (const node of list) sortRecursively(node.children); + }; + sortRecursively(roots); + for (const root of roots) finalize(root, 0); + + return roots.map(freeze); +} + +/** + * The phase a ROOT row is grouped under. + * + * Precedence, most urgent first: + * + * 1. Anything in the subtree is waiting on a human → Needs Input + * 2. Anything in the subtree is doing work → Implementing + * 3. Otherwise → the row's own phase + * + * Attention outranks work because a collapsed subtree hides it completely: an + * approval sitting two levels down under a parent filed as "Implementing" is + * invisible until someone happens to expand the right row. Hoisting the parent + * costs one row of churn and is the whole reason the Needs Input group is worth + * scanning first. + */ +export function resolvePhaseSidebarTreePhase(node: PhaseSidebarTreeNode): PhaseSidebarPhaseId { + if (node.descendantAttention !== null) return "needs_input"; + return node.hasBusyDescendant ? "implementing" : node.row.phaseId; +} + +export function flattenPhaseSidebarTree( + nodes: ReadonlyArray<PhaseSidebarTreeNode>, + isExpanded: (key: string) => boolean, +): ReadonlyArray<PhaseSidebarTreeNode> { + const flattened: PhaseSidebarTreeNode[] = []; + const visit = (node: PhaseSidebarTreeNode): void => { + flattened.push(node); + if (node.children.length === 0 || !isExpanded(node.key)) return; + for (const child of node.children) visit(child); + }; + for (const node of nodes) visit(node); + return flattened; +} + +/** Every key in a subtree except its root — backs "Expand/Collapse all children". */ +export function collectPhaseSidebarSubtreeKeys(node: PhaseSidebarTreeNode): ReadonlyArray<string> { + const keys: string[] = []; + const visit = (current: PhaseSidebarTreeNode): void => { + for (const child of current.children) { + keys.push(child.key); + visit(child); + } + }; + visit(node); + return keys; +} + +/** + * Keys of parents that must be force-expanded because a filter matched + * something inside them. Without this, filtering by repository would silently + * hide matches nested under a collapsed parent from another repository — the + * exact cross-repo case this feature exists to make visible. + */ +export function resolveForcedExpansionKeys( + nodes: ReadonlyArray<PhaseSidebarTreeNode>, + matches: (row: PhaseSidebarRow) => boolean, +): ReadonlySet<string> { + const forced = new Set<string>(); + const visit = (node: PhaseSidebarTreeNode): boolean => { + let descendantMatched = false; + for (const child of node.children) { + descendantMatched = visit(child) || descendantMatched; + } + if (descendantMatched) forced.add(node.key); + return descendantMatched || matches(node.row); + }; + for (const node of nodes) visit(node); + return forced; +} + +/** Indentation in px for a nested row, capped so deep chains stay readable. */ +export function phaseSidebarTreeIndent(depth: number): number { + return Math.min(depth, PHASE_SIDEBAR_TREE_MAX_INDENT_DEPTH) * 14; +} + +export function phaseSidebarFiltersActive(filters: PhaseSidebarFilters): boolean { + return ( + filters.repositoryKeys.length > 0 || + filters.phaseIds.length > 0 || + filters.providerKinds.length > 0 || + // T3-CUSTOM(expbkt3): ownership and co-participant facets. + filters.participantUserIds.length > 0 || + filters.ownedByMe + ); +} + +export interface PhaseSidebarTreeGroup extends PhaseSidebarPhaseDefinition { + readonly nodes: ReadonlyArray<PhaseSidebarTreeNode>; +} + +export interface PhaseSidebarTreeGroupsResult { + readonly groups: ReadonlyArray<PhaseSidebarTreeGroup>; + /** + * Parents the user did not open but that must render open anyway, because a + * filter matched something inside them. Transient — never written to the + * expansion store, so clearing the filter restores the user's own state. + */ + readonly forcedExpansionKeys: ReadonlySet<string>; +} + +/** + * The full pipeline for one section: filter, nest, then group the roots. + * + * Filtering runs against the tree rather than the flat row list so a match is + * never hidden inside a collapsed parent that does not itself match. A row + * survives when it matches, or when anything in its subtree matches (its + * ancestors are carried along to keep the path renderable). + */ +export function buildPhaseSidebarTreeGroups(input: { + readonly rows: ReadonlyArray<PhaseSidebarRow>; + readonly filters: PhaseSidebarFilters; + readonly compareSiblings: (left: PhaseSidebarRow, right: PhaseSidebarRow) => number; + readonly titleForKey?: (key: string) => string | null; +}): PhaseSidebarTreeGroupsResult { + const candidates = input.rows.filter((row) => row.thread.archivedAt === null); + const matches = (row: PhaseSidebarRow) => matchesPhaseSidebarFilters(row, input.filters); + const filtersActive = phaseSidebarFiltersActive(input.filters); + + let survivingRows = candidates; + if (filtersActive) { + // Nest against the UNFILTERED set first, so ancestry is true lineage rather + // than an artefact of what the filter happened to leave behind. A row is + // kept when it matches; its ancestors come along to keep the path to it + // renderable. + const keep = new Set<string>(); + const visit = (node: PhaseSidebarTreeNode, ancestorKeys: ReadonlyArray<string>): void => { + if (matches(node.row)) { + keep.add(node.key); + for (const ancestorKey of ancestorKeys) keep.add(ancestorKey); + } + const nextAncestors = [...ancestorKeys, node.key]; + for (const child of node.children) visit(child, nextAncestors); + }; + for (const node of buildPhaseSidebarTree(candidates, { + compareSiblings: input.compareSiblings, + })) { + visit(node, []); + } + survivingRows = candidates.filter((row) => keep.has(phaseSidebarRowKey(row))); + } + + // Descendant counts and the busy rollup describe what actually renders. + const tree = buildPhaseSidebarTree(survivingRows, { + compareSiblings: input.compareSiblings, + ...(input.titleForKey ? { titleForKey: input.titleForKey } : {}), + }); + + const groups = PHASE_SIDEBAR_PHASES.flatMap((phase) => { + const nodes = tree.filter((node) => resolvePhaseSidebarTreePhase(node) === phase.id); + return nodes.length > 0 ? [{ ...phase, nodes }] : []; + }); + + return { + groups, + forcedExpansionKeys: filtersActive + ? resolveForcedExpansionKeys(tree, matches) + : new Set<string>(), + }; +} diff --git a/apps/web/src/fork/planReviewSurface.tsx b/apps/web/src/fork/planReviewSurface.tsx new file mode 100644 index 00000000000..99421dff467 --- /dev/null +++ b/apps/web/src/fork/planReviewSurface.tsx @@ -0,0 +1,37 @@ +/** + * T3-CUSTOM(expbkt3): single entry point for the native plan review surface. + * + * Everything upstream files need lives here, so `ChatView`, `RightPanelTabs` + * and `ProposedPlanCard` each take one import and one line rather than growing + * plan-review logic inline. That keeps the next upstream merge cheap. + */ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { lazy } from "react"; + +import { useClientSettings } from "../hooks/useSettings"; +import { planReviewEnvironment } from "../state/planReview"; +import { useEnvironmentQuery } from "../state/query"; + +/** Plate is ~200 kB gzip — it must only load when a review is actually opened. */ +export const PlanReviewPanel = lazy(() => import("../components/planreview/PlanReviewPanel")); + +/** + * The thread's reviewable plan, or null when there is none. + * + * At most one plan document per thread is `open` at a time: an agent revision + * appends a version to the existing lineage rather than starting a new one, so + * this resolves to a single id without ambiguity. + */ +export function useOpenPlanReviewDocumentId( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): string | null { + const enabled = useClientSettings((settings) => settings.nativePlanReviewEnabled); + const { data } = useEnvironmentQuery( + !enabled || environmentId === null || threadId === null + ? null + : planReviewEnvironment.list({ environmentId, input: { threadId } }), + ); + + return data?.documents.find((document) => document.status === "open")?.documentId ?? null; +} diff --git a/apps/web/src/phaseSidebarFilterStore.ts b/apps/web/src/phaseSidebarFilterStore.ts index 2fe55059f50..a05285b2926 100644 --- a/apps/web/src/phaseSidebarFilterStore.ts +++ b/apps/web/src/phaseSidebarFilterStore.ts @@ -25,12 +25,15 @@ interface PhaseSidebarFilterStoreState extends PhaseSidebarFilters { toggleRepository: (repositoryKey: string) => void; togglePhase: (phaseId: PhaseSidebarPhaseId) => void; toggleProvider: (providerKind: string) => void; - toggleAssignedToMe: () => void; + // T3-CUSTOM(expbkt3): ownership and co-participant facets. + toggleOwnedByMe: () => void; + toggleParticipant: (userId: string) => void; clearAll: () => void; reconcile: (options: { readonly repositoryKeys: ReadonlySet<string>; readonly providerKinds: ReadonlySet<string>; readonly assignmentAvailable: boolean; + readonly participantUserIds?: ReadonlySet<string>; }) => void; } @@ -54,7 +57,9 @@ export const usePhaseSidebarFilterStore = create<PhaseSidebarFilterStoreState>() set((state) => ({ phaseIds: toggleValue(state.phaseIds, phaseId) })), toggleProvider: (providerKind) => set((state) => ({ providerKinds: toggleValue(state.providerKinds, providerKind) })), - toggleAssignedToMe: () => set((state) => ({ assignedToMe: !state.assignedToMe })), + toggleOwnedByMe: () => set((state) => ({ ownedByMe: !state.ownedByMe })), + toggleParticipant: (userId) => + set((state) => ({ participantUserIds: toggleValue(state.participantUserIds, userId) })), clearAll: () => set(EMPTY_PHASE_SIDEBAR_FILTERS), reconcile: (options) => set((state) => reconcilePhaseSidebarFilters(state, options)), }), @@ -68,7 +73,8 @@ export const usePhaseSidebarFilterStore = create<PhaseSidebarFilterStoreState>() repositoryKeys: state.repositoryKeys, phaseIds: state.phaseIds, providerKinds: state.providerKinds, - assignedToMe: state.assignedToMe, + ownedByMe: state.ownedByMe, + participantUserIds: state.participantUserIds, sort: state.sort, }), merge: (persisted, current) => ({ diff --git a/apps/web/src/phaseSidebarTreeStore.test.ts b/apps/web/src/phaseSidebarTreeStore.test.ts new file mode 100644 index 00000000000..a05aafa91de --- /dev/null +++ b/apps/web/src/phaseSidebarTreeStore.test.ts @@ -0,0 +1,59 @@ +// T3-CUSTOM(expbkt3): session-tree expansion store coverage. +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { usePhaseSidebarTreeStore } from "./phaseSidebarTreeStore"; + +const reset = () => usePhaseSidebarTreeStore.setState({ expandedKeys: [] }); +const state = () => usePhaseSidebarTreeStore.getState(); + +describe("phaseSidebarTreeStore", () => { + beforeEach(reset); + + it("starts with every subtree collapsed", () => { + expect(state().expandedKeys).toEqual([]); + }); + + it("toggles a key open and closed again", () => { + state().toggle("env:thread-1"); + expect(state().expandedKeys).toEqual(["env:thread-1"]); + + state().toggle("env:thread-1"); + expect(state().expandedKeys).toEqual([]); + }); + + it("expands a whole subtree in one write", () => { + state().setExpanded(["a", "b", "c"], true); + + expect(state().expandedKeys.toSorted()).toEqual(["a", "b", "c"]); + }); + + it("collapses a whole subtree without disturbing unrelated keys", () => { + state().setExpanded(["a", "b", "keep"], true); + state().setExpanded(["a", "b"], false); + + expect(state().expandedKeys).toEqual(["keep"]); + }); + + it("never records a key twice", () => { + state().setExpanded("a", true); + state().setExpanded(["a", "b"], true); + + expect(state().expandedKeys.toSorted()).toEqual(["a", "b"]); + }); + + it("keeps the same state object when nothing changes", () => { + state().setExpanded("a", true); + const before = state().expandedKeys; + state().setExpanded("a", true); + + // Referential stability matters: the sidebar memoizes on this array. + expect(state().expandedKeys).toBe(before); + }); + + it("ignores an empty update", () => { + const before = state().expandedKeys; + state().setExpanded([], true); + + expect(state().expandedKeys).toBe(before); + }); +}); diff --git a/apps/web/src/phaseSidebarTreeStore.ts b/apps/web/src/phaseSidebarTreeStore.ts new file mode 100644 index 00000000000..78401a5be5c --- /dev/null +++ b/apps/web/src/phaseSidebarTreeStore.ts @@ -0,0 +1,74 @@ +// T3-CUSTOM(expbkt3): which session subtrees are open in the experimental +// sidebar. A dedicated fork-owned store rather than a field on uiStateStore: +// the expansion set is exp-sidebar-only state, and keeping it here means the +// feature adds no hunks to an upstream-owned file. +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { resolveStorage } from "./lib/storage"; + +export const PHASE_SIDEBAR_TREE_STORAGE_KEY = "t3code:phase-sidebar-tree:v1"; + +interface PhaseSidebarTreeStoreState { + /** + * Expanded keys only. Children are hidden by default — a parent row already + * reports its subtree through the count pill and the running rollup, so the + * default view stays one row per unit of work. + */ + readonly expandedKeys: ReadonlyArray<string>; + toggle: (key: string) => void; + setExpanded: (keys: string | ReadonlyArray<string>, expanded: boolean) => void; +} + +function sanitizeKeys(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [ + ...new Set( + value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0), + ), + ]; +} + +export const usePhaseSidebarTreeStore = create<PhaseSidebarTreeStoreState>()( + persist( + (set) => ({ + expandedKeys: [], + toggle: (key) => + set((state) => ({ + expandedKeys: state.expandedKeys.includes(key) + ? state.expandedKeys.filter((candidate) => candidate !== key) + : [...state.expandedKeys, key], + })), + setExpanded: (keys, expanded) => + set((state) => { + const target = typeof keys === "string" ? [keys] : keys; + if (target.length === 0) return state; + if (!expanded) { + const removed = new Set(target); + const next = state.expandedKeys.filter((candidate) => !removed.has(candidate)); + return next.length === state.expandedKeys.length ? state : { expandedKeys: next }; + } + const additions = target.filter((key) => !state.expandedKeys.includes(key)); + return additions.length === 0 + ? state + : { expandedKeys: [...state.expandedKeys, ...additions] }; + }), + }), + { + name: PHASE_SIDEBAR_TREE_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ expandedKeys: state.expandedKeys }), + merge: (persisted, current) => ({ + ...current, + expandedKeys: sanitizeKeys( + persisted && typeof persisted === "object" + ? (persisted as { readonly expandedKeys?: unknown }).expandedKeys + : undefined, + ), + }), + }, + ), +); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 4547755bdab..13c57e3535b 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -21,6 +21,8 @@ import { withoutPersistedPlannotatorSurfaces } from "./plannotatorRightPanelPers export const RIGHT_PANEL_KINDS = [ "plannotator", + // T3-CUSTOM(expbkt3): native plan review surface. + "planReview", "diff", "files", "file", @@ -55,12 +57,19 @@ export type RightPanelSurface = kind: "plannotator"; url: `/plannotator/${string}/`; } + // T3-CUSTOM(expbkt3): native plan review surface. + | { + id: `plan-review:${string}`; + kind: "planReview"; + documentId: string; + } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v9 removed the "plan" surface kind (plans render inline in the transcript). // T3-CUSTOM(expbkt3): v9 also drops legacy persisted Plannotator surfaces. -const RIGHT_PANEL_STORAGE_VERSION = 9; +// T3-CUSTOM(expbkt3): v10 validates persisted native plan-review surfaces. +const RIGHT_PANEL_STORAGE_VERSION = 10; export interface ThreadRightPanelState { isOpen: boolean; @@ -72,10 +81,12 @@ interface RightPanelStoreState { byThreadKey: Record<string, ThreadRightPanelState>; open: ( ref: ScopedThreadRef, - kind: Exclude<RightPanelKind, "file" | "terminal" | "plannotator">, + kind: Exclude<RightPanelKind, "file" | "terminal" | "plannotator" | "planReview">, ) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openPlannotator: (ref: ScopedThreadRef, url: `/plannotator/${string}/`) => void; + // T3-CUSTOM(expbkt3): native plan review. + openPlanReview: (ref: ScopedThreadRef, documentId: string) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( @@ -98,7 +109,7 @@ interface RightPanelStoreState { toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, - kind: Exclude<RightPanelKind, "file" | "terminal" | "plannotator">, + kind: Exclude<RightPanelKind, "file" | "terminal" | "plannotator" | "planReview">, ) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -110,7 +121,8 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = { }; const singletonSurface = ( - kind: Exclude<RightPanelKind, "file" | "preview" | "terminal" | "plannotator">, + // T3-CUSTOM(expbkt3): planReview carries a document id, so it is never a singleton. + kind: Exclude<RightPanelKind, "file" | "preview" | "terminal" | "plannotator" | "planReview">, ): RightPanelSurface => { switch (kind) { case "diff": @@ -123,6 +135,13 @@ const singletonSurface = ( }; // T3-CUSTOM(expbkt3): BEGIN — construct the persisted Plannotator surface descriptor. +// T3-CUSTOM(expbkt3): native plan review surface descriptor. +const planReviewSurface = (documentId: string): RightPanelSurface => ({ + id: `plan-review:${documentId}`, + kind: "planReview", + documentId, +}); + const plannotatorSurface = (url: `/plannotator/${string}/`): RightPanelSurface => ({ id: `plannotator:${url}`, kind: "plannotator", @@ -222,6 +241,19 @@ export function migratePersistedRightPanelState(persistedState: unknown): { : 0; return [{ ...surface, revealLine, revealRequestId }]; } + // T3-CUSTOM(expbkt3): a plan-review surface is only valid + // when its id still matches its document (v10). + if ((surface as { kind?: string }).kind === "planReview") { + const documentId = (surface as { documentId?: unknown }).documentId; + if ( + typeof documentId !== "string" || + documentId.length === 0 || + surface.id !== `plan-review:${documentId}` + ) { + return []; + } + return [surface]; + } if (surface.kind === "plannotator") { if ( typeof surface.url !== "string" || @@ -323,6 +355,12 @@ export const useRightPanelStore = create<RightPanelStoreState>()( upsertSurface(current, plannotatorSurface(url)), ), })), + openPlanReview: (ref, documentId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + upsertSurface(current, planReviewSurface(documentId)), + ), + })), // T3-CUSTOM(expbkt3): END openFile: (ref, relativePath, line) => set((state) => ({ diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 8dd37f73209..44cc99890b7 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' +import { Route as SessionsRouteImport } from './routes/sessions' import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' @@ -37,6 +38,11 @@ const SettingsRoute = SettingsRouteImport.update({ path: '/settings', getParentRoute: () => rootRouteImport, } as any) +const SessionsRoute = SessionsRouteImport.update({ + id: '/sessions', + path: '/sessions', + getParentRoute: () => rootRouteImport, +} as any) const PairRoute = PairRouteImport.update({ id: '/pair', path: '/pair', @@ -147,6 +153,7 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/sessions': typeof SessionsRoute '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute @@ -169,6 +176,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/sessions': typeof SessionsRoute '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute @@ -194,6 +202,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/sessions': typeof SessionsRoute '/settings': typeof SettingsRouteWithChildren '/connect_/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute @@ -220,6 +229,7 @@ export interface FileRouteTypes { | '/' | '/connect' | '/pair' + | '/sessions' | '/settings' | '/connect/callback' | '/settings/appearance' @@ -242,6 +252,7 @@ export interface FileRouteTypes { to: | '/connect' | '/pair' + | '/sessions' | '/settings' | '/connect/callback' | '/settings/appearance' @@ -266,6 +277,7 @@ export interface FileRouteTypes { | '/_chat' | '/connect' | '/pair' + | '/sessions' | '/settings' | '/connect_/callback' | '/settings/appearance' @@ -291,6 +303,7 @@ export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute + SessionsRoute: typeof SessionsRoute SettingsRoute: typeof SettingsRouteWithChildren ConnectCallbackRoute: typeof ConnectCallbackRoute } @@ -304,6 +317,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/sessions': { + id: '/sessions' + path: '/sessions' + fullPath: '/sessions' + preLoaderRoute: typeof SessionsRouteImport + parentRoute: typeof rootRouteImport + } '/pair': { id: '/pair' path: '/pair' @@ -510,6 +530,7 @@ const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, ConnectRoute: ConnectRoute, PairRoute: PairRoute, + SessionsRoute: SessionsRoute, SettingsRoute: SettingsRouteWithChildren, ConnectCallbackRoute: ConnectCallbackRoute, } diff --git a/apps/web/src/routes/sessions.tsx b/apps/web/src/routes/sessions.tsx new file mode 100644 index 00000000000..9e6ca1a01f2 --- /dev/null +++ b/apps/web/src/routes/sessions.tsx @@ -0,0 +1,102 @@ +/** + * T3-CUSTOM(expbkt3): Route shell for the bulk session manager. + * + * Structurally a copy of `routes/settings.tsx` — same auth `beforeLoad`, same + * `SidebarInset` + workspace-topbar chrome (with the Electron drag-region + * variant), same Escape-to-go-back. Keeping the two shells identical is + * deliberate: they are the app's only two full-screen non-chat surfaces, and a + * divergent titlebar inset here would be visible the moment the sidebar + * collapses. + */ +import { createFileRoute, redirect, useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect } from "react"; + +import { SessionManagerPage } from "../components/sessionManager/SessionManagerPage"; +import { SidebarInset } from "../components/ui/sidebar"; +import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; + +function SessionsRouteLayout() { + const navigate = useNavigate(); + const canGoBack = useCanGoBack(); + const navigateBackWithinApp = useCallback(() => { + if (canGoBack) { + window.history.back(); + return; + } + void navigate({ to: "/" }); + }, [canGoBack, navigate]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + // The page itself consumes Escape while rows are selected (it calls + // preventDefault), so leaving the page is only ever the *second* + // Escape — a stray keypress can never discard a large selection AND + // navigate away in one go. + if (event.defaultPrevented) return; + if (event.key !== "Escape") return; + event.preventDefault(); + + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) { + activeElement.blur(); + } + + navigateBackWithinApp(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [navigateBackWithinApp]); + + return ( + <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> + <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> + {!isElectron && ( + <header + className={cn( + "workspace-topbar px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", + COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, + )} + > + <div className="flex w-full items-center gap-2"> + <span className="text-sm font-medium text-foreground">Manage sessions</span> + </div> + </header> + )} + + {isElectron && ( + <div + className={cn( + "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", + COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, + )} + > + <span className="text-xs font-medium tracking-wide text-muted-foreground/70"> + Manage sessions + </span> + </div> + )} + + <div className="min-h-0 flex flex-1 flex-col"> + <SessionManagerPage /> + </div> + </div> + </SidebarInset> + ); +} + +export const Route = createFileRoute("/sessions")({ + beforeLoad: async ({ context }) => { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: SessionsRouteLayout, +}); diff --git a/apps/web/src/sessionManagerFilterStore.ts b/apps/web/src/sessionManagerFilterStore.ts new file mode 100644 index 00000000000..afe9f46b6d7 --- /dev/null +++ b/apps/web/src/sessionManagerFilterStore.ts @@ -0,0 +1,160 @@ +/** + * T3-CUSTOM(expbkt3): Persisted filter + sort state for the bulk session + * manager page. + * + * Modelled on `phaseSidebarFilterStore` (persist + sanitize/reconcile) but kept + * separate on purpose: the manager and the sidebar answer different questions, + * and sharing one blob would make a table filter silently hide sidebar rows. + */ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import type { + PhaseSidebarAttentionKind, + PhaseSidebarPhaseId, +} from "./components/sidebar/PhaseGroupedSidebar.logic"; +import { + DEFAULT_SESSION_MANAGER_FILTERS, + DEFAULT_SESSION_MANAGER_SORT, + nextSessionManagerSort, + reconcileSessionManagerFilters, + sanitizeSessionManagerFilters, + sanitizeSessionManagerSort, + type SessionManagerFilters, + type SessionManagerLifecycle, + type SessionManagerSort, + type SessionManagerSortColumn, +} from "./components/sessionManager/SessionManagerPage.logic"; +import { resolveStorage } from "./lib/storage"; + +export const SESSION_MANAGER_FILTER_STORAGE_KEY = "t3code:session-manager-filters:v1"; + +interface SessionManagerFilterStoreState extends SessionManagerFilters { + readonly sort: SessionManagerSort; + /** Which saved view is highlighted, or null when the filters are hand-set. */ + readonly activeViewId: string | null; + setSearch: (search: string) => void; + toggleRepository: (repositoryKey: string) => void; + togglePhase: (phaseId: PhaseSidebarPhaseId) => void; + toggleProvider: (providerKind: string) => void; + togglePriority: (priorityRank: number) => void; + toggleAttention: (kind: PhaseSidebarAttentionKind) => void; + toggleOwner: (ownerUserId: string) => void; + toggleLifecycle: (lifecycle: SessionManagerLifecycle) => void; + setStaleDays: (days: number | null) => void; + setFacet: <K extends keyof SessionManagerFilters>( + facet: K, + value: SessionManagerFilters[K], + ) => void; + cycleSort: (column: SessionManagerSortColumn) => void; + setSort: (sort: SessionManagerSort) => void; + applyView: (viewId: string, filters: SessionManagerFilters, sort?: SessionManagerSort) => void; + clearAll: () => void; + reconcile: (options: { + readonly repositoryKeys: ReadonlySet<string>; + readonly providerKinds: ReadonlySet<string>; + readonly ownerUserIds: ReadonlySet<string>; + }) => void; +} + +function toggleValue<T>(values: ReadonlyArray<T>, value: T): T[] { + return values.includes(value) + ? values.filter((candidate) => candidate !== value) + : [...values, value]; +} + +export const useSessionManagerFilterStore = create<SessionManagerFilterStoreState>()( + persist( + (set) => ({ + ...DEFAULT_SESSION_MANAGER_FILTERS, + sort: DEFAULT_SESSION_MANAGER_SORT, + activeViewId: null, + + setSearch: (search) => set({ search, activeViewId: null }), + toggleRepository: (repositoryKey) => + set((state) => ({ + repositoryKeys: toggleValue(state.repositoryKeys, repositoryKey), + activeViewId: null, + })), + togglePhase: (phaseId) => + set((state) => ({ phaseIds: toggleValue(state.phaseIds, phaseId), activeViewId: null })), + toggleProvider: (providerKind) => + set((state) => ({ + providerKinds: toggleValue(state.providerKinds, providerKind), + activeViewId: null, + })), + togglePriority: (priorityRank) => + set((state) => ({ + priorities: toggleValue(state.priorities, priorityRank), + activeViewId: null, + })), + toggleAttention: (kind) => + set((state) => ({ + attentionKinds: toggleValue(state.attentionKinds, kind), + activeViewId: null, + })), + toggleOwner: (ownerUserId) => + set((state) => ({ + ownerUserIds: toggleValue(state.ownerUserIds, ownerUserId), + activeViewId: null, + })), + toggleLifecycle: (lifecycle) => + set((state) => { + const next = toggleValue(state.lifecycles, lifecycle); + // Never let the user switch every lifecycle off — the table would go + // permanently empty with no obvious cause. + return { + lifecycles: next.length > 0 ? next : state.lifecycles, + activeViewId: null, + }; + }), + setStaleDays: (staleDays) => set({ staleDays, activeViewId: null }), + setFacet: (facet, value) => set({ [facet]: value, activeViewId: null } as never), + cycleSort: (column) => set((state) => ({ sort: nextSessionManagerSort(state.sort, column) })), + setSort: (sort) => set({ sort }), + applyView: (viewId, filters, sort) => + set((state) => ({ + ...filters, + sort: sort ?? state.sort, + activeViewId: viewId, + })), + clearAll: () => set({ ...DEFAULT_SESSION_MANAGER_FILTERS, activeViewId: null }), + reconcile: (options) => + set((state) => { + const reconciled = reconcileSessionManagerFilters(state, options); + return reconciled === (state as SessionManagerFilters) ? state : reconciled; + }), + }), + { + name: SESSION_MANAGER_FILTER_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ + // The search box is deliberately NOT persisted: a stale query hiding + // every row across a reload is indistinguishable from an outage. + repositoryKeys: state.repositoryKeys, + phaseIds: state.phaseIds, + providerKinds: state.providerKinds, + priorities: state.priorities, + attentionKinds: state.attentionKinds, + ownerUserIds: state.ownerUserIds, + lifecycles: state.lifecycles, + staleDays: state.staleDays, + sort: state.sort, + }), + merge: (persisted, current) => ({ + ...current, + ...sanitizeSessionManagerFilters(persisted), + search: "", + sort: sanitizeSessionManagerSort( + persisted && typeof persisted === "object" + ? (persisted as { readonly sort?: unknown }).sort + : undefined, + ), + activeViewId: null, + }), + }, + ), +); diff --git a/apps/web/src/sessionManagerSelectionStore.ts b/apps/web/src/sessionManagerSelectionStore.ts new file mode 100644 index 00000000000..b91e26e9763 --- /dev/null +++ b/apps/web/src/sessionManagerSelectionStore.ts @@ -0,0 +1,158 @@ +/** + * T3-CUSTOM(expbkt3): Selection state for the bulk session manager table. + * + * Deliberately a *separate* store from `threadSelectionStore`, which the + * sidebar owns. The manager's "select all filtered" routinely selects 100+ + * rows; sharing one store would light up the sidebar's own multi-select + * chrome and bulk context menu for a selection the user made on another page. + * Same shape and semantics (toggle, shift-range, prune), different lifetime. + */ +import { create } from "zustand"; + +export interface SessionManagerSelectionState { + /** Currently selected scoped thread keys. */ + selectedThreadKeys: ReadonlySet<string>; + /** The scoped thread key that anchors shift-click range selection. */ + anchorThreadKey: string | null; +} + +interface SessionManagerSelectionStore extends SessionManagerSelectionState { + /** Toggle a single scoped thread key (plain click on the row checkbox). */ + toggleThread: (threadKey: string) => void; + /** + * Select every key between the anchor and `threadKey` (Shift+Click). + * `orderedThreadKeys` must be the keys in on-screen order, so the range + * matches what the user sees rather than the underlying sort. + */ + rangeSelectTo: (threadKey: string, orderedThreadKeys: readonly string[]) => void; + /** Replace the whole selection (select-all-filtered / Cmd-A). */ + replaceSelection: (threadKeys: readonly string[]) => void; + /** Clear all selection state. */ + clearSelection: () => void; + /** Remove specific keys (e.g. after deletion). */ + removeFromSelection: (threadKeys: readonly string[]) => void; + /** + * Drop any selected key that is no longer a live row. Rows are live atoms, + * so a thread can vanish mid-run (deleted elsewhere, filtered out by its own + * update); a stale key would make the toolbar count lie. + */ + pruneSelection: (liveThreadKeys: ReadonlySet<string>) => void; + /** Set the anchor without selecting it. */ + setAnchor: (threadKey: string) => void; + hasSelection: () => boolean; +} + +const EMPTY_SET: ReadonlySet<string> = new Set<string>(); + +export const useSessionManagerSelectionStore = create<SessionManagerSelectionStore>((set, get) => ({ + selectedThreadKeys: EMPTY_SET, + anchorThreadKey: null, + + toggleThread: (threadKey) => { + set((state) => { + const next = new Set(state.selectedThreadKeys); + if (next.has(threadKey)) { + next.delete(threadKey); + } else { + next.add(threadKey); + } + return { + selectedThreadKeys: next, + anchorThreadKey: next.has(threadKey) ? threadKey : state.anchorThreadKey, + }; + }); + }, + + rangeSelectTo: (threadKey, orderedThreadKeys) => { + set((state) => { + const anchor = state.anchorThreadKey; + if (anchor === null) { + const next = new Set(state.selectedThreadKeys); + next.add(threadKey); + return { selectedThreadKeys: next, anchorThreadKey: threadKey }; + } + + const anchorIndex = orderedThreadKeys.indexOf(anchor); + const targetIndex = orderedThreadKeys.indexOf(threadKey); + if (anchorIndex === -1 || targetIndex === -1) { + // The anchor scrolled out of the filtered set — degrade to a toggle + // rather than selecting an arbitrary range. + const next = new Set(state.selectedThreadKeys); + next.add(threadKey); + return { selectedThreadKeys: next, anchorThreadKey: threadKey }; + } + + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + const next = new Set(state.selectedThreadKeys); + for (let index = start; index <= end; index += 1) { + const key = orderedThreadKeys[index]; + if (key !== undefined) next.add(key); + } + // Anchor stays put so repeated shift-clicks grow from the same origin. + return { selectedThreadKeys: next, anchorThreadKey: anchor }; + }); + }, + + replaceSelection: (threadKeys) => { + set((state) => ({ + selectedThreadKeys: new Set(threadKeys), + anchorThreadKey: state.anchorThreadKey, + })); + }, + + clearSelection: () => { + const state = get(); + if (state.selectedThreadKeys.size === 0 && state.anchorThreadKey === null) return; + set({ selectedThreadKeys: EMPTY_SET, anchorThreadKey: null }); + }, + + setAnchor: (threadKey) => { + if (get().anchorThreadKey === threadKey) return; + set({ anchorThreadKey: threadKey }); + }, + + removeFromSelection: (threadKeys) => { + set((state) => { + const toRemove = new Set(threadKeys); + let changed = false; + const next = new Set<string>(); + for (const key of state.selectedThreadKeys) { + if (toRemove.has(key)) { + changed = true; + } else { + next.add(key); + } + } + if (!changed) return state; + const anchorThreadKey = + state.anchorThreadKey !== null && toRemove.has(state.anchorThreadKey) + ? null + : state.anchorThreadKey; + return { selectedThreadKeys: next, anchorThreadKey }; + }); + }, + + pruneSelection: (liveThreadKeys) => { + set((state) => { + let changed = false; + const next = new Set<string>(); + for (const key of state.selectedThreadKeys) { + if (liveThreadKeys.has(key)) { + next.add(key); + } else { + changed = true; + } + } + const anchorStale = + state.anchorThreadKey !== null && !liveThreadKeys.has(state.anchorThreadKey); + if (!changed && !anchorStale) return state; + return { + selectedThreadKeys: next, + anchorThreadKey: anchorStale ? null : state.anchorThreadKey, + }; + }); + }, + + hasSelection: () => get().selectedThreadKeys.size > 0, +})); diff --git a/apps/web/src/state/planReview.ts b/apps/web/src/state/planReview.ts new file mode 100644 index 00000000000..de61c2fb21e --- /dev/null +++ b/apps/web/src/state/planReview.ts @@ -0,0 +1,6 @@ +// T3-CUSTOM(expbkt3): native plan review atoms bound to the web connection. +import { createPlanReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/planReview"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const planReviewEnvironment = createPlanReviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/sessionArchive.ts b/apps/web/src/state/sessionArchive.ts new file mode 100644 index 00000000000..b7ef9e275e8 --- /dev/null +++ b/apps/web/src/state/sessionArchive.ts @@ -0,0 +1,7 @@ +// T3-CUSTOM(expbkt3): archived-session worktree reclaim commands. +import { createSessionArchiveEnvironmentAtoms } from "@t3tools/client-runtime/state/session-archive"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const sessionArchiveEnvironment = + createSessionArchiveEnvironmentAtoms(connectionAtomRuntime); diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index 89683e7bd34..a657eb13fe9 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -69,6 +69,21 @@ Examples include `thread.create`, `thread.turn.start`, and `thread.checkpoint.re A persisted fact that something already happened. In [the contracts][1], events are the source of truth, and [projector.ts][4] shows how they are applied. Examples include `thread.created`, `thread.message-sent`, and `thread.turn-diff-completed`. +#### Session lineage (child session, session tree) + +A thread created by another thread — today via the `t3_create_session` MCP tool — stores that thread +in `parentThreadId`. A thread with a parent is a **child session**; a parent plus everything beneath +it is a **session tree**. `parentThreadId` is null for a **root session**, which is what any +person-started thread is. + +Lineage is always a forest: the invariant lives in [threadLineage.ts][28], and the decider rejects a +re-parent that would close a cycle. The link is a bare `ThreadId` with no environment qualifier, +because a session can only be created by a caller on the same server. + +The experimental phase-grouped sidebar renders a tree as nested rows and pulls a parent into the +Implementing group whenever anything in its subtree is working. See [t3-mcp-control.md][29] for the +agent-facing `createAsChild` control. + #### Decider The pure orchestration logic that turns commands plus current state into events. The core implementation is in [decider.ts][8], with preconditions in [commandInvariants.ts][9]. @@ -243,3 +258,5 @@ The file patch and changed-file summary for one turn. It is usually computed in [25]: ./source-control-identity.md [26]: ./thread-bootstrap.md [27]: ./execution-reliability.md +[28]: ../../apps/server/src/orchestration/threadLineage.ts +[29]: ../user/t3-mcp-control.md diff --git a/docs/operations/expbkt3-customizations.md b/docs/operations/expbkt3-customizations.md index 083f0d0a6fc..58706271b6f 100644 --- a/docs/operations/expbkt3-customizations.md +++ b/docs/operations/expbkt3-customizations.md @@ -88,25 +88,27 @@ turn-settlement rewrite in `state/threadReducer.ts`, the restart predicate in ## Feature ownership -| Area | Dedicated implementation | Upstream-facing seams | -| ------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| Active Projects | `ActiveProjectsSettingsPanel*`, `settings.projects.tsx` | `SettingsSidebarNav.tsx`, generated route tree | -| Personal MCP identity | `ExternalMcpSettingsSection*`, `UserMcpProfileStore.ts`, `McpUpstreamProxy.ts`, `personalMcp.ts` | provider adapters, RPC group, server route/layer wiring | -| MCP operator/native tools | `apps/server/src/mcp/toolkits/control/` | MCP toolkit assembly and server route wiring | -| MCP web UI parity | `apps/server/src/mcp/toolkits/webUi/` | one reusable authenticated-handler layer export in `ws.ts`; MCP toolkit assembly | -| Plannotator runtime | `apps/server/src/plannotator/`, `packages/shared/src/plannotator.ts` | server service layers and proxy route | -| Native-plan detection | `NativePlanBridge.ts` | orchestration plan lifecycle hooks | -| Focused review UI | `PlannotatorFocusSurface*` | chat, plan card/sidebar, and right-panel store seams | -| Lifecycle counters | experimental sidebar counter components | `SidebarChrome.tsx` | -| Urgent pending input | `PhaseGroupedSidebar.logic.ts` | `PhaseGroupedSidebar.tsx` | -| Lifecycle parking shelves | `PhaseGroupedSidebar.logic.ts` (`partitionPhaseSidebarRows`), `PhaseGroupedSidebar.tsx` | `useThreadActions.ts`, `Sidebar.snooze.ts`, `Sidebar.logic.ts` (all read-only) | -| Thread Linear tags | `LinearIssueResolver.ts`, `LinearIssueTagDialog.tsx`, `linearIssue.ts`, migration 1004 | orchestration/contracts projections, `ws.ts`, `PhaseGroupedSidebar.tsx` | -| T3 Conductor | `T3ConductorCard*`, `T3Conductor.logic*`, `T3ConductorLinearIssueControl.tsx`, `T3ConductorSettingsSection.tsx` | experimental settings schema, `PhaseGroupedSidebar.tsx`, and one `ChatHeader.tsx` mount | -| Durable thread bootstrap | `apps/server/src/thread-bootstrap/`, `ThreadBootstrapPanel*`, `ProjectCreationDefaultsCard.tsx` | orchestration/contracts projections, dispatcher, terminal manager, chat composer/settings seams | -| Notification alerts | `apps/web/src/notifications/`, `NotificationsSettingsPanel.tsx`, `settings.notifications.tsx` | `__root.tsx` mount, `settingsSearch.ts` path/label, `SettingsSidebarNav.tsx` icon | -| Session title maintenance | `apps/server/src/thread-title/`, `ThreadTitleMaintenanceSettingsSection.tsx` | `ProviderCommandReactor.ts` turn-start seam, experimental settings schema, Experiments panel | -| Execution resume resync | none — two marked seams only | `ws.ts` shell/thread resume, `client-runtime/state/shellReducer.ts` overlay merge | -| Experimental deployment | `.github/workflows/deploy-expbkt3.yml`, `deploy/expbkt3/` | none | +| Area | Dedicated implementation | Upstream-facing seams | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Active Projects | `ActiveProjectsSettingsPanel*`, `settings.projects.tsx` | `SettingsSidebarNav.tsx`, generated route tree | +| Personal MCP identity | `ExternalMcpSettingsSection*`, `UserMcpProfileStore.ts`, `McpUpstreamProxy.ts`, `personalMcp.ts` | provider adapters, RPC group, server route/layer wiring | +| MCP operator/native tools | `apps/server/src/mcp/toolkits/control/` | MCP toolkit assembly and server route wiring | +| MCP web UI parity | `apps/server/src/mcp/toolkits/webUi/` | one reusable authenticated-handler layer export in `ws.ts`; MCP toolkit assembly | +| Plannotator runtime | `apps/server/src/plannotator/`, `packages/shared/src/plannotator.ts` | server service layers and proxy route | +| Native-plan detection | `NativePlanBridge.ts` | orchestration plan lifecycle hooks | +| Focused review UI | `PlannotatorFocusSurface*` | chat, plan card/sidebar, and right-panel store seams | +| Lifecycle counters | experimental sidebar counter components | `SidebarChrome.tsx` | +| Urgent pending input | `PhaseGroupedSidebar.logic.ts` | `PhaseGroupedSidebar.tsx` | +| Lifecycle parking shelves | `PhaseGroupedSidebar.logic.ts` (`partitionPhaseSidebarRows`), `PhaseGroupedSidebar.tsx` | `useThreadActions.ts`, `Sidebar.snooze.ts`, `Sidebar.logic.ts` (all read-only) | +| Thread Linear tags | `LinearIssueResolver.ts`, `LinearIssueTagDialog.tsx`, `linearIssue.ts`, migration 1004 | orchestration/contracts projections, `ws.ts`, `PhaseGroupedSidebar.tsx` | +| T3 Conductor | `T3ConductorCard*`, `T3Conductor.logic*`, `T3ConductorLinearIssueControl.tsx`, `T3ConductorSettingsSection.tsx` | experimental settings schema, `PhaseGroupedSidebar.tsx`, and one `ChatHeader.tsx` mount | +| Durable thread bootstrap | `apps/server/src/thread-bootstrap/`, `ThreadBootstrapPanel*`, `ProjectCreationDefaultsCard.tsx` | orchestration/contracts projections, dispatcher, terminal manager, chat composer/settings seams | +| Notification alerts | `apps/web/src/notifications/`, `NotificationsSettingsPanel.tsx`, `settings.notifications.tsx` | `__root.tsx` mount, `settingsSearch.ts` path/label, `SettingsSidebarNav.tsx` icon | +| Session title maintenance | `apps/server/src/thread-title/`, `ThreadTitleMaintenanceSettingsSection.tsx` | `ProviderCommandReactor.ts` turn-start seam, experimental settings schema, Experiments panel | +| Sidebar people filters | `PhaseGroupedSidebar.logic.ts` facets, `phaseSidebarFilterStore.ts` | `PhaseGroupedSidebar.tsx` popover, chips, row projection | +| Execution resume resync | none — two marked seams only | `ws.ts` shell/thread resume, `client-runtime/state/shellReducer.ts` overlay merge | +| Native plan review | `apps/server/src/planreview/`, `persistence/PlanReviewDocuments.ts`, migration 1009, `packages/shared/src/planReview.ts`, `packages/contracts/src/planReview.ts`, `apps/web/src/components/planreview/`, `apps/web/src/fork/planReviewSurface.tsx` | fork RPC group + scopes + handlers, one `ws.ts` dep, right-panel store/tabs, `ChatView.tsx` branch, `ProposedPlanCard.tsx` button, Beta settings toggle | +| Experimental deployment | `.github/workflows/deploy-expbkt3.yml`, `deploy/expbkt3/` | none | Generated files such as `apps/web/src/routeTree.gen.ts` do not receive hand-written markers; they are regenerated from marked route sources. @@ -234,6 +236,26 @@ Escalation to Mattermost deliberately does **not** live here. It belongs to `t3-linear-bridge`, which already polls `/api/orchestration/shell` for every thread and owns Mattermost delivery, mention resolution, and idempotency keys. +## Sidebar people filters + +Two facets in the experimental sidebar's filter popover, both fork-owned logic +with the UI in `PhaseGroupedSidebar.tsx`: + +- **Started by me** (`ownedByMe`) matches on `ownerUserId`. It replaced an + "Assigned to me" facet that tested owner-or-tagged — which is exactly the + server's visibility rule in `accessRules.ts`, so every thread the operator + could see already satisfied it and the checkbox selected everything. If a + future change makes that filter "assigned" again, it is a no-op again. +- **People on the session** (`participantUserIds`) matches threads that include + _all_ selected people, not any: selecting two teammates asks for their shared + sessions. The directory comes from `useOrgMembers`, and `reconcile` drops ids + that leave it — but only when a directory set is supplied, so an empty list + during load cannot wipe a live selection. + +Both persist in the existing `t3code:phase-sidebar-filters:v1` blob; the +sanitizer defaults them off for blobs written before they existed, so the +storage version stays v1. + ## Execution state is live-only — keep the resume seams Execution (`activity`, `turn.state`, the durable intent overlay) is published by diff --git a/docs/user/plan-review.md b/docs/user/plan-review.md new file mode 100644 index 00000000000..c9752b763c3 --- /dev/null +++ b/docs/user/plan-review.md @@ -0,0 +1,80 @@ +# Plan review + +> **T3-CUSTOM(expbkt3):** This feature is maintained as an experimental, +> upstream-isolated extension. See the +> [customization boundary registry](../operations/expbkt3-customizations.md). + +When an agent proposes a plan, you can open it in a side panel, comment on exact +lines, edit it, and send the result back — without the agent re-reading the whole +plan every round. + +Turn it on or off in **Settings → Beta features → Native plan review**. It is on +by default. While it is off, plan review goes through Plannotator only. + +## Opening a plan + +Two ways in, both of which appear once an agent has proposed a plan that has not +been implemented yet: + +- **Preview** on the plan card in the conversation. +- A floating **Open the plan in preview** button above the composer. + +The panel opens beside the chat as a tab, so the conversation stays visible. Use +the maximise control in the panel header for a full-width read. + +## Commenting + +Select any text in the plan and choose **Comment on selection**. The comment is +anchored to the exact lines you selected, and those lines are quoted back to the +agent when you send feedback — the rest of the plan is not repeated. + +Comments appear in the rail beside the plan. **Resolve** a comment once it no +longer applies; resolved comments are left out of what gets sent. + +## Editing + +The panel edits the plan directly. **Suggest edits** is on by default, so your +changes are recorded as tracked changes attributed to you rather than silently +overwriting what the agent wrote. Turn it off to edit in place. + +**Save version** records your edits as a new version without sending anything. + +If two people edit the same plan at once, the second save is refused with a +notice rather than overwriting the first. Reload the panel to pick up their +changes. + +## Versions + +The **Versions** tab lists every revision of the plan in order, each with its +author — the agent, you, or a teammate — and when it was made. Select any two +versions to see what changed between them. **Restore** brings an older version +back as a new version; history is never rewritten. + +The list is one continuous history: when you send feedback and the agent +rewrites the plan, its revision lands in the same list rather than starting a new +one. + +## Sending the result + +- **Approve** starts implementation. The agent is told the plan is approved + rather than being sent the plan again, because it already has it. If you edited + the plan first, only your changes are sent. In the few cases where the agent + genuinely cannot see the plan any more — the session compacted its context + after the plan was written, or it is no longer running — the full plan is + repeated, and the confirmation says so. +- **Send feedback** asks for a revision. The agent receives your comments with + the lines they point at, plus a diff of any edits you made — not the document. +- **Discard** closes the review without telling the agent anything. + +Add overall notes in the box above the buttons; they are sent with either +decision. + +## Which plans this applies to + +Plan review works on plans produced by T3's normal plan mode, for any provider +that supports it. Only the newest plan on a session that has not been implemented +yet is reviewable. Once a plan is approved and implementation starts, its review +becomes read-only history. + +Plan review is available in the web and desktop apps. On mobile, plan decisions +appear in the session history but the panel itself is not available yet. diff --git a/docs/user/t3-mcp-control.md b/docs/user/t3-mcp-control.md index bf78263b6c3..d0237e6a37a 100644 --- a/docs/user/t3-mcp-control.md +++ b/docs/user/t3-mcp-control.md @@ -128,6 +128,8 @@ custom-header authentication are also supported. | `t3_create_project` | Register or safely create a workspace project on a fresh T3 server. External operators only. | | `t3_update_project` | Update project creation defaults, model/options, and project actions. External operators only. | | `t3_create_session` | Create a user-owned session. User-bound provider sessions, external users, and legacy external operators. | +| `t3_link_session` | File one session under another, for organising related work into a tree. | +| `t3_unlink_session` | Detach a session from its parent, returning it to the top level. | | `t3_submit_plan` | Publish Markdown or HTML and start an attached Plannotator review gate. | | `t3_list_plannotator_reviews` | Inspect review state, decision, feedback, proxy path, and diagnostics. | | `t3_update_server_settings` | Apply a validated settings patch. External operators only. | @@ -196,6 +198,40 @@ It returns `threadId`, `bootstrapId`, and `bootstrapStatus: "queued"` after dura prompt is supplied, the first turn remains pending until new-worktree setup succeeds or a user bypasses a failed setup. +### Session lineage + +A session created by another session records that session as its parent, and the experimental +phase-grouped sidebar files it under that parent as a collapsible subtree. This keeps a fan-out — +typically cross-repo work — readable as one unit of work instead of several unrelated rows. + +Nesting is the calling agent's choice: + +| Field | Effect | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createAsChild` omitted or `true` | Nested under the calling session. The default, and the right choice for work you fanned out. | +| `createAsChild: false` | Created at the top level, exactly as if a person had started it. Use this when the new session is independent work that should stand on its own. | +| `parentSessionId` | Nest under a specific session rather than the caller, for building a tree you are not the root of. Cannot be combined with `createAsChild: false`. | + +`t3_list_sessions` and `t3_get_session` report `parentSessionId`, so an agent can inspect its own +subtree instead of re-spawning work it already delegated. + +Sessions created through a personal external token are never parented automatically: that token is +scoped to the user's conductor thread rather than to a session they are working in. + +Lineage is editable after creation, so an agent can reorganise a workspace it did not lay out +itself: + +- `t3_link_session({ sessionId, parentSessionId })` files one session under another. +- `t3_unlink_session({ sessionId })` returns it to the top level. + +A session's own children always travel with it, and unlinking a parent never orphans its subtree. + +A person can do the same from the sidebar row's context menu — **Move under session…** and **Detach +from parent**. + +Lineage always stays a tree. A session cannot be filed under itself or under any of its own +descendants; the server rejects such a link rather than storing a cycle. + ## Recommended triage loop 1. Call `t3_list_sessions` with `attentionOnly: true`. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 119145a72a0..cd893ec6742 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -127,6 +127,10 @@ "types": "./src/state/users.ts", "default": "./src/state/users.ts" }, + "./state/planReview": { + "types": "./src/state/planReview.ts", + "default": "./src/state/planReview.ts" + }, "./state/terminal": { "types": "./src/state/terminal.ts", "default": "./src/state/terminal.ts" @@ -155,6 +159,10 @@ "types": "./src/state/threadSearch.ts", "default": "./src/state/threadSearch.ts" }, + "./state/session-archive": { + "types": "./src/state/sessionArchive.ts", + "default": "./src/state/sessionArchive.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/operations/commandsFork.ts b/packages/client-runtime/src/operations/commandsFork.ts index ba280d00543..21917fb9b08 100644 --- a/packages/client-runtime/src/operations/commandsFork.ts +++ b/packages/client-runtime/src/operations/commandsFork.ts @@ -25,6 +25,7 @@ import { } from "./commands.ts"; export type RequestThreadCatchupSummaryInput = ForkCommandInput<"thread.catchup-summary.request">; +export type RequestThreadWorkSummaryInput = ForkCommandInput<"thread.work-summary.request">; export type RestartThreadSessionInput = ForkCommandInput<"thread.session.restart">; export type StopThreadExecutionInput = OrchestrationStopExecutionInput; export type AddThreadMemberInput = ForkCommandInput<"thread.member.add">; @@ -105,6 +106,23 @@ export const requestThreadCatchupSummary: ( }, ); +/** + * Bulk session manager: ask the server to (re)generate this thread's work + * summary. One command per selected session; the reactor answers durably on + * the thread's `workSummary` column, so the table renders progress from live + * shell state rather than from this call's result. + */ +export const requestThreadWorkSummary: (input: RequestThreadWorkSummaryInput) => ForkCommandEffect = + Effect.fn("EnvironmentCommands.requestThreadWorkSummary")(function* (input) { + const metadata = yield* timestampedCommandMetadataInternal(input); + return yield* dispatchCommandInternal({ + ...input, + type: "thread.work-summary.request", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + export const restartThreadSession: (input: RestartThreadSessionInput) => ForkCommandEffect = Effect.fn("EnvironmentCommands.restartThreadSession")(function* (input) { const metadata = yield* timestampedCommandMetadataInternal(input); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index a8b74ba04e7..984aa99dd12 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -48,6 +48,8 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.subscribeServerResources | typeof WS_METHODS.subscribeProviderRateLimits + // T3-CUSTOM(expbkt3): native plan review snapshots. + | typeof WS_METHODS.subscribePlanReview | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents diff --git a/packages/client-runtime/src/state/planReview.ts b/packages/client-runtime/src/state/planReview.ts new file mode 100644 index 00000000000..bf99bb635c3 --- /dev/null +++ b/packages/client-runtime/src/state/planReview.ts @@ -0,0 +1,85 @@ +/** + * T3-CUSTOM(expbkt3): client atoms for native plan review. + * + * Mutations are serialised per document rather than per environment: two people + * reviewing different plans should not queue behind each other, but concurrent + * writes to one document must stay ordered so the draft revision token means + * something. + */ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; + +export function createPlanReviewEnvironmentAtoms<R, E>( + runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>, +) { + const scheduler = createAtomCommandScheduler(); + const serialByDocument = { + mode: "serial" as const, + key: ({ + environmentId, + input, + }: { + readonly environmentId: string; + readonly input: { readonly documentId: string }; + }) => `${environmentId}:${input.documentId}`, + }; + + return { + review: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:plan-review:get", + tag: WS_METHODS.planReviewGet, + }), + list: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:plan-review:list", + tag: WS_METHODS.planReviewList, + }), + versionDiff: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:plan-review:version-diff", + tag: WS_METHODS.planReviewVersionDiff, + }), + subscription: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:plan-review:subscribe", + tag: WS_METHODS.subscribePlanReview, + idleTtlMs: 5_000, + }), + saveDraft: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plan-review:save-draft", + tag: WS_METHODS.planReviewSaveDraft, + scheduler, + concurrency: serialByDocument, + }), + cutVersion: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plan-review:cut-version", + tag: WS_METHODS.planReviewCutVersion, + scheduler, + concurrency: serialByDocument, + }), + upsertDiscussion: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plan-review:upsert-discussion", + tag: WS_METHODS.planReviewUpsertDiscussion, + scheduler, + concurrency: serialByDocument, + }), + resolveDiscussion: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plan-review:resolve-discussion", + tag: WS_METHODS.planReviewResolveDiscussion, + scheduler, + concurrency: serialByDocument, + }), + submit: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plan-review:submit", + tag: WS_METHODS.planReviewSubmit, + scheduler, + concurrency: serialByDocument, + }), + }; +} diff --git a/packages/client-runtime/src/state/sessionArchive.ts b/packages/client-runtime/src/state/sessionArchive.ts new file mode 100644 index 00000000000..ba2f1ea3208 --- /dev/null +++ b/packages/client-runtime/src/state/sessionArchive.ts @@ -0,0 +1,32 @@ +/** + * T3-CUSTOM(expbkt3): Client commands for archived-session worktree reclaim. + * + * Three RPCs, all deliberately unscheduled and serialized by the server rather + * than the client: a scan walks the filesystem and a reclaim deletes from it, + * so firing several concurrently would only compete for the same disk. The + * panel drives these one at a time from an explicit click. + */ +import { WS_METHODS } from "@t3tools/contracts"; +import type { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcCommand } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createSessionArchiveEnvironmentAtoms<R, E>( + runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, +) { + return { + scan: createEnvironmentRpcCommand(runtime, { + label: "environment-data:session-archive:scan", + tag: WS_METHODS.sessionArchiveScan, + }), + exportHistory: createEnvironmentRpcCommand(runtime, { + label: "environment-data:session-archive:export", + tag: WS_METHODS.sessionArchiveExport, + }), + reclaim: createEnvironmentRpcCommand(runtime, { + label: "environment-data:session-archive:reclaim", + tag: WS_METHODS.sessionArchiveReclaim, + }), + }; +} diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index b6d8e163ed1..dad2d1f3078 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -65,12 +65,14 @@ import { type AddThreadMemberInput, type RemoveThreadMemberInput, type RequestThreadCatchupSummaryInput, + type RequestThreadWorkSummaryInput, type RestartThreadSessionInput, type StopThreadExecutionInput, type TransferThreadOwnershipInput, addThreadMember, removeThreadMember, requestThreadCatchupSummary, + requestThreadWorkSummary, restartThreadSession, stopThreadExecution, transferThreadOwnership, @@ -235,6 +237,7 @@ export type { AddThreadMemberInput, RemoveThreadMemberInput, RequestThreadCatchupSummaryInput, + RequestThreadWorkSummaryInput, RestartThreadSessionInput, StopThreadExecutionInput, TransferThreadOwnershipInput, @@ -412,6 +415,14 @@ export function createThreadEnvironmentAtoms<R, E>( scheduler, concurrency, }), + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary request. + requestWorkSummary: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:request-work-summary", + execute: (input: RequestThreadWorkSummaryInput) => requestThreadWorkSummary(input), + scheduler, + concurrency, + }), + // T3-CUSTOM(expbkt3): END stopSession: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:stop-session", execute: (input: StopThreadSessionInput) => stopThreadSession(input), diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 720e7f3d621..cd2f7e243fd 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -240,6 +240,10 @@ export function applyThreadDetailEvent( ...(event.payload.linearIssueUrl !== undefined ? { linearIssueUrl: event.payload.linearIssueUrl } : {}), + // T3-CUSTOM(expbkt3): session lineage re-parent / detach. + ...(event.payload.parentThreadId !== undefined + ? { parentThreadId: event.payload.parentThreadId } + : {}), updatedAt: event.payload.updatedAt, }, }; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0e3c0f7684f..64e3fa869fe 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -20,11 +20,15 @@ export * from "./settings.ts"; export * from "./personalMcp.ts"; // T3-CUSTOM(expbkt3): lifecycle-row Linear issue status schemas export * from "./linearIssue.ts"; +// T3-CUSTOM(expbkt3): native plan review contracts. +export * from "./planReview.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; // T3-CUSTOM(expbkt3): fork source-control identity schemas export * from "./sourceControlProfiles.ts"; +// T3-CUSTOM(expbkt3): archived-session worktree reclaim schemas +export * from "./sessionArchive.ts"; // T3-CUSTOM(expbkt3): fork websocket RPC definitions export * from "./rpcFork.ts"; export * from "./orchestration.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 32bb482749f..589a6a47db8 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -615,6 +615,54 @@ export const ThreadTitleRegeneration = Schema.Struct({ }); export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; +// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. +// +// A per-thread, AI-written answer to "what did this session do and how far is +// it?", generated on demand for the bulk session manager table. It is a +// separate pipeline from the catch-up summary: its own settings block, its own +// model, its own prompt, and its own durable column, so turning either off +// leaves the other intact. +/** + * Coarse lifecycle stage the model judges the session to be in. Deliberately + * five buckets: enough to sort a table by, few enough that the model picks the + * same one twice. + */ +export const ThreadWorkSummaryStage = Schema.Literals([ + "planning", + "implementing", + "blocked", + "awaiting-review", + "done", +]); +export type ThreadWorkSummaryStage = typeof ThreadWorkSummaryStage.Type; + +/** + * "pending" while the reactor is generating, "ready" once the model answered, + * and "error" when generation failed or the feature is disabled. The error + * state is durable so a reconnecting table shows the reason instead of an + * eternal spinner. + */ +export const ThreadWorkSummaryStatus = Schema.Literals(["pending", "ready", "error"]); +export type ThreadWorkSummaryStatus = typeof ThreadWorkSummaryStatus.Type; + +export const ThreadWorkSummary = Schema.Struct({ + status: ThreadWorkSummaryStatus, + /** Prose work summary. Null while pending and on error. */ + summary: Schema.NullOr(Schema.String), + stage: Schema.NullOr(ThreadWorkSummaryStage), + /** One line describing what is left. Empty string when the session is done. */ + remaining: Schema.NullOr(Schema.String), + /** Rough completion of the session's stated goal, 0..100. */ + percent: Schema.NullOr(NonNegativeInt), + /** User-facing failure detail; only set for `status: "error"`. */ + error: Schema.NullOr(Schema.String), + /** Command id of the request this record answers; drives the supersede rule. */ + requestId: Schema.NullOr(CommandId), + updatedAt: IsoDateTime, +}); +export type ThreadWorkSummary = typeof ThreadWorkSummary.Type; +// T3-CUSTOM(expbkt3): END + // T3-CUSTOM(expbkt3): session priority. Linear-style P0..P4 stored as an // integer so ordering is arithmetic; 0 is the highest priority and an absent // value means "unprioritised" (sorts after P4). The "P0" spelling is purely @@ -622,6 +670,19 @@ export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; export const ThreadPriority = Schema.Literals([0, 1, 2, 3, 4]); export type ThreadPriority = typeof ThreadPriority.Type; +// T3-CUSTOM(expbkt3): session lineage. A thread spawned by another session +// (today: the `t3_create_session` MCP tool) records the thread that spawned +// it, so the experimental sidebar can file it under its parent instead of +// stranding it as an unrelated top-level row. A null value means "root +// session", which is what a human-started session always is. +// +// The link is deliberately a bare ThreadId with no environment qualifier: +// a session can only be created by a caller on the same server, so parent +// and child always share an environment. Consumers resolve it within the +// environment they already hold. +// The cycle guard that enforces this lives server-side in +// apps/server/src/orchestration/threadLineage.ts — contracts stay schema-only. + // T3-CUSTOM(expbkt3): attach-to-external-session. Binds a brand-new thread to // a provider session that was started outside T3 (e.g. `claude`/`codex` in a // terminal). Carries the provider *instance* rather than the driver kind @@ -677,6 +738,13 @@ export const OrchestrationThread = Schema.Struct({ priority: Schema.optional(Schema.NullOr(ThreadPriority)), // T3-CUSTOM(expbkt3): optional so payloads from pre-manual-tag servers decode. linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // T3-CUSTOM(expbkt3): session lineage. Optional so payloads from + // pre-lineage servers decode; null means this is a root session. + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. Optional so payloads + // from pre-work-summary servers decode; absent/null means never generated. + workSummary: Schema.optional(Schema.NullOr(ThreadWorkSummary)), + // T3-CUSTOM(expbkt3): END deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -755,6 +823,11 @@ export const OrchestrationThreadShell = Schema.Struct({ priority: Schema.optional(Schema.NullOr(ThreadPriority)), // T3-CUSTOM(expbkt3): durable manual Linear issue URL. linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // T3-CUSTOM(expbkt3): session lineage (see the ThreadPriority block above). + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (see ThreadWorkSummary). + workSummary: Schema.optional(Schema.NullOr(ThreadWorkSummary)), + // T3-CUSTOM(expbkt3): END session: Schema.NullOr(OrchestrationSession), execution: Schema.optionalKey(Schema.NullOr(ThreadExecutionSnapshot)), latestUserMessageAt: Schema.NullOr(IsoDateTime), @@ -976,6 +1049,8 @@ const ThreadCreateCommand = Schema.Struct({ createdAt: IsoDateTime, // T3-CUSTOM(expbkt3): session priority. Absent means "unprioritised". priority: Schema.optional(Schema.NullOr(ThreadPriority)), + // T3-CUSTOM(expbkt3): session lineage. Absent/null creates a root session. + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), // T3-CUSTOM(expbkt3): attach-to-external-session. Handled as a dispatcher // side-effect (seeds the provider session binding); deliberately not carried // into the thread.created event, so the event log stays upstream-shaped. @@ -1027,6 +1102,8 @@ export const ThreadBootstrapRequestCommand = Schema.Struct({ overrides: Schema.optional(ThreadBootstrapOverrides), sourceControlProfileId: Schema.optional(Schema.NullOr(SourceControlProfileId)), priority: Schema.optional(Schema.NullOr(ThreadPriority)), + // T3-CUSTOM(expbkt3): session lineage. Absent/null creates a root session. + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), ownerUserId: Schema.optional(UserId), createdAt: IsoDateTime, }); @@ -1115,6 +1192,9 @@ export const ResolvedThreadBootstrapRequest = Schema.Struct({ ), sourceControlProfileId: Schema.NullOr(SourceControlProfileId), priority: Schema.NullOr(ThreadPriority), + // T3-CUSTOM(expbkt3): session lineage, resolved at accept time. Optional so + // resolved requests persisted before lineage shipped still decode. + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), ownerUserId: Schema.optional(UserId), createdAt: IsoDateTime, }); @@ -1201,6 +1281,9 @@ const ThreadMetaUpdateCommand = Schema.Struct({ priority: Schema.optional(Schema.NullOr(ThreadPriority)), // T3-CUSTOM(expbkt3): manual Linear tag. undefined = unchanged, null = clear. linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // T3-CUSTOM(expbkt3): session lineage. undefined = unchanged, null = detach + // to a root session. The decider rejects a value that would form a cycle. + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), }).check( Schema.makeFilter( (input) => @@ -1292,6 +1375,9 @@ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ // T3-CUSTOM(expbkt3): lets single-shot creators (MCP, the Linear bridge) // set a priority at creation time. priority: Schema.optional(Schema.NullOr(ThreadPriority)), + // T3-CUSTOM(expbkt3): session lineage set at creation time by the same + // single-shot creators. Absent/null creates a root session. + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), }); const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ @@ -1318,6 +1404,8 @@ export const ThreadTurnStartBootstrap = Schema.Struct({ overrides: Schema.optional(ThreadBootstrapOverrides), sourceControlProfileId: Schema.optional(Schema.NullOr(SourceControlProfileId)), priority: Schema.optional(Schema.NullOr(ThreadPriority)), + // T3-CUSTOM(expbkt3): session lineage carried through the client outbox. + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), ownerUserId: Schema.optional(UserId), createdAt: IsoDateTime, }), @@ -1418,6 +1506,16 @@ const ThreadCatchupSummaryRequestCommand = Schema.Struct({ createdAt: IsoDateTime, }); +// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary request. Public +// and user-triggered, dispatched one command per selected session. +const ThreadWorkSummaryRequestCommand = Schema.Struct({ + type: Schema.Literal("thread.work-summary.request"), + commandId: CommandId, + threadId: ThreadId, + createdAt: IsoDateTime, +}); +// T3-CUSTOM(expbkt3): END + const ThreadSessionStopCommand = Schema.Struct({ type: Schema.Literal("thread.session.stop"), commandId: CommandId, @@ -1465,6 +1563,9 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadCatchupSummaryRequestCommand, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + ThreadWorkSummaryRequestCommand, + // T3-CUSTOM(expbkt3): END ThreadSessionStopCommand, ThreadSessionRestartCommand, ]); @@ -1504,6 +1605,9 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadCatchupSummaryRequestCommand, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. + ThreadWorkSummaryRequestCommand, + // T3-CUSTOM(expbkt3): END ThreadSessionStopCommand, ThreadSessionRestartCommand, ]); @@ -1588,6 +1692,20 @@ const ThreadCatchupSummaryUpdateCommand = Schema.Struct({ createdAt: IsoDateTime, }); +// T3-CUSTOM(expbkt3): BEGIN — internal work summary result. Dispatched by the +// WorkSummaryReactor only; the public entry point is +// `thread.work-summary.request` above. +const ThreadWorkSummaryUpdateCommand = Schema.Struct({ + type: Schema.Literal("thread.work-summary.update"), + commandId: CommandId, + threadId: ThreadId, + /** The `thread.work-summary.request` command id this result answers. */ + requestId: CommandId, + workSummary: ThreadWorkSummary, + createdAt: IsoDateTime, +}); +// T3-CUSTOM(expbkt3): END + const ThreadActivityAppendCommand = Schema.Struct({ type: Schema.Literal("thread.activity.append"), commandId: CommandId, @@ -1665,6 +1783,9 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadCatchupSummaryUpdateCommand, + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary result. + ThreadWorkSummaryUpdateCommand, + // T3-CUSTOM(expbkt3): END ThreadActivityAppendCommand, ThreadRevertCompleteCommand, ThreadTitleRegenerationCompleteCommand, @@ -1719,6 +1840,10 @@ export const OrchestrationEventType = Schema.Literals([ "thread.turn-diff-completed", "thread.catchup-summary-requested", "thread.catchup-summary-updated", + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary lifecycle. + "thread.work-summary-requested", + "thread.work-summary-updated", + // T3-CUSTOM(expbkt3): END "thread.activity-appended", // T3-CUSTOM(expbkt3): durable workspace preparation lifecycle. "thread.bootstrap-requested", @@ -1784,6 +1909,9 @@ export const ThreadCreatedPayload = Schema.Struct({ updatedAt: IsoDateTime, // T3-CUSTOM(expbkt3): session priority at creation time. priority: Schema.optional(Schema.NullOr(ThreadPriority)), + // T3-CUSTOM(expbkt3): session lineage at creation time. Immutable on this + // event; later re-parenting travels on thread.meta.updated. + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), }); export const ThreadDeletedPayload = Schema.Struct({ @@ -1859,6 +1987,8 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ priority: Schema.optional(Schema.NullOr(ThreadPriority)), // T3-CUSTOM(expbkt3): manual Linear tag. undefined = unchanged, null = clear. linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // T3-CUSTOM(expbkt3): session lineage. undefined = unchanged, null = detach. + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), updatedAt: IsoDateTime, }); @@ -1993,6 +2123,21 @@ export const ThreadCatchupSummaryUpdatedPayload = Schema.Struct({ createdAt: IsoDateTime, }); +// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary events. +export const ThreadWorkSummaryRequestedPayload = Schema.Struct({ + threadId: ThreadId, + /** Command id of the request; the projector stores it on the pending record. */ + requestId: CommandId, + requestedAt: IsoDateTime, +}); + +export const ThreadWorkSummaryUpdatedPayload = Schema.Struct({ + threadId: ThreadId, + requestId: CommandId, + workSummary: ThreadWorkSummary, +}); +// T3-CUSTOM(expbkt3): END + export const ThreadActivityAppendedPayload = Schema.Struct({ threadId: ThreadId, activity: OrchestrationThreadActivity, @@ -2285,6 +2430,18 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.catchup-summary-updated"), payload: ThreadCatchupSummaryUpdatedPayload, }), + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary events. + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.work-summary-requested"), + payload: ThreadWorkSummaryRequestedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.work-summary-updated"), + payload: ThreadWorkSummaryUpdatedPayload, + }), + // T3-CUSTOM(expbkt3): END Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.activity-appended"), diff --git a/packages/contracts/src/planReview.ts b/packages/contracts/src/planReview.ts new file mode 100644 index 00000000000..b26eb9d3091 --- /dev/null +++ b/packages/contracts/src/planReview.ts @@ -0,0 +1,194 @@ +/** + * T3-CUSTOM(expbkt3): contracts for native plan review. + * + * Plan review is not an orchestration aggregate — it lives in fork-owned tables + * reached over fork RPC, so these schemas stay out of `orchestration.ts` and + * cost nothing at upstream merge time. + */ +import * as Schema from "effect/Schema"; + +import { ThreadId, UserId } from "./baseSchemas.ts"; + +export const PlanReviewStatus = Schema.Literals([ + "open", + "approved", + "changes-requested", + "discarded", +]); +export type PlanReviewStatus = typeof PlanReviewStatus.Type; + +export const PlanReviewFormat = Schema.Literals(["md", "html"]); +export type PlanReviewFormat = typeof PlanReviewFormat.Type; + +export const PlanReviewAuthorKind = Schema.Literals(["agent", "user"]); +export type PlanReviewAuthorKind = typeof PlanReviewAuthorKind.Type; + +export const PlanReviewVersionOrigin = Schema.Literals([ + "agent-proposed", + "agent-revision", + "human-edit", +]); +export type PlanReviewVersionOrigin = typeof PlanReviewVersionOrigin.Type; + +export const PlanReviewDecision = Schema.Literals(["approved", "changes-requested", "discarded"]); +export type PlanReviewDecision = typeof PlanReviewDecision.Type; + +export const PlanReviewDocument = Schema.Struct({ + documentId: Schema.String, + threadId: ThreadId, + projectId: Schema.String, + title: Schema.String, + currentRevision: Schema.Number, + status: PlanReviewStatus, + format: PlanReviewFormat, + createdByUserId: Schema.NullOr(UserId), + createdAt: Schema.String, + updatedAt: Schema.String, +}); +export type PlanReviewDocument = typeof PlanReviewDocument.Type; + +export const PlanReviewVersion = Schema.Struct({ + versionId: Schema.String, + documentId: Schema.String, + revision: Schema.Number, + authorKind: PlanReviewAuthorKind, + authorUserId: Schema.NullOr(UserId), + origin: PlanReviewVersionOrigin, + contentMarkdown: Schema.String, + contentValueJson: Schema.NullOr(Schema.String), + summary: Schema.NullOr(Schema.String), + createdAt: Schema.String, +}); +export type PlanReviewVersion = typeof PlanReviewVersion.Type; + +export const PlanReviewDraft = Schema.Struct({ + contentValueJson: Schema.String, + baseVersionId: Schema.String, + revisionToken: Schema.String, + updatedByUserId: Schema.NullOr(UserId), + updatedAt: Schema.String, +}); +export type PlanReviewDraft = typeof PlanReviewDraft.Type; + +export const PlanReviewComment = Schema.Struct({ + commentId: Schema.String, + discussionId: Schema.String, + authorUserId: Schema.NullOr(UserId), + bodyMarkdown: Schema.String, + isEdited: Schema.Boolean, + createdAt: Schema.String, + updatedAt: Schema.String, +}); +export type PlanReviewComment = typeof PlanReviewComment.Type; + +export const PlanReviewDiscussion = Schema.Struct({ + discussionId: Schema.String, + documentId: Schema.String, + anchorVersionId: Schema.String, + quotedText: Schema.String, + isResolved: Schema.Boolean, + resolvedByUserId: Schema.NullOr(UserId), + resolvedAt: Schema.NullOr(Schema.String), + createdByUserId: Schema.NullOr(UserId), + createdAt: Schema.String, +}); +export type PlanReviewDiscussion = typeof PlanReviewDiscussion.Type; + +export const PlanReviewSnapshotResult = Schema.Struct({ + document: PlanReviewDocument, + versions: Schema.Array(PlanReviewVersion), + draft: Schema.NullOr(PlanReviewDraft), + discussions: Schema.Array(PlanReviewDiscussion), + comments: Schema.Array(PlanReviewComment), +}); +export type PlanReviewSnapshotResult = typeof PlanReviewSnapshotResult.Type; + +export const PlanReviewListResult = Schema.Struct({ + documents: Schema.Array(PlanReviewDocument), +}); +export type PlanReviewListResult = typeof PlanReviewListResult.Type; + +export const PlanReviewDocumentIdInput = Schema.Struct({ + documentId: Schema.String, +}); + +export const PlanReviewListInput = Schema.Struct({ + threadId: ThreadId, +}); + +export const PlanReviewSaveDraftInput = Schema.Struct({ + documentId: Schema.String, + contentValueJson: Schema.String, + /** Null on the first save for a document. */ + expectedRevisionToken: Schema.NullOr(Schema.String), +}); + +export const PlanReviewSaveDraftResult = Schema.Struct({ + revisionToken: Schema.String, +}); + +export const PlanReviewCutVersionInput = Schema.Struct({ + documentId: Schema.String, + contentMarkdown: Schema.String, + contentValueJson: Schema.NullOr(Schema.String), + summary: Schema.NullOr(Schema.String), +}); + +export const PlanReviewUpsertDiscussionInput = Schema.Struct({ + documentId: Schema.String, + discussionId: Schema.String, + quotedText: Schema.String, + bodyMarkdown: Schema.String, +}); + +export const PlanReviewResolveDiscussionInput = Schema.Struct({ + documentId: Schema.String, + discussionId: Schema.String, + isResolved: Schema.Boolean, +}); + +export const PlanReviewVersionDiffInput = Schema.Struct({ + documentId: Schema.String, + fromVersionId: Schema.String, + toVersionId: Schema.String, +}); + +export const PlanReviewVersionDiffResult = Schema.Struct({ + diff: Schema.String, +}); + +export const PlanReviewSubmitInput = Schema.Struct({ + documentId: Schema.String, + decision: PlanReviewDecision, + globalComment: Schema.String, + /** Reviewer-edited markdown; null when the plan was not edited. */ + editedMarkdown: Schema.NullOr(Schema.String), +}); + +export const PlanReviewSubmitResult = Schema.Struct({ + documentId: Schema.String, + status: PlanReviewStatus, + /** The exact text handed to the agent, so the UI can show what was sent. */ + prompt: Schema.NullOr(Schema.String), + turnStarted: Schema.Boolean, + /** True when the policy decided the plan body had to be repeated. */ + resentPlan: Schema.Boolean, +}); + +export const PlanReviewErrorReason = Schema.Literals([ + "not-found", + "draft-conflict", + "version-conflict", + "invalid", +]); +export type PlanReviewErrorReason = typeof PlanReviewErrorReason.Type; + +export class PlanReviewError extends Schema.TaggedErrorClass<PlanReviewError>()("PlanReviewError", { + operation: Schema.String, + reason: PlanReviewErrorReason, + detail: Schema.String, +}) { + override get message(): string { + return `Plan review ${this.operation} failed: ${this.detail}`; + } +} diff --git a/packages/contracts/src/rpcFork.ts b/packages/contracts/src/rpcFork.ts index a44e4178ffe..270ab787119 100644 --- a/packages/contracts/src/rpcFork.ts +++ b/packages/contracts/src/rpcFork.ts @@ -25,8 +25,32 @@ import { PersonalMcpTokenResult, } from "./personalMcp.ts"; import { LinearIssueStatusInput, LinearIssueStatusResult } from "./linearIssue.ts"; +import { + PlanReviewCutVersionInput, + PlanReviewDocumentIdInput, + PlanReviewError, + PlanReviewListInput, + PlanReviewListResult, + PlanReviewResolveDiscussionInput, + PlanReviewSaveDraftInput, + PlanReviewSaveDraftResult, + PlanReviewSnapshotResult, + PlanReviewSubmitInput, + PlanReviewSubmitResult, + PlanReviewUpsertDiscussionInput, + PlanReviewVersionDiffInput, + PlanReviewVersionDiffResult, +} from "./planReview.ts"; import { ProviderRateLimitsStreamSnapshot } from "./providerRateLimits.ts"; import { ServerResourceSample } from "./server.ts"; +import { + SessionArchiveError, + SessionArchiveExportInput, + SessionArchiveExportResult, + SessionArchiveReclaimInput, + SessionArchiveReclaimResult, + SessionArchiveScanResult, +} from "./sessionArchive.ts"; import { GitHubSourceControlProfile, SourceControlProfileArchiveInput, @@ -68,6 +92,18 @@ export const WS_FORK_METHODS = { subscribeServerResources: "subscribeServerResources", subscribeProviderRateLimits: "subscribeProviderRateLimits", linearIssuesResolve: "linearIssues.resolve", + planReviewGet: "planReview.get", + planReviewList: "planReview.list", + planReviewSaveDraft: "planReview.saveDraft", + planReviewCutVersion: "planReview.cutVersion", + planReviewUpsertDiscussion: "planReview.upsertDiscussion", + planReviewResolveDiscussion: "planReview.resolveDiscussion", + planReviewVersionDiff: "planReview.versionDiff", + planReviewSubmit: "planReview.submit", + subscribePlanReview: "subscribePlanReview", + sessionArchiveScan: "sessionArchive.scan", + sessionArchiveExport: "sessionArchive.export", + sessionArchiveReclaim: "sessionArchive.reclaim", } as const; export const WsPersonalMcpGetProfileRpc = Rpc.make(WS_FORK_METHODS.personalMcpGetProfile, { @@ -230,6 +266,25 @@ export const WsLinearIssuesResolveRpc = Rpc.make(WS_FORK_METHODS.linearIssuesRes error: EnvironmentAuthorizationError, }); +// T3-CUSTOM(expbkt3): archived-session worktree reclaim. +export const WsSessionArchiveScanRpc = Rpc.make(WS_FORK_METHODS.sessionArchiveScan, { + payload: Schema.Struct({}), + success: SessionArchiveScanResult, + error: Schema.Union([SessionArchiveError, EnvironmentAuthorizationError]), +}); + +export const WsSessionArchiveExportRpc = Rpc.make(WS_FORK_METHODS.sessionArchiveExport, { + payload: SessionArchiveExportInput, + success: SessionArchiveExportResult, + error: Schema.Union([SessionArchiveError, EnvironmentAuthorizationError]), +}); + +export const WsSessionArchiveReclaimRpc = Rpc.make(WS_FORK_METHODS.sessionArchiveReclaim, { + payload: SessionArchiveReclaimInput, + success: SessionArchiveReclaimResult, + error: Schema.Union([SessionArchiveError, EnvironmentAuthorizationError]), +}); + export const WsOrchestrationStopExecutionRpc = Rpc.make(ORCHESTRATION_WS_METHODS.stopExecution, { payload: OrchestrationRpcSchemas.stopExecution.input, success: OrchestrationRpcSchemas.stopExecution.output, @@ -242,7 +297,79 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS. error: Schema.Union([OrchestrationReplayEventsError, EnvironmentAuthorizationError]), }); +// T3-CUSTOM(expbkt3): native plan review. +export const WsPlanReviewGetRpc = Rpc.make(WS_FORK_METHODS.planReviewGet, { + payload: PlanReviewDocumentIdInput, + success: PlanReviewSnapshotResult, + error: Schema.Union([PlanReviewError, EnvironmentAuthorizationError]), +}); + +export const WsPlanReviewListRpc = Rpc.make(WS_FORK_METHODS.planReviewList, { + payload: PlanReviewListInput, + success: PlanReviewListResult, + error: Schema.Union([PlanReviewError, EnvironmentAuthorizationError]), +}); + +export const WsPlanReviewSaveDraftRpc = Rpc.make(WS_FORK_METHODS.planReviewSaveDraft, { + payload: PlanReviewSaveDraftInput, + success: PlanReviewSaveDraftResult, + error: Schema.Union([PlanReviewError, EnvironmentAuthorizationError]), +}); + +export const WsPlanReviewCutVersionRpc = Rpc.make(WS_FORK_METHODS.planReviewCutVersion, { + payload: PlanReviewCutVersionInput, + success: PlanReviewSnapshotResult, + error: Schema.Union([PlanReviewError, EnvironmentAuthorizationError]), +}); + +export const WsPlanReviewUpsertDiscussionRpc = Rpc.make( + WS_FORK_METHODS.planReviewUpsertDiscussion, + { + payload: PlanReviewUpsertDiscussionInput, + success: PlanReviewSnapshotResult, + error: Schema.Union([PlanReviewError, EnvironmentAuthorizationError]), + }, +); + +export const WsPlanReviewResolveDiscussionRpc = Rpc.make( + WS_FORK_METHODS.planReviewResolveDiscussion, + { + payload: PlanReviewResolveDiscussionInput, + success: PlanReviewSnapshotResult, + error: Schema.Union([PlanReviewError, EnvironmentAuthorizationError]), + }, +); + +export const WsPlanReviewVersionDiffRpc = Rpc.make(WS_FORK_METHODS.planReviewVersionDiff, { + payload: PlanReviewVersionDiffInput, + success: PlanReviewVersionDiffResult, + error: Schema.Union([PlanReviewError, EnvironmentAuthorizationError]), +}); + +export const WsPlanReviewSubmitRpc = Rpc.make(WS_FORK_METHODS.planReviewSubmit, { + payload: PlanReviewSubmitInput, + success: PlanReviewSubmitResult, + error: Schema.Union([PlanReviewError, EnvironmentAuthorizationError]), +}); + +/** Pushes a fresh snapshot whenever any client mutates the review. */ +export const WsSubscribePlanReviewRpc = Rpc.make(WS_FORK_METHODS.subscribePlanReview, { + payload: PlanReviewDocumentIdInput, + success: PlanReviewSnapshotResult, + error: Schema.Union([PlanReviewError, EnvironmentAuthorizationError]), + stream: true, +}); + export const FORK_WS_RPCS = [ + WsPlanReviewGetRpc, + WsPlanReviewListRpc, + WsPlanReviewSaveDraftRpc, + WsPlanReviewCutVersionRpc, + WsPlanReviewUpsertDiscussionRpc, + WsPlanReviewResolveDiscussionRpc, + WsPlanReviewVersionDiffRpc, + WsPlanReviewSubmitRpc, + WsSubscribePlanReviewRpc, WsPersonalMcpGetProfileRpc, WsPersonalMcpUpdateProfileRpc, WsPersonalMcpRotateTokenRpc, @@ -262,6 +389,9 @@ export const FORK_WS_RPCS = [ WsSubscribeServerResourcesRpc, WsSubscribeProviderRateLimitsRpc, WsLinearIssuesResolveRpc, + WsSessionArchiveScanRpc, + WsSessionArchiveExportRpc, + WsSessionArchiveReclaimRpc, WsOrchestrationStopExecutionRpc, WsOrchestrationReplayEventsRpc, ] as const; diff --git a/packages/contracts/src/sessionArchive.ts b/packages/contracts/src/sessionArchive.ts new file mode 100644 index 00000000000..2cef82736e4 --- /dev/null +++ b/packages/contracts/src/sessionArchive.ts @@ -0,0 +1,204 @@ +/** + * T3-CUSTOM(expbkt3): Reclaiming an archived session's worktree. + * + * Upstream removes a worktree only when a thread is *deleted*, so the only way + * to get the disk back is to destroy the session. These shapes describe the + * middle ground: report what an archived session still occupies, write its + * history somewhere durable, then give the space back while the thread stays + * readable. + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + IsoDateTime, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +// `SessionArchiveReclaimMode` lives in `settings.ts` because the auto-sweep +// setting stores one. It is re-exported from the package index there, so this +// module imports it rather than re-exporting and colliding with that barrel. +import { SessionArchiveReclaimMode } from "./settings.ts"; + +/** + * What is left of the worktree right now. + * + * `slimmed` is inferred, not recorded: a checkout whose regenerable directories + * are all absent looks exactly like one that was never built. Treating both as + * `slimmed` is honest about the only thing that matters here — there is nothing + * more to reclaim short of removing the worktree. + */ +export const SessionArchiveReclaimState = Schema.Literals([ + "present", + "slimmed", + "removed", + "missing", +]); +export type SessionArchiveReclaimState = typeof SessionArchiveReclaimState.Type; + +/** + * Why an entry cannot be reclaimed. Rendered verbatim in the panel, so each + * value has to read as a reason a person can act on. + */ +export const SessionArchiveBlockedReason = Schema.Literals([ + "not-archived", + "worktree-shared", + "worktree-live", + "retention-window", + "dirty-worktree", + "unpushed-commits", + "no-worktree", +]); +export type SessionArchiveBlockedReason = typeof SessionArchiveBlockedReason.Type; + +/** + * Gates an operator may deliberately override with `force`. + * + * These protect the operator's *own* uncommitted or unpushed work, so it is + * theirs to discard. Everything absent from this list protects something else — + * a worktree another live session is using, or one a running process sits in — + * and no flag overrides those. Shared between tiers so the panel cannot offer a + * force the server will refuse. + */ +export const FORCEABLE_BLOCKED_REASONS: ReadonlyArray<SessionArchiveBlockedReason> = [ + "dirty-worktree", + "unpushed-commits", +]; + +const forceableReasons = new Set<string>(FORCEABLE_BLOCKED_REASONS); + +export function isForceableBlockedReason( + reason: SessionArchiveBlockedReason | null, +): reason is SessionArchiveBlockedReason { + return reason !== null && forceableReasons.has(reason); +} + +export const SessionArchiveEntry = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + /** + * Display name of the owning project. + * + * Carried on the entry rather than looked up client-side: the archive can + * contain threads whose project the client no longer lists, and "select every + * worktree in this project" has to work for those too. + */ + projectName: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + title: Schema.String, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + archivedAt: Schema.NullOr(IsoDateTime), + /** Null when sizing was skipped or the path is gone, not zero — zero is a real size. */ + worktreeBytes: Schema.NullOr(NonNegativeInt), + /** How much of `worktreeBytes` a `slim` would give back. */ + reclaimableBytes: Schema.NullOr(NonNegativeInt), + reclaimState: SessionArchiveReclaimState, + /** Null means a slim is allowed; otherwise the first failing gate. */ + blockedReason: Schema.NullOr(SessionArchiveBlockedReason), + /** + * The same evaluation for `remove`, which gates harder than `slim`. + * + * Reported separately because a worktree with uncommitted work is perfectly + * fine to slim and refused for removal — collapsing the two would leave the + * panel unable to say which button will actually do anything. + */ + removeBlockedReason: Schema.NullOr(SessionArchiveBlockedReason).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + /** Set once a history export exists on disk. */ + historyPath: Schema.NullOr(Schema.String), +}); +export type SessionArchiveEntry = typeof SessionArchiveEntry.Type; + +/** + * A worktree directory with no thread pointing at it. + * + * Reported so an operator can see where the disk actually went, never reclaimed + * automatically: nothing in the database can vouch for what these contain. + */ +export const SessionArchiveOrphanedWorktree = Schema.Struct({ + worktreePath: Schema.String, + sizeBytes: Schema.NullOr(NonNegativeInt), + lastModifiedAt: Schema.NullOr(IsoDateTime), +}); +export type SessionArchiveOrphanedWorktree = typeof SessionArchiveOrphanedWorktree.Type; + +export const SessionArchiveScanResult = Schema.Struct({ + scannedAt: IsoDateTime, + entries: Schema.Array(SessionArchiveEntry), + orphanedWorktrees: Schema.Array(SessionArchiveOrphanedWorktree), + /** Sum over entries whose `blockedReason` is null. */ + totalReclaimableBytes: NonNegativeInt, + historyDir: Schema.String, + /** True when sizing hit its budget and some entries carry a null size. */ + sizingIncomplete: Schema.Boolean, +}); +export type SessionArchiveScanResult = typeof SessionArchiveScanResult.Type; + +export const SessionArchiveExportInput = Schema.Struct({ + threadIds: Schema.Array(ThreadId), +}); +export type SessionArchiveExportInput = typeof SessionArchiveExportInput.Type; + +export const SessionArchiveExportedFile = Schema.Struct({ + threadId: ThreadId, + digestPath: Schema.String, + transcriptPath: Schema.NullOr(Schema.String), + messageCount: NonNegativeInt, +}); +export type SessionArchiveExportedFile = typeof SessionArchiveExportedFile.Type; + +export const SessionArchiveExportResult = Schema.Struct({ + exported: Schema.Array(SessionArchiveExportedFile), + failures: Schema.Array( + Schema.Struct({ + threadId: ThreadId, + message: Schema.String, + }), + ), +}); +export type SessionArchiveExportResult = typeof SessionArchiveExportResult.Type; + +export const SessionArchiveReclaimInput = Schema.Struct({ + threadIds: Schema.Array(ThreadId), + mode: SessionArchiveReclaimMode, + /** + * Override the dirty-tree and unpushed-commit gates for `remove`. Never + * overrides the shared-worktree or live-worktree gates: those protect other + * sessions rather than the operator's own uncommitted work. + */ + force: Schema.Boolean, +}); +export type SessionArchiveReclaimInput = typeof SessionArchiveReclaimInput.Type; + +export const SessionArchiveReclaimOutcome = Schema.Struct({ + threadId: ThreadId, + reclaimed: Schema.Boolean, + mode: SessionArchiveReclaimMode, + freedBytes: NonNegativeInt, + /** Null on success; a blocked gate or a failure message otherwise. */ + skippedReason: Schema.NullOr(Schema.String), + digestPath: Schema.NullOr(Schema.String), +}); +export type SessionArchiveReclaimOutcome = typeof SessionArchiveReclaimOutcome.Type; + +export const SessionArchiveReclaimResult = Schema.Struct({ + outcomes: Schema.Array(SessionArchiveReclaimOutcome), + totalFreedBytes: NonNegativeInt, +}); +export type SessionArchiveReclaimResult = typeof SessionArchiveReclaimResult.Type; + +/** + * Whole-request failure only. A single thread that could not be reclaimed comes + * back as a `skippedReason` on its outcome, so one bad entry never fails the + * batch the operator selected. + */ +export class SessionArchiveError extends Schema.TaggedErrorClass<SessionArchiveError>()( + "SessionArchiveError", + { + operation: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, + }, +) {} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 60c40b4949b..30d9d050ba0 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -189,6 +189,9 @@ export const ClientSettingsSchema = Schema.Struct({ // default UI; this beta flag restores it (plus the /plan and /default slash // commands) for users who still rely on the old workflow. planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // T3-CUSTOM(expbkt3): native plan review. On by default; turning it off hides + // the Preview entry points and leaves Plannotator as the only review path. + nativePlanReviewEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -541,6 +544,35 @@ export const SessionSummarySettings = Schema.Struct({ }); export type SessionSummarySettings = typeof SessionSummarySettings.Type; +/** + * T3-CUSTOM(expbkt3): BEGIN — Bulk session manager work summaries. + * + * Deliberately a peer of `SessionSummarySettings` rather than a reuse of it. + * The catch-up note answers "what just happened in this turn" for one open + * session; the work summary answers "what has this session achieved and how far + * is it" for thirty sessions at once. Different reader, different prompt, and + * different cost profile — so it gets its own model, character budget, and + * prompt instructions instead of inheriting the catch-up ones. + */ +export const SessionWorkSummarySettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + modelSelection: ModelSelection.pipe( + Schema.withDecodingDefault( + Effect.succeed({ + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_TEXT_GENERATION_MODEL, + }), + ), + ), + dataLimitChars: SessionSummaryDataLimitChars.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SESSION_SUMMARY_DATA_LIMIT_CHARS)), + ), + // Appended to the work-summary prompt. Empty means "use the built-in prompt". + promptInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), +}); +export type SessionWorkSummarySettings = typeof SessionWorkSummarySettings.Type; +// T3-CUSTOM(expbkt3): END + export const ExternalMcpSettings = Schema.Struct({ enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), apiKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -599,14 +631,70 @@ export const ThreadTitleMaintenanceSettings = Schema.Struct({ }); export type ThreadTitleMaintenanceSettings = typeof ThreadTitleMaintenanceSettings.Type; +/** + * T3-CUSTOM(expbkt3): How much of an archived session's worktree to give back. + * + * `slim` deletes only regenerable directories (`node_modules`, build output, + * caches) and leaves a usable checkout behind. `remove` runs + * `git worktree remove`, which reclaims everything but means reopening the + * session has to re-create the worktree first. + */ +export const SessionArchiveReclaimMode = Schema.Literals(["slim", "remove"]); +export type SessionArchiveReclaimMode = typeof SessionArchiveReclaimMode.Type; + +/** Days an archived thread must sit untouched before the sweeper may reclaim it. */ +export const SessionArchiveMinArchivedDays = Schema.Int.check( + Schema.isBetween({ minimum: 0, maximum: 365 }), +); + +export const DEFAULT_SESSION_ARCHIVE_MIN_ARCHIVED_DAYS = 14; + +/** + * T3-CUSTOM(expbkt3): Reclaim archived sessions' worktrees without losing what + * the session did. + * + * Upstream only removes a worktree when a thread is *deleted*, so the sole way + * to get the disk back is to destroy the history. Archived worktrees therefore + * accumulate indefinitely. This exports a durable history file pair (digest + * Markdown plus a full transcript sidecar) outside the worktree first, then + * reclaims the worktree per `SessionArchiveReclaimMode`. + * + * `historyDir` is blank by default, meaning `<baseDir>/session-history`. + * `autoSweep` is off by default: the panel drives this by hand until an + * operator opts into the timer. + */ +export const SessionArchiveAutoSweepSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + mode: SessionArchiveReclaimMode.pipe(Schema.withDecodingDefault(Effect.succeed("slim" as const))), + minArchivedDays: SessionArchiveMinArchivedDays.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SESSION_ARCHIVE_MIN_ARCHIVED_DAYS)), + ), +}); +export type SessionArchiveAutoSweepSettings = typeof SessionArchiveAutoSweepSettings.Type; + +export const SessionArchiveSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + historyDir: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + includeTranscriptSidecar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + autoSweep: SessionArchiveAutoSweepSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), +}); +export type SessionArchiveSettings = typeof SessionArchiveSettings.Type; + export const ExperimentalSettings = Schema.Struct({ sessionSummary: SessionSummarySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summaries. + sessionWorkSummary: SessionWorkSummarySettings.pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + // T3-CUSTOM(expbkt3): END externalMcp: ExternalMcpSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), t3Conductor: T3ConductorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), // T3-CUSTOM(expbkt3): periodic title refresh. threadTitleMaintenance: ThreadTitleMaintenanceSettings.pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + // T3-CUSTOM(expbkt3): archived-session worktree reclaim. + sessionArchive: SessionArchiveSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ExperimentalSettings = typeof ExperimentalSettings.Type; @@ -940,6 +1028,32 @@ export const ServerSettingsPatch = Schema.Struct({ promptInstructions: Schema.optionalKey(TrimmedString), }), ), + // T3-CUSTOM(expbkt3): archived-session worktree reclaim. + sessionArchive: Schema.optionalKey( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + historyDir: Schema.optionalKey(TrimmedString), + includeTranscriptSidecar: Schema.optionalKey(Schema.Boolean), + autoSweep: Schema.optionalKey( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + mode: Schema.optionalKey(SessionArchiveReclaimMode), + minArchivedDays: Schema.optionalKey(SessionArchiveMinArchivedDays), + }), + ), + }), + ), + // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summaries are patched + // independently of the catch-up summary block. + sessionWorkSummary: Schema.optionalKey( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + modelSelection: Schema.optionalKey(ModelSelectionPatch), + dataLimitChars: Schema.optionalKey(SessionSummaryDataLimitChars), + promptInstructions: Schema.optionalKey(TrimmedString), + }), + ), + // T3-CUSTOM(expbkt3): END }), ), providers: Schema.optionalKey( @@ -999,6 +1113,8 @@ export const ClientSettingsPatch = Schema.Struct({ providerRateLimitsEnabled: Schema.optionalKey(Schema.Boolean), resourceMonitorEnabled: Schema.optionalKey(Schema.Boolean), planModeEnabled: Schema.optionalKey(Schema.Boolean), + // T3-CUSTOM(expbkt3): native plan review. + nativePlanReviewEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( diff --git a/packages/shared/package.json b/packages/shared/package.json index 6ac78f5b83f..3e4d4a2ea8d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -211,6 +211,10 @@ "types": "./src/plannotator.ts", "import": "./src/plannotator.ts" }, + "./planReview": { + "types": "./src/planReview.ts", + "import": "./src/planReview.ts" + }, "./devHome": { "types": "./src/devHome.ts", "import": "./src/devHome.ts" diff --git a/packages/shared/src/planReview.test.ts b/packages/shared/src/planReview.test.ts new file mode 100644 index 00000000000..bd3391a2947 --- /dev/null +++ b/packages/shared/src/planReview.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildPlanReviewApprovalPrompt, + buildPlanReviewFeedbackPrompt, + formatPlanReviewComment, + locateQuotedLineRange, + planReviewFence, +} from "./planReview.ts"; + +const PLAN = [ + "# Auth rewrite", + "", + "## Steps", + "", + "1. Add the migration", + "2. Backfill the rows", + "3. Flip the flag", +].join("\n"); + +describe("locateQuotedLineRange", () => { + it("finds a single quoted line", () => { + expect(locateQuotedLineRange(PLAN, "2. Backfill the rows")).toEqual({ + startIndex: 5, + endIndex: 5, + }); + }); + + it("finds a multi-line quote", () => { + expect(locateQuotedLineRange(PLAN, "1. Add the migration\n2. Backfill the rows")).toEqual({ + startIndex: 4, + endIndex: 5, + }); + }); + + it("tolerates blank lines inside the document between quoted lines", () => { + expect(locateQuotedLineRange(PLAN, "# Auth rewrite\n## Steps")).toEqual({ + startIndex: 0, + endIndex: 2, + }); + }); + + it("returns null when the quote is absent", () => { + expect(locateQuotedLineRange(PLAN, "4. Delete production")).toBeNull(); + }); + + it("returns null for an empty quote", () => { + expect(locateQuotedLineRange(PLAN, " \n ")).toBeNull(); + }); +}); + +describe("planReviewFence", () => { + it("uses a longer fence when the content contains backticks", () => { + const fenced = planReviewFence("markdown", "use ``` for code"); + expect(fenced.startsWith("````markdown")).toBe(true); + expect(fenced.endsWith("````")).toBe(true); + }); +}); + +describe("formatPlanReviewComment", () => { + it("emits a review_comment block the existing transcript parser understands", () => { + const block = formatPlanReviewComment("doc-1", "Auth rewrite", { + startIndex: 4, + endIndex: 5, + quotedText: "1. Add the migration\n2. Backfill the rows", + body: "Split this into two migrations.", + authorLabel: "Tushar", + }); + + expect(block).toContain('sectionId="plan:doc-1"'); + expect(block).toContain('filePath="Auth rewrite.md"'); + expect(block).toContain('startIndex="4"'); + expect(block).toContain('endIndex="5"'); + expect(block).toContain('rangeLabel="L5 to L6"'); + expect(block).toContain("— Tushar"); + expect(block.startsWith("<review_comment ")).toBe(true); + expect(block.endsWith("</review_comment>")).toBe(true); + }); + + it("labels a single-line anchor without a range", () => { + const block = formatPlanReviewComment("doc-1", "Plan", { + startIndex: 2, + endIndex: 2, + quotedText: "## Steps", + body: "Rename this heading.", + authorLabel: null, + }); + expect(block).toContain('rangeLabel="L3"'); + expect(block).not.toContain("—"); + }); + + it("omits the range when the quote could not be located", () => { + const block = formatPlanReviewComment("doc-1", "Plan", { + startIndex: null, + endIndex: null, + quotedText: "a line that moved", + body: "Reword this.", + authorLabel: null, + }); + expect(block).not.toContain("startIndex="); + expect(block).not.toContain("endIndex="); + expect(block).toContain('rangeLabel="quoted text"'); + expect(block).toContain("a line that moved"); + }); + + it("escapes quotes in the plan title", () => { + const block = formatPlanReviewComment("doc-1", 'The "big" rewrite', { + startIndex: 0, + endIndex: 0, + quotedText: "x", + body: "y", + authorLabel: null, + }); + expect(block).toContain('filePath="The "big" rewrite.md"'); + }); +}); + +describe("buildPlanReviewFeedbackPrompt", () => { + const base = { + documentId: "doc-1", + planTitle: "Auth rewrite", + globalComment: "", + comments: [], + editDiff: "", + fromRevision: null, + toRevision: null, + editAuthorLabel: null, + fullDocument: null, + }; + + it("never includes the plan body", () => { + const prompt = buildPlanReviewFeedbackPrompt({ + ...base, + globalComment: "Too broad.", + comments: [ + { + startIndex: 4, + endIndex: 4, + quotedText: "1. Add the migration", + body: "Split this.", + authorLabel: null, + }, + ], + }); + + expect(prompt).toContain("Revise the plan you proposed."); + expect(prompt).toContain("Too broad."); + expect(prompt).toContain("<review_comment "); + expect(prompt).not.toContain("3. Flip the flag"); + }); + + it("attaches reviewer edits as a diff with version attribution", () => { + const prompt = buildPlanReviewFeedbackPrompt({ + ...base, + editDiff: "@@ -1,1 +1,1 @@\n-old\n+new", + fromRevision: 1, + toRevision: 2, + editAuthorLabel: "Tushar", + }); + + expect(prompt).toContain('<plan_edit filePath="Auth rewrite.md" fromVersion="1" toVersion="2"'); + expect(prompt).toContain('author="Tushar"'); + expect(prompt).toContain("```diff"); + }); + + it("falls back to the full document when the edit rewrote most of the plan", () => { + const prompt = buildPlanReviewFeedbackPrompt({ + ...base, + editDiff: "@@ -1,1 +1,1 @@\n-old\n+new", + fullDocument: "# Rewritten\n\nEverything changed.", + }); + + expect(prompt).toContain('mode="full"'); + expect(prompt).toContain("# Rewritten"); + expect(prompt).not.toContain("```diff"); + }); + + it("omits empty sections", () => { + const prompt = buildPlanReviewFeedbackPrompt(base); + expect(prompt).toBe( + "Revise the plan you proposed. Respond with the complete revised plan.\nDo not modify repository files while planning.", + ); + }); +}); + +describe("buildPlanReviewApprovalPrompt", () => { + it("sends one short line when the plan is still in context", () => { + const prompt = buildPlanReviewApprovalPrompt({ + notes: "", + resendPlanMarkdown: null, + resendReason: null, + approvedEditDiff: "", + }); + + expect(prompt).toBe( + "Plan approved. Implement the plan you proposed above, exactly as written.", + ); + }); + + it("appends reviewer notes without the plan body", () => { + const prompt = buildPlanReviewApprovalPrompt({ + notes: "Start with the migration.", + resendPlanMarkdown: null, + resendReason: null, + approvedEditDiff: "", + }); + + expect(prompt).toContain("Reviewer notes:\nStart with the migration."); + expect(prompt).not.toContain("PLEASE IMPLEMENT"); + }); + + it("carries the edit diff when the reviewer changed the plan before approving", () => { + const prompt = buildPlanReviewApprovalPrompt({ + notes: "", + resendPlanMarkdown: null, + resendReason: null, + approvedEditDiff: "@@ -1,1 +1,1 @@\n-old\n+new", + }); + + expect(prompt).toContain("The reviewer edited the plan before approving."); + expect(prompt).toContain("```diff"); + }); + + it("repeats the plan and explains why when the policy demands it", () => { + const prompt = buildPlanReviewApprovalPrompt({ + notes: "", + resendPlanMarkdown: PLAN, + resendReason: "this session compacted its context after the plan was written", + approvedEditDiff: "", + }); + + expect(prompt).toContain("PLEASE IMPLEMENT THIS APPROVED PLAN:"); + expect(prompt).toContain("3. Flip the flag"); + expect(prompt).toContain("(The full plan is repeated because this session compacted"); + }); +}); diff --git a/packages/shared/src/planReview.ts b/packages/shared/src/planReview.ts new file mode 100644 index 00000000000..f8e3d2a55f3 --- /dev/null +++ b/packages/shared/src/planReview.ts @@ -0,0 +1,231 @@ +/** + * T3-CUSTOM(expbkt3): shared, dependency-free plan-review prompt format. + * + * The server builds these blocks and the web transcript parses them, so the + * format lives here rather than in either app. `<review_comment>` deliberately + * matches the existing file/diff review format in + * `apps/web/src/reviewCommentContext.ts` — that parser already renders these + * blocks as cards, so anchored plan feedback needs no new transcript UI. + */ + +export interface PlanReviewAnchoredComment { + /** + * 0-based inclusive line indices into the reviewed markdown, or null when the + * quote could not be located. Null omits the range attributes entirely rather + * than claiming line 1, which would point the model at the wrong place. + */ + readonly startIndex: number | null; + readonly endIndex: number | null; + /** The lines the reviewer selected, quoted back for the model. */ + readonly quotedText: string; + /** The reviewer's note. */ + readonly body: string; + readonly authorLabel: string | null; +} + +export interface PlanReviewFeedbackInput { + readonly documentId: string; + readonly planTitle: string; + readonly globalComment: string; + readonly comments: ReadonlyArray<PlanReviewAnchoredComment>; + /** Unified diff of reviewer edits, empty when the plan was not edited. */ + readonly editDiff: string; + readonly fromRevision: number | null; + readonly toRevision: number | null; + readonly editAuthorLabel: string | null; + /** + * Set when the edit was too large to express as a diff. The full document is + * sent instead, and the UI says so. + */ + readonly fullDocument: string | null; +} + +export interface PlanReviewApprovalInput { + readonly notes: string; + /** + * Full plan body. Only present when the policy decided the model cannot see + * the plan any more — normally null, which is the whole point of the feature. + */ + readonly resendPlanMarkdown: string | null; + readonly resendReason: string | null; + /** Diff from the agent's last version to the approved one, when edited. */ + readonly approvedEditDiff: string; +} + +function escapeAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +/** Picks a fence long enough to survive backticks inside the content. */ +export function planReviewFence(language: string, contents: string): string { + const longestRun = Math.max( + 0, + ...Array.from(contents.matchAll(/`+/g), (match) => match[0].length), + ); + const fence = "`".repeat(Math.max(3, longestRun + 1)); + return [`${fence}${language}`, contents.trimEnd(), fence].join("\n"); +} + +function rangeLabel(startIndex: number, endIndex: number): string { + const start = startIndex + 1; + const end = endIndex + 1; + return start === end ? `L${start}` : `L${start} to L${end}`; +} + +export function formatPlanReviewComment( + documentId: string, + planTitle: string, + comment: PlanReviewAnchoredComment, +): string { + const located = comment.startIndex !== null && comment.endIndex !== null; + const attributes = [ + `sectionId="${escapeAttribute(`plan:${documentId}`)}"`, + `sectionTitle="${escapeAttribute("Plan review")}"`, + `filePath="${escapeAttribute(`${planTitle}.md`)}"`, + ...(located + ? [ + `startIndex="${comment.startIndex}"`, + `endIndex="${comment.endIndex}"`, + `rangeLabel="${escapeAttribute(rangeLabel(comment.startIndex!, comment.endIndex!))}"`, + ] + : [`rangeLabel="${escapeAttribute("quoted text")}"`]), + ].join(" "); + + const body = comment.authorLabel + ? `${comment.body.trim()}\n\n— ${comment.authorLabel}` + : comment.body.trim(); + + return [ + `<review_comment ${attributes}>`, + body, + planReviewFence("markdown", comment.quotedText), + "</review_comment>", + ].join("\n"); +} + +const FEEDBACK_HEADER = [ + "Revise the plan you proposed. Respond with the complete revised plan.", + "Do not modify repository files while planning.", +].join("\n"); + +/** + * Builds the revision prompt. The plan body is deliberately absent — the model + * authored it and still has it in context; only the deltas are sent. + */ +export function buildPlanReviewFeedbackPrompt(input: PlanReviewFeedbackInput): string { + const sections: string[] = [FEEDBACK_HEADER]; + + const globalComment = input.globalComment.trim(); + if (globalComment.length > 0) sections.push(globalComment); + + for (const comment of input.comments) { + sections.push(formatPlanReviewComment(input.documentId, input.planTitle, comment)); + } + + if (input.fullDocument !== null) { + sections.push( + [ + `<plan_edit filePath="${escapeAttribute(`${input.planTitle}.md`)}" mode="full">`, + "The reviewer's edits changed most of the plan, so the full edited document follows.", + planReviewFence("markdown", input.fullDocument), + "</plan_edit>", + ].join("\n"), + ); + } else if (input.editDiff.trim().length > 0) { + const attributes = [ + `filePath="${escapeAttribute(`${input.planTitle}.md`)}"`, + ...(input.fromRevision !== null ? [`fromVersion="${input.fromRevision}"`] : []), + ...(input.toRevision !== null ? [`toVersion="${input.toRevision}"`] : []), + ...(input.editAuthorLabel !== null + ? [`author="${escapeAttribute(input.editAuthorLabel)}"`] + : []), + ].join(" "); + sections.push( + [`<plan_edit ${attributes}>`, planReviewFence("diff", input.editDiff), "</plan_edit>"].join( + "\n", + ), + ); + } + + return sections.join("\n\n"); +} + +/** + * Builds the approval prompt. Normally one line: the plan is already in the + * model's context, so re-sending it is pure waste. + */ +export function buildPlanReviewApprovalPrompt(input: PlanReviewApprovalInput): string { + const sections: string[] = []; + const notes = input.notes.trim(); + + if (input.resendPlanMarkdown !== null) { + sections.push("PLEASE IMPLEMENT THIS APPROVED PLAN:"); + sections.push(input.resendPlanMarkdown.trim()); + if (input.resendReason !== null) { + sections.push(`(The full plan is repeated because ${input.resendReason}.)`); + } + } else { + sections.push("Plan approved. Implement the plan you proposed above, exactly as written."); + if (input.approvedEditDiff.trim().length > 0) { + sections.push( + [ + "The reviewer edited the plan before approving. Apply these changes to it:", + planReviewFence("diff", input.approvedEditDiff), + ].join("\n"), + ); + } + } + + if (notes.length > 0) { + sections.push(`Reviewer notes:\n${notes}`); + } + + return sections.join("\n\n"); +} + +/** + * Locates a quoted excerpt in the reviewed markdown and returns its 0-based + * inclusive line range. Plate anchors comments to document marks, so line + * numbers are derived at submit time from the quoted text. + */ +export function locateQuotedLineRange( + markdown: string, + quotedText: string, +): { readonly startIndex: number; readonly endIndex: number } | null { + const quoteLines = quotedText + .replaceAll("\r\n", "\n") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (quoteLines.length === 0) return null; + + const documentLines = markdown.replaceAll("\r\n", "\n").split("\n"); + const trimmedDocument = documentLines.map((line) => line.trim()); + + for (let start = 0; start < trimmedDocument.length; start += 1) { + if (!trimmedDocument[start]?.includes(quoteLines[0]!)) continue; + + let matched = 1; + let cursor = start + 1; + while (matched < quoteLines.length && cursor < trimmedDocument.length) { + // Blank lines in the document do not break a multi-line quote match. + if (trimmedDocument[cursor] === "") { + cursor += 1; + continue; + } + if (!trimmedDocument[cursor]!.includes(quoteLines[matched]!)) break; + matched += 1; + cursor += 1; + } + + if (matched === quoteLines.length) { + return { startIndex: start, endIndex: cursor - 1 }; + } + } + + return null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32e02ffd69d..df98531e015 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,7 +513,7 @@ importers: dependencies: '@base-ui/react': specifier: ^1.4.1 - version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.0(@types/react@19.2.16)(date-fns@4.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/clerk-js': specifier: 6.25.12 version: 6.25.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -543,7 +543,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -553,6 +553,30 @@ importers: '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@platejs/basic-nodes': + specifier: 53.0.0 + version: 53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@platejs/code-block': + specifier: 53.0.0 + version: 53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@platejs/comment': + specifier: 53.0.0 + version: 53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@platejs/link': + specifier: 53.3.1 + version: 53.3.1(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@platejs/list-classic': + specifier: 53.0.0 + version: 53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@platejs/markdown': + specifier: 53.3.3 + version: 53.3.3(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) + '@platejs/suggestion': + specifier: 53.2.3 + version: 53.2.3(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@platejs/table': + specifier: 53.0.9 + version: 53.0.9(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -571,6 +595,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) @@ -583,6 +610,12 @@ importers: lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.6) + mermaid: + specifier: 11.16.1 + version: 11.16.1 + platejs: + specifier: 53.3.3 + version: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) react: specifier: 19.2.6 version: 19.2.6 @@ -609,7 +642,7 @@ importers: version: 3.6.0 zustand: specifier: ^5.0.11 - version: 5.0.14(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) + version: 5.0.14(@types/react@19.2.16)(immer@10.2.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 @@ -934,6 +967,9 @@ packages: '@alchemy.run/node-utils@0.0.5': resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170': resolution: {integrity: sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg==} cpu: [arm64] @@ -1614,6 +1650,9 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bruits/satteri-darwin-arm64@0.9.3': resolution: {integrity: sha512-dRUZZrdwh1asfTOyM1nDNmzolhnHtlIFpqYrl1Tdd3YVcaebKmrfJgGL7NAoGPjbEwYmZxaugrxA0uzw83c0dw==} cpu: [arm64] @@ -1673,6 +1712,9 @@ packages: resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@clack/core@0.5.0': resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} @@ -2698,6 +2740,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/react@0.27.19': resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} peerDependencies: @@ -2719,6 +2767,12 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -2949,6 +3003,9 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} + '@juggle/resize-observer@3.4.0': + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + '@legendapp/list@3.2.0': resolution: {integrity: sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg==} peerDependencies: @@ -3112,6 +3169,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -3618,6 +3678,105 @@ packages: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 + '@platejs/basic-nodes@53.0.0': + resolution: {integrity: sha512-ZL5ULASsWVTcKfBgIHoloh5aa8ZmFKEFef+j9fN2/CSCElU4nadndv9uxb7gor+RowzCBfxH8cLT9RGV7Z6b6A==} + peerDependencies: + platejs: '>=53.0.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/code-block@53.0.0': + resolution: {integrity: sha512-KOO3wUUVZxgpA8R+pc8PwAGirUtzl233//jHI2H9Icc6blUk0sQWiXTkvCdAvTeZAS+eclNW2ER7KRa+FoiC6w==} + peerDependencies: + platejs: '>=53.0.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/comment@53.0.0': + resolution: {integrity: sha512-GjOqAXx7ZsN4QUBVPUamic6y+cUycPUoaeOqa3obhNBFlN+zBrhwy5Kp+sdltj9Z4j98CuJhm8uPgyDo7jXXwA==} + peerDependencies: + platejs: '>=53.0.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/core@53.3.3': + resolution: {integrity: sha512-WS9drXKef/3eShuf0Psdoga3J+b8fGxXD9FHzAEeqjkDrD4JyHpjsO2ZP++vA7K6uFyAZL1OM1ai+hKYiNV0ow==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/date@53.0.0': + resolution: {integrity: sha512-XQ28bUElHwCYxHbVmwc0gMrl/PKA1KwlTQU/nksWKhOETqQm89biirXS+J3/038EimhsAEPyMLlYntm38Uo65Q==} + peerDependencies: + platejs: '>=53.0.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/diff@53.0.0': + resolution: {integrity: sha512-OldWJ0SYMqy6GcTaqA/oHhdshwbcQue9bIPnRZMoCET+svo/COu3gXHdEfWJZ7ueHZwYA8zrHAiw5amRgZB6wg==} + peerDependencies: + platejs: '>=53.0.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/floating@53.0.0': + resolution: {integrity: sha512-malVGWRgpoVNxxBkn+8722KqrleB/rhbXzuToijjymTeD1FRyBbvSP5ANtg1LTRus3ByMUZCn/gbquIe+RXcUQ==} + peerDependencies: + platejs: '>=53.0.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/link@53.3.1': + resolution: {integrity: sha512-qoQ2di0kOAargfSYAyybtQeAJxK3rQkXdaD9enVj3gfPy7QyG068BKvwPomdqdLeMlPqi+XUH55lnstkpHfnzA==} + peerDependencies: + platejs: '>=53.0.3' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/list-classic@53.0.0': + resolution: {integrity: sha512-2L7QYfBswIZiqQKviiHl1vzNQPoyJr7rihXGgNKpXxt4sGtbNN6Av8FxvDRXmymU2sa12I+H+5Y3jFyYmEsciA==} + peerDependencies: + platejs: '>=53.0.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/markdown@53.3.3': + resolution: {integrity: sha512-sdTLEG/gpyNJQUFKzcAs6naUNnaESloWakL3u4PRkmSIYTWAxMBN9CI9WNDVKwu/6Ccw0uirQyCDZj0gE7X47Q==} + peerDependencies: + platejs: '>=53.3.3' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/resizable@53.0.0': + resolution: {integrity: sha512-/Wl8DmjByOTib+wKAcEnE3S3PMcpKcrupCOJvUa+vnn/cqXV3cwWzapVZ3bojF6sFWttMsLprvIGhpS/gpl5FQ==} + peerDependencies: + platejs: '>=53.0.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/slate@53.3.3': + resolution: {integrity: sha512-CecdyGccIujkpACa72YuGBOLK5oRzpow0ptGp4fBWNlbh6qmPPAfzja49zVYvy8d5RaJNtua1rv0qllH3ysVnw==} + + '@platejs/suggestion@53.2.3': + resolution: {integrity: sha512-SMBe0dU2KYfP2ri+BN49JaY4dz4xoGRKuNdF3YBB1pvbf3Ho58BsNuSHkhkr6QCzNDZUY4il4ACasOeSbeSjeA==} + peerDependencies: + platejs: '>=53.0.3' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/table@53.0.9': + resolution: {integrity: sha512-fie96c0AE8bShfog+SA767rbf/1i5YYAxZFndETCNkXxT+nzc0GKaVyEOkSaVBtwq9OBqHfD/t4ITpadbrTa0Q==} + peerDependencies: + platejs: '>=53.0.7' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@platejs/utils@53.3.3': + resolution: {integrity: sha512-Ou7rbaufTaYIkQq5vs1NH7dlwKps8Pzne3QSpnZD0PSdefN4mSwa01OydHgGBCUyzDxzTmxn0L7dxiL4bFyLLA==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -4792,6 +4951,99 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -4810,6 +5062,9 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hammerjs@2.0.46': resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} @@ -4866,6 +5121,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -4937,9 +5195,27 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + '@udecode/react-hotkeys@52.0.11': + resolution: {integrity: sha512-MwdQlfTZhrP0O+BazuNgY9g2qJSZAB05ykdVCdWmBsjv5VQvQ3R9RkXLUMGD20TqUR62iT/Phb/ca8toxiCm0w==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@udecode/react-utils@52.3.4': + resolution: {integrity: sha512-qN95XzH6OlzJ9UjWVhvOuLJoDrFwhBOk2itYAmSxsfdMFxF3A+SzYes4gj05Udv2kVZsRd3eAgvn/Znd9EYmJw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@udecode/utils@52.3.4': + resolution: {integrity: sha512-I1l2FL+FacNitwSgOA3Gh/PoyP1FS7t5bmEURxClqKA2/1rtdovZ9O61qRYN9mdABTJBTyn5BxKDlwp98QvqlQ==} + '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/config@0.3.0': resolution: {integrity: sha512-Tf5k5y2F478oTiQcU5R8Ntix1UejE6NdduZnI7aa1XXxjCtifX1XdRS/D2uTjiQAwIL3pLa1LSAN80ABbba+TQ==} hasBin: true @@ -5234,6 +5510,11 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -5855,6 +6136,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -5875,6 +6160,9 @@ packages: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -5936,6 +6224,12 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -5988,6 +6282,168 @@ packages: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -6050,6 +6506,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -6093,6 +6552,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff-match-patch-ts@0.6.0: + resolution: {integrity: sha512-U0uPIJ+wJqgaBoVw2MFSFpGIk7q3mJJ+/sehbxDZFv4Gx6a1GOmrsSLmxVDDrGtRL4Q9de084aa5lVpCHn+eUw==} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -6104,6 +6566,10 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + direction@1.0.4: + resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==} + hasBin: true + dmg-builder@26.15.6: resolution: {integrity: sha512-nr5vQxEhM0REomp1qiHbc6V99yrfBZy+wUU56VXADfSOlLj8PdLqsHiRe7b+FbqKesiyv4ax+k1GVwGonYKuCg==} @@ -6126,6 +6592,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -6452,6 +6921,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -7076,6 +7548,9 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -7168,6 +7643,9 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -7199,6 +7677,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -7222,6 +7704,12 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -7257,6 +7745,13 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -7325,6 +7820,9 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-hotkey@0.2.0: + resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==} + is-in-ci@2.0.0: resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} engines: {node: '>=20'} @@ -7354,6 +7852,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -7435,6 +7937,36 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jotai-optics@0.4.0: + resolution: {integrity: sha512-osbEt9AgS55hC4YTZDew2urXKZkaiLmLqkTS/wfW5/l0ib8bmmQ7kBXSFaosV6jDDWSp00IipITcJARFHdp42g==} + peerDependencies: + jotai: '>=2.0.0' + optics-ts: '>=2.0.0' + + jotai-x@2.3.4: + resolution: {integrity: sha512-FirSJ2ZwJtWXpg1EP5iTZdXZE4ilXUvHnMR6DqvSfUuDADl2LNEvFBjdenyxmUdE5Fqru6qLrArw2MMRAFFIdA==} + peerDependencies: + '@types/react': '>=17.0.0' + jotai: '>=2.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + + jotai@2.8.4: + resolution: {integrity: sha512-f6jwjhBJcDtpeauT2xH01gnqadKEySwwt1qNBLvAXcnojkmb76EdqRt05Ym8IamfHGAQz2qMKAwftnyjeSoHAA==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -7505,9 +8037,16 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -7523,6 +8062,12 @@ packages: resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} hasBin: true + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -7775,6 +8320,9 @@ packages: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -7785,6 +8333,9 @@ packages: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.mapvalues@4.6.0: + resolution: {integrity: sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==} + lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} @@ -7855,6 +8406,16 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -7893,12 +8454,18 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} mdast-util-mdx-jsx@3.2.0: resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} @@ -7947,6 +8514,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.16.1: + resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==} + metro-babel-transformer@0.84.4: resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -8029,12 +8599,30 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} micromark-factory-label@2.0.1: resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + micromark-factory-space@2.0.1: resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} @@ -8065,6 +8653,9 @@ packages: micromark-util-encode@2.0.1: resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + micromark-util-html-tag-name@2.0.1: resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} @@ -8253,6 +8844,10 @@ packages: multitars@1.0.0: resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==} + mutative@1.1.0: + resolution: {integrity: sha512-2PJADREjOusk3iJkD3rXV2YjAxTuaLxdfqtqTEt6vcY07LtEBR1seHuBHXWEIuscqRDGvbauYPs+A4Rj/KTczQ==} + engines: {node: '>=14.0'} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -8272,6 +8867,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -8455,6 +9055,9 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} + optics-ts@2.4.1: + resolution: {integrity: sha512-HaYzMHvC80r7U/LqAd4hQyopDezC60PO2qF5GuIwALut2cl5rK1VWHsqTp0oqoJJWjiv6uXKqsO+Q2OO0C3MmQ==} + ora@3.4.0: resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} engines: {node: '>=6'} @@ -8558,6 +9161,9 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -8685,6 +9291,12 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + platejs@53.3.3: + resolution: {integrity: sha512-LZJTVYXkx4inwCFjHzeFRlaadb7gKXR5LLOB4t7Y2a+XxmDqc8dSyC4pk45xth6ENTFtWE8sceH7HiljgW1dDg==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} @@ -8706,6 +9318,12 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -8822,6 +9440,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-compare@2.6.0: + resolution: {integrity: sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -8868,6 +9489,11 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-compiler-runtime@1.0.0: + resolution: {integrity: sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental + react-devtools-core@6.1.5: resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} @@ -9079,9 +9705,22 @@ packages: '@types/react': optional: true - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} - engines: {node: '>=0.10.0'} + react-tracked@1.7.14: + resolution: {integrity: sha512-6UMlgQeRAGA+uyYzuQGm7kZB6ZQYFhc7sntgP7Oxwwd6M0Ud/POyb4K3QWT1eXvoifSa80nrAWnXWFGpOvbwkw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '*' + react-native: '*' + scheduler: '>=0.19.0' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} @@ -9168,6 +9807,9 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -9267,6 +9909,9 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.0.0-rc.17: resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9282,6 +9927,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -9289,6 +9937,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -9315,6 +9966,9 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -9467,6 +10121,27 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slate-dom@0.126.0: + resolution: {integrity: sha512-FdRlaKh0xksnymiRiv8/8c6dYhybwTg1QJIjmvEyeOUKHfdJI55YA4gGKv4vQj4ZKklau2aHY6uX/npJu5jYMA==} + peerDependencies: + slate: '>=0.121.0' + + slate-hyperscript@0.125.0: + resolution: {integrity: sha512-tH6AYvTrTnSdUo+L5cUvqcHRzQ3muEkcSEN7APM9NtEdWk9L7lqEbmpyeKfv7oyzCUW2WbR/lPhlnOSu8/jUcA==} + peerDependencies: + slate: '>=0.114.3' + + slate-react@0.126.0: + resolution: {integrity: sha512-pQEu2X25mj539jWK/GpwNEdfE/gwqRccDwQaf4UeoABX3+cLaDls8CIyT8lASGRHyphqkgGW7pUxWOCBGC2hqA==} + peerDependencies: + react: '>=18.2.0' + react-dom: '>=18.2.0' + slate: '>=0.121.0' + slate-dom: '>=0.119.1' + + slate@0.126.0: + resolution: {integrity: sha512-vajsZXY28Uxrg0odEChRIicg3QE9VbYLVLln+8Je4yIg/T7UJqj+WHK1236RL7i7CM/6/lnqPmA19n4Na1shgA==} + slice-ansi@8.0.0: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} @@ -9617,6 +10292,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -9707,6 +10385,9 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.1: + resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + tiny-typed-emitter@2.1.0: resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} @@ -9792,6 +10473,18 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + ts-essentials@10.1.0: + resolution: {integrity: sha512-LirrVzbhIpFQ9BdGfqLnM9r7aP9rnyfeoxbP5ZEkdr531IaY21+KdebRSsbvqu28VDJtcDDn+AlGn95t0c52zQ==} + peerDependencies: + typescript: '>=4.5.0' + peerDependenciesMeta: + typescript: + optional: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -9888,6 +10581,9 @@ packages: unist-util-modify-children@4.0.0: resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} @@ -9903,6 +10599,9 @@ packages: unist-util-visit-parents@6.0.2: resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} @@ -10029,6 +10728,24 @@ packages: '@types/react': optional: true + use-context-selector@1.4.4: + resolution: {integrity: sha512-pS790zwGxxe59GoBha3QYOwk8AFGp4DN6DOtH+eoqVmgBBRXVx4IlPDhJmmMiNQAgUaLlP+58aqRC3A4rdaSjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '*' + react-native: '*' + scheduler: '>=0.19.0' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + + use-deep-compare@1.3.0: + resolution: {integrity: sha512-94iG+dEdEP/Sl3WWde+w9StIunlV8Dgj+vkt5wTwMoFQLaijiEZSXXy8KtcStpmEDtIptRJiNeD4ACTtVvnIKA==} + peerDependencies: + react: '>=16.8.0' + use-latest-callback@0.2.6: resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} peerDependencies: @@ -10044,6 +10761,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.4.0: + resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -10461,6 +11183,11 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand-x@6.2.1: + resolution: {integrity: sha512-y3nQMQNx3BORY95vpuodJvh/8AqQu++S3q6mJYBSo1J0Q168Sy+FatqER658YESDqv2bwviXcIT3bgl/Ip6M5g==} + peerDependencies: + zustand: '>=5.0.2' + zustand@5.0.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} @@ -10499,6 +11226,11 @@ snapshots: '@alchemy.run/node-utils@0.0.5': {} + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170': optional: true @@ -11402,7 +12134,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@base-ui/react@1.5.0(@types/react@19.2.16)(date-fns@4.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.7 '@base-ui/utils': 0.2.9(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -11413,6 +12145,7 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.6) optionalDependencies: '@types/react': 19.2.16 + date-fns: 4.4.0 '@base-ui/utils@0.2.9(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -11427,6 +12160,8 @@ snapshots: '@blazediff/core@1.9.1': {} + '@braintree/sanitize-url@7.1.2': {} + '@bruits/satteri-darwin-arm64@0.9.3': optional: true @@ -11467,6 +12202,8 @@ snapshots: dependencies: fontkitten: 1.0.3 + '@chevrotain/types@11.1.2': {} + '@clack/core@0.5.0': dependencies: picocolors: 1.1.1 @@ -12776,6 +13513,14 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + '@floating-ui/react@0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@floating-ui/utils': 0.2.11 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + tabbable: 6.4.0 + '@floating-ui/react@0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -12794,6 +13539,14 @@ snapshots: '@iarna/toml@2.2.5': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.4': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': optional: true @@ -12968,12 +13721,15 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@juggle/resize-observer@3.4.0': {} + + '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) optionalDependencies: react-dom: 19.2.6(react@19.2.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: @@ -13213,6 +13969,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27) @@ -13619,6 +14379,174 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + '@platejs/basic-nodes@53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/code-block@53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/comment@53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + lodash: 4.18.1 + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/core@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6))': + dependencies: + '@platejs/slate': 53.3.3 + '@udecode/react-hotkeys': 52.0.11(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@udecode/react-utils': 52.3.4(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@udecode/utils': 52.3.4 + clsx: 2.1.1 + html-entities: 2.6.0 + is-hotkey: 0.2.0 + jotai: 2.8.4(@types/react@19.2.16)(react@19.2.6) + jotai-optics: 0.4.0(jotai@2.8.4(@types/react@19.2.16)(react@19.2.6))(optics-ts@2.4.1) + jotai-x: 2.3.4(@types/react@19.2.16)(jotai@2.8.4(@types/react@19.2.16)(react@19.2.6))(react@19.2.6) + lodash: 4.18.1 + nanoid: 5.1.16 + optics-ts: 2.4.1 + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + slate: 0.126.0 + slate-dom: 0.126.0(slate@0.126.0) + slate-hyperscript: 0.125.0(slate@0.126.0) + slate-react: 0.126.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(slate-dom@0.126.0(slate@0.126.0))(slate@0.126.0) + use-deep-compare: 1.3.0(react@19.2.6) + zustand: 5.0.14(@types/react@19.2.16)(immer@10.2.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) + zustand-x: 6.2.1(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(zustand@5.0.14(@types/react@19.2.16)(immer@10.2.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6))) + transitivePeerDependencies: + - '@types/react' + - immer + - react-native + - scheduler + - use-sync-external-store + + '@platejs/date@53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/diff@53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + diff-match-patch-ts: 0.6.0 + lodash: 4.18.1 + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/floating@53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/react': 0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/link@53.3.1(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@platejs/floating': 53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/list-classic@53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + lodash: 4.18.1 + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/markdown@53.3.3(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)': + dependencies: + '@platejs/date': 53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + lodash: 4.18.1 + marked: 15.0.12 + mdast-util-math: 3.0.0 + mdast-util-mdx: 3.0.0 + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + ts-essentials: 10.1.0(typescript@6.0.3) + unified: 11.0.5 + unist-util-visit: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@platejs/resizable@53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/slate@53.3.3': + dependencies: + '@udecode/utils': 52.3.4 + is-plain-object: 5.0.0 + lodash: 4.18.1 + scroll-into-view-if-needed: 3.1.0 + slate: 0.126.0 + slate-dom: 0.126.0(slate@0.126.0) + + '@platejs/suggestion@53.2.3(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@platejs/diff': 53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + lodash: 4.18.1 + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/table@53.0.9(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@platejs/resizable': 53.0.0(platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + lodash: 4.18.1 + platejs: 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@platejs/utils@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6))': + dependencies: + '@platejs/core': 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + '@platejs/slate': 53.3.3 + '@udecode/react-utils': 52.3.4(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@udecode/utils': 52.3.4 + clsx: 2.1.1 + lodash: 4.18.1 + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + - immer + - react-native + - scheduler + - use-sync-external-store + '@polka/url@1.0.0-next.29': {} '@preact/signals-core@1.14.2': {} @@ -13662,7 +14590,6 @@ snapshots: react: 19.2.6 optionalDependencies: '@types/react': 19.2.16 - optional: true '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.3)': dependencies: @@ -13940,7 +14867,6 @@ snapshots: react: 19.2.6 optionalDependencies: '@types/react': 19.2.16 - optional: true '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -14935,6 +15861,123 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -14953,6 +15996,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/geojson@7946.0.16': {} + '@types/hammerjs@2.0.46': {} '@types/hast@3.0.4': @@ -15013,6 +16058,9 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -15069,8 +16117,32 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260604.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260604.1 + '@udecode/react-hotkeys@52.0.11(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + + '@udecode/react-utils@52.3.4(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) + '@udecode/utils': 52.3.4 + clsx: 2.1.1 + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + + '@udecode/utils@52.3.4': {} + '@ungap/structured-clone@1.3.1': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/config@0.3.0': dependencies: '@vercel/routing-utils': 6.2.0 @@ -15312,6 +16384,10 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} agent-base@7.1.4: {} @@ -16150,6 +17226,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + commander@9.5.0: optional: true @@ -16173,6 +17251,8 @@ snapshots: transitivePeerDependencies: - supports-color + compute-scroll-into-view@3.1.1: {} + concat-map@0.0.1: {} conf@10.2.0: @@ -16230,6 +17310,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cross-dirname@0.1.0: optional: true @@ -16286,6 +17374,194 @@ snapshots: culori@4.0.2: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + date-fns@4.4.0: {} + + dayjs@1.11.21: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -16336,6 +17612,10 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -16363,6 +17643,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff-match-patch-ts@0.6.0: {} + diff@8.0.3: {} diff@9.0.0: {} @@ -16372,6 +17654,8 @@ snapshots: minimatch: 3.1.5 p-limit: 3.1.0 + direction@1.0.4: {} + dmg-builder@26.15.6(electron-builder-squirrel-windows@26.15.6): dependencies: app-builder-lib: 26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6) @@ -16401,6 +17685,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -16681,6 +17969,11 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -17662,6 +18955,8 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + hachure-fill@0.5.2: {} + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -17826,6 +19121,8 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-entities@2.6.0: {} + html-escaper@3.0.3: {} html-url-attributes@3.0.1: {} @@ -17863,6 +19160,10 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -17880,6 +19181,10 @@ snapshots: immediate@3.0.6: {} + immer@10.2.0: {} + + import-meta-resolve@4.2.0: {} + indent-string@4.0.0: optional: true @@ -17931,6 +19236,10 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -17988,6 +19297,8 @@ snapshots: is-hexadecimal@2.0.1: {} + is-hotkey@0.2.0: {} + is-in-ci@2.0.0: {} is-inside-container@1.0.0: @@ -18004,6 +19315,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-plain-object@5.0.0: {} + is-promise@4.0.0: {} is-property@1.0.2: {} @@ -18075,6 +19388,23 @@ snapshots: jose@6.2.3: {} + jotai-optics@0.4.0(jotai@2.8.4(@types/react@19.2.16)(react@19.2.6))(optics-ts@2.4.1): + dependencies: + jotai: 2.8.4(@types/react@19.2.16)(react@19.2.6) + optics-ts: 2.4.1 + + jotai-x@2.3.4(@types/react@19.2.16)(jotai@2.8.4(@types/react@19.2.16)(react@19.2.6))(react@19.2.6): + dependencies: + jotai: 2.8.4(@types/react@19.2.16)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + react: 19.2.6 + + jotai@2.8.4(@types/react@19.2.16)(react@19.2.6): + optionalDependencies: + '@types/react': 19.2.16 + react: 19.2.6 + js-base64@3.7.8: {} js-cookie@3.0.7: {} @@ -18135,10 +19465,16 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -18147,6 +19483,10 @@ snapshots: lan-network@0.2.1: {} + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lazy-val@1.0.5: {} leven@3.1.0: {} @@ -18337,12 +19677,16 @@ snapshots: p-locate: 3.0.0 path-exists: 3.0.0 + lodash-es@4.18.1: {} + lodash.debounce@4.0.8: {} lodash.escaperegexp@4.1.2: {} lodash.isequal@4.5.0: {} + lodash.mapvalues@4.6.0: {} + lodash.throttle@4.1.1: {} lodash@4.18.1: {} @@ -18404,6 +19748,10 @@ snapshots: markdown-table@3.0.4: {} + marked@15.0.12: {} + + marked@16.4.2: {} + marky@1.3.0: {} matcher@3.0.0: @@ -18501,6 +19849,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -18529,6 +19889,16 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -18596,6 +19966,30 @@ snapshots: merge2@1.4.1: {} + mermaid@11.16.1: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.4 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.13 + es-toolkit: 1.47.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.1 + metro-babel-transformer@0.84.4: dependencies: '@babel/core': 7.29.7 @@ -18847,6 +20241,57 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -18860,6 +20305,18 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + micromark-factory-space@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -18912,6 +20369,16 @@ snapshots: micromark-util-encode@2.0.1: {} + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + micromark-util-html-tag-name@2.0.1: {} micromark-util-normalize-identifier@2.0.1: @@ -19095,6 +20562,8 @@ snapshots: multitars@1.0.0: {} + mutative@1.1.0: {} + mute-stream@2.0.0: {} mysql2@3.22.4(@types/node@24.12.4): @@ -19115,6 +20584,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@5.1.16: {} + negotiator@0.6.3: {} negotiator@0.6.4: {} @@ -19298,6 +20769,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + optics-ts@2.4.1: {} + ora@3.4.0: dependencies: chalk: 2.4.2 @@ -19443,6 +20916,8 @@ snapshots: path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} + path-exists@3.0.0: {} path-expression-matcher@1.5.0: {} @@ -19560,6 +21035,24 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + platejs@53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)): + dependencies: + '@platejs/core': 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + '@platejs/slate': 53.3.3 + '@platejs/utils': 53.3.3(@types/react@19.2.16)(immer@10.2.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.6)) + '@udecode/react-hotkeys': 52.0.11(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@udecode/react-utils': 52.3.4(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@udecode/utils': 52.3.4 + react: 19.2.6 + react-compiler-runtime: 1.0.0(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + - immer + - react-native + - scheduler + - use-sync-external-store + playwright-core@1.60.0: {} plist@3.1.0: @@ -19578,6 +21071,13 @@ snapshots: pngjs@7.0.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -19676,6 +21176,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-compare@2.6.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -19722,6 +21224,10 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-compiler-runtime@1.0.0(react@19.2.6): + dependencies: + react: 19.2.6 + react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: shell-quote: 1.8.4 @@ -20137,6 +21643,16 @@ snapshots: '@types/react': 19.2.16 optional: true + react-tracked@1.7.14(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0): + dependencies: + proxy-compare: 2.6.0 + react: 19.2.6 + scheduler: 0.27.0 + use-context-selector: 1.4.4(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react@19.2.3: {} react@19.2.6: {} @@ -20255,6 +21771,13 @@ snapshots: transitivePeerDependencies: - supports-color + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -20382,6 +21905,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + robust-predicates@3.0.3: {} + rolldown@1.0.0-rc.17: dependencies: '@oxc-project/types': 0.127.0 @@ -20457,6 +21982,13 @@ snapshots: fsevents: 2.3.3 optional: true + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -20471,6 +22003,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -20509,6 +22043,10 @@ snapshots: ajv-formats: 2.1.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + semver-compare@1.0.0: optional: true @@ -20730,6 +22268,36 @@ snapshots: sisteransi@1.0.5: {} + slate-dom@0.126.0(slate@0.126.0): + dependencies: + '@juggle/resize-observer': 3.4.0 + direction: 1.0.4 + is-hotkey: 0.2.0 + is-plain-object: 5.0.0 + lodash: 4.18.1 + scroll-into-view-if-needed: 3.1.0 + slate: 0.126.0 + tiny-invariant: 1.3.1 + + slate-hyperscript@0.125.0(slate@0.126.0): + dependencies: + slate: 0.126.0 + + slate-react@0.126.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(slate-dom@0.126.0(slate@0.126.0))(slate@0.126.0): + dependencies: + '@juggle/resize-observer': 3.4.0 + direction: 1.0.4 + is-hotkey: 0.2.0 + lodash: 4.18.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + scroll-into-view-if-needed: 3.1.0 + slate: 0.126.0 + slate-dom: 0.126.0(slate@0.126.0) + tiny-invariant: 1.3.1 + + slate@0.126.0: {} + slice-ansi@8.0.0: dependencies: ansi-styles: 6.2.3 @@ -20860,6 +22428,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.4.0: {} + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -20965,6 +22535,8 @@ snapshots: tiny-inflate@1.0.3: {} + tiny-invariant@1.3.1: {} + tiny-typed-emitter@2.1.0: {} tinybench@2.9.0: {} @@ -21028,6 +22600,12 @@ snapshots: ts-algebra@2.0.0: {} + ts-dedent@2.3.0: {} + + ts-essentials@10.1.0(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + tslib@2.8.1: {} type-fest@0.13.1: @@ -21118,6 +22696,10 @@ snapshots: array-iterate: 2.0.1 optional: true + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -21126,7 +22708,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 unist-util-visit: 5.1.0 - optional: true unist-util-stringify-position@4.0.0: dependencies: @@ -21142,6 +22723,12 @@ snapshots: '@types/unist': 3.0.3 unist-util-is: 6.0.1 + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unist-util-visit@5.1.0: dependencies: '@types/unist': 3.0.3 @@ -21228,6 +22815,19 @@ snapshots: '@types/react': 19.2.16 optional: true + use-context-selector@1.4.4(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + + use-deep-compare@1.3.0(react@19.2.6): + dependencies: + dequal: 2.0.3 + react: 19.2.6 + use-latest-callback@0.2.6(react@19.2.3): dependencies: react: 19.2.3 @@ -21254,6 +22854,10 @@ snapshots: '@types/react': 19.2.16 optional: true + use-sync-external-store@1.4.0(react@19.2.6): + dependencies: + react: 19.2.6 + use-sync-external-store@1.6.0(react@19.2.3): dependencies: react: 19.2.3 @@ -21690,9 +23294,24 @@ snapshots: zod@4.4.3: {} - zustand@5.0.14(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): + zustand-x@6.2.1(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0)(zustand@5.0.14(@types/react@19.2.16)(immer@10.2.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6))): + dependencies: + immer: 10.2.0 + lodash.mapvalues: 4.6.0 + mutative: 1.1.0 + react-tracked: 1.7.14(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(scheduler@0.27.0) + use-sync-external-store: 1.4.0(react@19.2.6) + zustand: 5.0.14(@types/react@19.2.16)(immer@10.2.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) + transitivePeerDependencies: + - react + - react-dom + - react-native + - scheduler + + zustand@5.0.14(@types/react@19.2.16)(immer@10.2.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): optionalDependencies: '@types/react': 19.2.16 + immer: 10.2.0 react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6)