| author | |
| committer | |
| log | a8cd2ad2ebb32ad789e8e0ecd2fc713c2edc38f4 |
| tree | e5409886e55b9316364096126ee4f7a9efdb4979 |
| parent | 4a07c1ca9d9033ef35161a1ffbea44d0418ca2bf |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>30 files changed, 619 insertions(+), 768 deletions(-)
apps/desktop/src/settings/DesktopClientSettings.test.ts-1| ... | ... | @@ -13,7 +13,6 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 13 | 13 | import * as DesktopClientSettings from "./DesktopClientSettings.ts"; |
| 14 | 14 | |
| 15 | 15 | const clientSettings: ClientSettings = { |
| 16 | autoOpenPlanSidebar: false, | |
| 17 | 16 | confirmThreadArchive: true, |
| 18 | 17 | confirmThreadDelete: false, |
| 19 | 18 | dismissedProviderUpdateNotificationKeys: [], |
apps/server/integration/OrchestrationEngineHarness.integration.ts+5-1| ... | ... | @@ -51,6 +51,7 @@ import { OrchestrationEngineLive } from "../src/orchestration/Layers/Orchestrati |
| 51 | 51 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; |
| 52 | 52 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; |
| 53 | 53 | import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts"; |
| 54 | import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts"; | |
| 54 | 55 | import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; |
| 55 | 56 | import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; |
| 56 | 57 | import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; |
| ... | ... | @@ -310,7 +311,10 @@ export const makeOrchestrationIntegrationHarness = ( |
| 310 | 311 | checkpointStoreLayer, |
| 311 | 312 | providerLayer, |
| 312 | 313 | RuntimeReceiptBusTest, |
| 313 | ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); | |
| 314 | ).pipe( | |
| 315 | Layer.provideMerge(ThreadBackgroundLiveness.layer), | |
| 316 | Layer.provideMerge(ThreadPlanProgress.layer), | |
| 317 | ); | |
| 314 | 318 | const serverSettingsLayer = ServerSettingsService.layerTest(); |
| 315 | 319 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| 316 | 320 | Layer.provideMerge(runtimeServicesLayer), |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+3| ... | ... | @@ -40,6 +40,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 40 | 40 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 41 | 41 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 42 | 42 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; |
| 43 | import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; | |
| 43 | 44 | import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; |
| 44 | 45 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 45 | 46 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| ... | ... | @@ -296,6 +297,7 @@ describe("CheckpointReactor", () => { |
| 296 | 297 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 297 | 298 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 298 | 299 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 300 | Layer.provide(ThreadPlanProgress.layer), | |
| 299 | 301 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 300 | 302 | Layer.provide(OrchestrationEventStoreLive), |
| 301 | 303 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| ... | ... | @@ -304,6 +306,7 @@ describe("CheckpointReactor", () => { |
| 304 | 306 | ); |
| 305 | 307 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( |
| 306 | 308 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 309 | Layer.provide(ThreadPlanProgress.layer), | |
| 307 | 310 | Layer.provide(RepositoryIdentityResolver.layer), |
| 308 | 311 | Layer.provide(SqlitePersistenceMemory), |
| 309 | 312 | ); |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5| ... | ... | @@ -32,6 +32,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 32 | 32 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 33 | 33 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 34 | 34 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; |
| 35 | import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; | |
| 35 | 36 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 36 | 37 | import { |
| 37 | 38 | OrchestrationProjectionPipeline, |
| ... | ... | @@ -57,6 +58,7 @@ async function createOrchestrationSystem() { |
| 57 | 58 | OrchestrationProjectionSnapshotQueryLive, |
| 58 | 59 | ).pipe( |
| 59 | 60 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 61 | Layer.provide(ThreadPlanProgress.layer), | |
| 60 | 62 | Layer.provide(OrchestrationEventStoreLive), |
| 61 | 63 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 62 | 64 | Layer.provide(RepositoryIdentityResolver.layer), |
| ... | ... | @@ -820,6 +822,7 @@ describe("OrchestrationEngine", () => { |
| 820 | 822 | OrchestrationEngineLive.pipe( |
| 821 | 823 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 822 | 824 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 825 | Layer.provide(ThreadPlanProgress.layer), | |
| 823 | 826 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 824 | 827 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), |
| 825 | 828 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| ... | ... | @@ -926,6 +929,7 @@ describe("OrchestrationEngine", () => { |
| 926 | 929 | OrchestrationEngineLive.pipe( |
| 927 | 930 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 928 | 931 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 932 | Layer.provide(ThreadPlanProgress.layer), | |
| 929 | 933 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 930 | 934 | Layer.provide(OrchestrationEventStoreLive), |
| 931 | 935 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| ... | ... | @@ -1070,6 +1074,7 @@ describe("OrchestrationEngine", () => { |
| 1070 | 1074 | OrchestrationEngineLive.pipe( |
| 1071 | 1075 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 1072 | 1076 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 1077 | Layer.provide(ThreadPlanProgress.layer), | |
| 1073 | 1078 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 1074 | 1079 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), |
| 1075 | 1080 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2| ... | ... | @@ -32,6 +32,7 @@ import { |
| 32 | 32 | } from "./ProjectionPipeline.ts"; |
| 33 | 33 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 34 | 34 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; |
| 35 | import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; | |
| 35 | 36 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 36 | 37 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; |
| 37 | 38 | import { ServerConfig } from "../../config.ts"; |
| ... | ... | @@ -2673,6 +2674,7 @@ const engineLayer = it.layer( |
| 2673 | 2674 | OrchestrationEngineLive.pipe( |
| 2674 | 2675 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 2675 | 2676 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 2677 | Layer.provide(ThreadPlanProgress.layer), | |
| 2676 | 2678 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 2677 | 2679 | Layer.provide(OrchestrationEventStoreLive), |
| 2678 | 2680 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+4| ... | ... | @@ -18,6 +18,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes |
| 18 | 18 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 19 | 19 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 20 | 20 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; |
| 21 | import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; | |
| 21 | 22 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 22 | 23 | import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; |
| 23 | 24 | |
| ... | ... | @@ -30,6 +31,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val |
| 30 | 31 | const projectionSnapshotLayer = it.layer( |
| 31 | 32 | OrchestrationProjectionSnapshotQueryLive.pipe( |
| 32 | 33 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 34 | Layer.provide(ThreadPlanProgress.layer), | |
| 33 | 35 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| 34 | 36 | Layer.provideMerge(SqlitePersistenceMemory), |
| 35 | 37 | Layer.provideMerge(NodeServices.layer), |
| ... | ... | @@ -447,6 +449,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 447 | 449 | hasPendingUserInput: false, |
| 448 | 450 | hasActionableProposedPlan: false, |
| 449 | 451 | backgroundLiveness: null, |
| 452 | planProgress: null, | |
| 450 | 453 | }, |
| 451 | 454 | ]); |
| 452 | 455 | |
| ... | ... | @@ -1830,6 +1833,7 @@ it.effect( |
| 1830 | 1833 | const resolveCalls: string[] = []; |
| 1831 | 1834 | const layer = OrchestrationProjectionSnapshotQueryLive.pipe( |
| 1832 | 1835 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 1836 | Layer.provide(ThreadPlanProgress.layer), | |
| 1833 | 1837 | Layer.provideMerge( |
| 1834 | 1838 | Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { |
| 1835 | 1839 | resolve: (cwd: string) => |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+5| ... | ... | @@ -44,6 +44,7 @@ import { |
| 44 | 44 | } from "../../persistence/Errors.ts"; |
| 45 | 45 | import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; |
| 46 | 46 | import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; |
| 47 | import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; | |
| 47 | 48 | import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; |
| 48 | 49 | import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; |
| 49 | 50 | import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; |
| ... | ... | @@ -344,6 +345,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st |
| 344 | 345 | |
| 345 | 346 | const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 346 | 347 | const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; |
| 348 | const threadPlanProgress = yield* ThreadPlanProgressService; | |
| 347 | 349 | const sql = yield* SqlClient.SqlClient; |
| 348 | 350 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; |
| 349 | 351 | const repositoryIdentityResolutionConcurrency = 4; |
| ... | ... | @@ -1908,6 +1910,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1908 | 1910 | backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( |
| 1909 | 1911 | row.threadId, |
| 1910 | 1912 | ), |
| 1913 | planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), | |
| 1911 | 1914 | } satisfies OrchestrationThreadShell) |
| 1912 | 1915 | : Result.failVoid, |
| 1913 | 1916 | ), |
| ... | ... | @@ -2051,6 +2054,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2051 | 2054 | backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( |
| 2052 | 2055 | row.threadId, |
| 2053 | 2056 | ), |
| 2057 | planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), | |
| 2054 | 2058 | }), |
| 2055 | 2059 | ), |
| 2056 | 2060 | updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", |
| ... | ... | @@ -2326,6 +2330,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2326 | 2330 | backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( |
| 2327 | 2331 | threadRow.value.threadId, |
| 2328 | 2332 | ), |
| 2333 | planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId), | |
| 2329 | 2334 | } satisfies OrchestrationThreadShell); |
| 2330 | 2335 | }); |
| 2331 | 2336 |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3| ... | ... | @@ -49,6 +49,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 49 | 49 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 50 | 50 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 51 | 51 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; |
| 52 | import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; | |
| 52 | 53 | import { |
| 53 | 54 | providerErrorLabel, |
| 54 | 55 | providerErrorLabelFromInstanceHint, |
| ... | ... | @@ -347,6 +348,7 @@ describe("ProviderCommandReactor", () => { |
| 347 | 348 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 348 | 349 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), |
| 349 | 350 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 351 | Layer.provide(ThreadPlanProgress.layer), | |
| 350 | 352 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 351 | 353 | Layer.provide(OrchestrationEventStoreLive), |
| 352 | 354 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| ... | ... | @@ -355,6 +357,7 @@ describe("ProviderCommandReactor", () => { |
| 355 | 357 | ); |
| 356 | 358 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( |
| 357 | 359 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 360 | Layer.provide(ThreadPlanProgress.layer), | |
| 358 | 361 | Layer.provide(RepositoryIdentityResolver.layer), |
| 359 | 362 | Layer.provide(SqlitePersistenceMemory), |
| 360 | 363 | ); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+2| ... | ... | @@ -45,6 +45,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 45 | 45 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 46 | 46 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 47 | 47 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; |
| 48 | import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; | |
| 48 | 49 | import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; |
| 49 | 50 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 50 | 51 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| ... | ... | @@ -241,6 +242,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 241 | 242 | // Single shared liveness instance across ingestion (writer), the |
| 242 | 243 | // engine, and the snapshot query (reader). |
| 243 | 244 | Layer.provideMerge(ThreadBackgroundLiveness.layer), |
| 245 | Layer.provideMerge(ThreadPlanProgress.layer), | |
| 244 | 246 | Layer.provideMerge(SqlitePersistenceMemory), |
| 245 | 247 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 246 | 248 | Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+17| ... | ... | @@ -35,6 +35,7 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio |
| 35 | 35 | import { isGitRepository } from "../../git/Utils.ts"; |
| 36 | 36 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 37 | 37 | import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; |
| 38 | import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; | |
| 38 | 39 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 39 | 40 | import { |
| 40 | 41 | ProviderRuntimeIngestionService, |
| ... | ... | @@ -867,6 +868,7 @@ export function runtimeEventToActivities( |
| 867 | 868 | |
| 868 | 869 | const make = Effect.gen(function* () { |
| 869 | 870 | const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; |
| 871 | const threadPlanProgress = yield* ThreadPlanProgressService; | |
| 870 | 872 | const crypto = yield* Crypto.Crypto; |
| 871 | 873 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 872 | 874 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| ... | ... | @@ -1935,6 +1937,21 @@ const make = Effect.gen(function* () { |
| 1935 | 1937 | yield* rememberTaskDescription(thread.id, event.payload.taskId, description); |
| 1936 | 1938 | } |
| 1937 | 1939 | } |
| 1940 | // Working-indicator plan progress: current step while the turn runs, | |
| 1941 | // cleared on settle so a finished plan never lingers as stale UI. | |
| 1942 | // Events carrying a turn id that conflicts with the active turn are | |
| 1943 | // stale (superseded turn) and must neither overwrite nor clear the | |
| 1944 | // active turn's progress; session.exited always clears. | |
| 1945 | if (event.type === "session.exited") { | |
| 1946 | threadPlanProgress.clearThreadPlanProgress(thread.id); | |
| 1947 | } else if (!conflictsWithActiveTurn) { | |
| 1948 | if (event.type === "turn.plan.updated") { | |
| 1949 | threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan); | |
| 1950 | } else if (event.type === "turn.completed" || event.type === "turn.aborted") { | |
| 1951 | threadPlanProgress.clearThreadPlanProgress(thread.id); | |
| 1952 | } | |
| 1953 | } | |
| 1954 | ||
| 1938 | 1955 | // Sidebar background liveness: fed from the same lifecycle stream, |
| 1939 | 1956 | // read by the shell query at mapping time (no persistence). |
| 1940 | 1957 | switch (event.type) { |
apps/server/src/orchestration/ThreadPlanProgress.test.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; | |
| 3 | ||
| 4 | describe("ThreadPlanProgress", () => { | |
| 5 | it("tracks the in-progress step and clears when the plan completes", () => { | |
| 6 | const progress = ThreadPlanProgress.make(); | |
| 7 | const threadId = "t-plan-1"; | |
| 8 | progress.recordPlanProgress(threadId, [ | |
| 9 | { step: "Audit failure paths", status: "completed" }, | |
| 10 | { step: "Implement the fix", status: "inProgress" }, | |
| 11 | { step: "Run targeted tests", status: "pending" }, | |
| 12 | ]); | |
| 13 | expect(progress.getThreadPlanProgress(threadId)).toEqual({ | |
| 14 | step: "Implement the fix", | |
| 15 | completedSteps: 1, | |
| 16 | totalSteps: 3, | |
| 17 | }); | |
| 18 | ||
| 19 | progress.recordPlanProgress(threadId, [ | |
| 20 | { step: "Audit failure paths", status: "completed" }, | |
| 21 | { step: "Implement the fix", status: "completed" }, | |
| 22 | { step: "Run targeted tests", status: "completed" }, | |
| 23 | ]); | |
| 24 | expect(progress.getThreadPlanProgress(threadId)).toBeNull(); | |
| 25 | }); | |
| 26 | ||
| 27 | it("falls back to the first non-completed step when nothing is in progress", () => { | |
| 28 | const progress = ThreadPlanProgress.make(); | |
| 29 | const threadId = "t-plan-2"; | |
| 30 | progress.recordPlanProgress(threadId, [ | |
| 31 | { step: "First", status: "pending" }, | |
| 32 | { step: "Second", status: "pending" }, | |
| 33 | ]); | |
| 34 | expect(progress.getThreadPlanProgress(threadId)?.step).toBe("First"); | |
| 35 | }); | |
| 36 | ||
| 37 | it("clearThreadPlanProgress removes the entry (turn settled / session died)", () => { | |
| 38 | const progress = ThreadPlanProgress.make(); | |
| 39 | const threadId = "t-plan-3"; | |
| 40 | progress.recordPlanProgress(threadId, [{ step: "Only step", status: "inProgress" }]); | |
| 41 | progress.clearThreadPlanProgress(threadId); | |
| 42 | expect(progress.getThreadPlanProgress(threadId)).toBeNull(); | |
| 43 | }); | |
| 44 | }); |
apps/server/src/orchestration/ThreadPlanProgress.ts created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | /** | |
| 2 | * ThreadPlanProgressService - in-memory per-thread plan progress for the | |
| 3 | * Working indicators (sidebar rows, in-chat working line). | |
| 4 | * | |
| 5 | * Plans are a progress annotation, not a surface of their own: the useful | |
| 6 | * kernel of a turn.plan.updated event is "which step is the agent on right | |
| 7 | * now". Ingestion records the current step here and the shell query reads it | |
| 8 | * at mapping time — no persistence, no migration (same pattern as | |
| 9 | * ThreadBackgroundLivenessService). Cleared when the turn settles or the | |
| 10 | * session dies, so a finished plan never lingers as stale UI. | |
| 11 | * | |
| 12 | * @module ThreadPlanProgressService | |
| 13 | */ | |
| 14 | import * as Context from "effect/Context"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | ||
| 18 | export interface ThreadPlanProgress { | |
| 19 | readonly step: string; | |
| 20 | readonly completedSteps: number; | |
| 21 | readonly totalSteps: number; | |
| 22 | } | |
| 23 | ||
| 24 | interface PlanStepInput { | |
| 25 | readonly step: string; | |
| 26 | readonly status: string; | |
| 27 | } | |
| 28 | ||
| 29 | export class ThreadPlanProgressService extends Context.Service< | |
| 30 | ThreadPlanProgressService, | |
| 31 | { | |
| 32 | /** | |
| 33 | * Feed one turn.plan.updated payload. An all-completed plan clears the | |
| 34 | * entry (the turn is wrapping up; nothing is "in progress" anymore). | |
| 35 | */ | |
| 36 | readonly recordPlanProgress: (threadId: string, plan: ReadonlyArray<PlanStepInput>) => void; | |
| 37 | ||
| 38 | /** Turn settled or session died: the working indicator reverts to plain. */ | |
| 39 | readonly clearThreadPlanProgress: (threadId: string) => void; | |
| 40 | ||
| 41 | readonly getThreadPlanProgress: (threadId: string) => ThreadPlanProgress | null; | |
| 42 | } | |
| 43 | >()("t3/orchestration/ThreadPlanProgress/ThreadPlanProgressService") {} | |
| 44 | ||
| 45 | export function make(): ThreadPlanProgressService["Service"] { | |
| 46 | const progressByThreadId = new Map<string, ThreadPlanProgress>(); | |
| 47 | ||
| 48 | return { | |
| 49 | recordPlanProgress: (threadId, plan) => { | |
| 50 | const totalSteps = plan.length; | |
| 51 | const completedSteps = plan.filter((step) => step.status === "completed").length; | |
| 52 | // Current step: the in-progress one, else the first pending one (a | |
| 53 | // plan that was just written has no in-progress step yet). | |
| 54 | const current = | |
| 55 | plan.find((step) => step.status === "inProgress") ?? | |
| 56 | plan.find((step) => step.status !== "completed"); | |
| 57 | if (totalSteps === 0 || completedSteps === totalSteps || current === undefined) { | |
| 58 | progressByThreadId.delete(threadId); | |
| 59 | return; | |
| 60 | } | |
| 61 | progressByThreadId.set(threadId, { | |
| 62 | step: current.step, | |
| 63 | completedSteps, | |
| 64 | totalSteps, | |
| 65 | }); | |
| 66 | }, | |
| 67 | ||
| 68 | clearThreadPlanProgress: (threadId) => { | |
| 69 | progressByThreadId.delete(threadId); | |
| 70 | }, | |
| 71 | ||
| 72 | getThreadPlanProgress: (threadId) => progressByThreadId.get(threadId) ?? null, | |
| 73 | }; | |
| 74 | } | |
| 75 | ||
| 76 | export const layer = Layer.effect(ThreadPlanProgressService, Effect.sync(make)); |
apps/server/src/orchestration/runtimeLayer.ts+9-4| ... | ... | @@ -6,6 +6,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; |
| 6 | 6 | import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; |
| 7 | 7 | import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; |
| 8 | 8 | import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; |
| 9 | import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; | |
| 9 | 10 | |
| 10 | 11 | export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( |
| 11 | 12 | OrchestrationEventStoreLive, |
| ... | ... | @@ -20,10 +21,14 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( |
| 20 | 21 | OrchestrationProjectionSnapshotQueryLive, |
| 21 | 22 | OrchestrationEventInfrastructureLayerLive, |
| 22 | 23 | OrchestrationProjectionPipelineLayerLive, |
| 23 | // Shared background-liveness registry: written by runtime ingestion, | |
| 24 | // read by the snapshot query. provideMerge feeds the same instance to | |
| 25 | // the snapshot query here and re-exports it for runtime ingestion. | |
| 26 | ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); | |
| 24 | // Shared background-liveness and plan-progress registries: written by | |
| 25 | // runtime ingestion, read by the snapshot query. provideMerge feeds the | |
| 26 | // same instance to the snapshot query here and re-exports it for runtime | |
| 27 | // ingestion. | |
| 28 | ).pipe( | |
| 29 | Layer.provideMerge(ThreadBackgroundLiveness.layer), | |
| 30 | Layer.provideMerge(ThreadPlanProgress.layer), | |
| 31 | ); | |
| 27 | 32 | |
| 28 | 33 | export const OrchestrationLayerLive = Layer.mergeAll( |
| 29 | 34 | OrchestrationInfrastructureLayerLive, |
apps/web/src/components/ChatView.tsx+38-169| ... | ... | @@ -85,7 +85,7 @@ import { |
| 85 | 85 | deriveTimelineEntries, |
| 86 | 86 | deriveActiveWorkStartedAt, |
| 87 | 87 | deriveActivePlanState, |
| 88 | findSidebarProposedPlan, | |
| 88 | deriveTurnPlans, | |
| 89 | 89 | findLatestProposedPlan, |
| 90 | 90 | deriveWorkLogEntries, |
| 91 | 91 | hasActionableProposedPlan, |
| ... | ... | @@ -121,11 +121,6 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 121 | 121 | import { isCommandPaletteOpen } from "../commandPaletteBus"; |
| 122 | 122 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 123 | 123 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 124 | import { | |
| 125 | clearPlanSidebarDismissal, | |
| 126 | dismissPlanSidebarForTurn, | |
| 127 | isPlanSidebarDismissedForTurn, | |
| 128 | } from "../planSidebarDismissal"; | |
| 129 | 124 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; |
| 130 | 125 | import { |
| 131 | 126 | selectActiveRightPanel, |
| ... | ... | @@ -157,7 +152,6 @@ import { |
| 157 | 152 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 158 | 153 | import { BranchToolbar } from "./BranchToolbar"; |
| 159 | 154 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 160 | import PlanSidebar from "./PlanSidebar"; | |
| 161 | 155 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 162 | 156 | import { |
| 163 | 157 | AlarmClockIcon, |
| ... | ... | @@ -234,7 +228,6 @@ import { |
| 234 | 228 | useProject, |
| 235 | 229 | useProjects, |
| 236 | 230 | useThread, |
| 237 | useThreadProposedPlans, | |
| 238 | 231 | useThreadRefs, |
| 239 | 232 | useThreadShell, |
| 240 | 233 | } from "../state/entities"; |
| ... | ... | @@ -451,8 +444,6 @@ type EnvironmentUnavailableState = { |
| 451 | 444 | readonly connection: EnvironmentConnectionPresentation; |
| 452 | 445 | }; |
| 453 | 446 | |
| 454 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; | |
| 455 | ||
| 456 | 447 | function eventPathContainsSelector(event: Event, selector: string): boolean { |
| 457 | 448 | const path = event.composedPath(); |
| 458 | 449 | if (path.length === 0 && event.target) { |
| ... | ... | @@ -1271,7 +1262,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 1271 | 1262 | (store) => store.setStickyModelSelection, |
| 1272 | 1263 | ); |
| 1273 | 1264 | const timestampFormat = settings.timestampFormat; |
| 1274 | const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; | |
| 1275 | 1265 | const navigate = useNavigate(); |
| 1276 | 1266 | const { resolvedTheme } = useTheme(); |
| 1277 | 1267 | // Granular store selectors — avoid subscribing to prompt changes. |
| ... | ... | @@ -1341,10 +1331,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1341 | 1331 | >({}); |
| 1342 | 1332 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = |
| 1343 | 1333 | useState<Record<string, number>>({}); |
| 1344 | const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); | |
| 1345 | // When set, the thread-change reset effect will open the sidebar instead of closing it. | |
| 1346 | // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. | |
| 1347 | const planSidebarOpenOnNextThreadRef = useRef(false); | |
| 1334 | const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); | |
| 1348 | 1335 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 1349 | 1336 | const [pullRequestDialogState, setPullRequestDialogState] = |
| 1350 | 1337 | useState<PullRequestDialogState | null>(null); |
| ... | ... | @@ -1577,10 +1564,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 1577 | 1564 | ); |
| 1578 | 1565 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); |
| 1579 | 1566 | const rightPanelOpen = rightPanelState.isOpen; |
| 1580 | const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; | |
| 1567 | const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; | |
| 1581 | 1568 | const rightPanelMaximized = |
| 1582 | 1569 | canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; |
| 1583 | const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; | |
| 1570 | const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; | |
| 1584 | 1571 | |
| 1585 | 1572 | useEffect(() => { |
| 1586 | 1573 | if (!activeThreadRef) return; |
| ... | ... | @@ -1607,36 +1594,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 1607 | 1594 | previewPanelOpen, |
| 1608 | 1595 | ]); |
| 1609 | 1596 | |
| 1610 | const planSidebarOpen = activeRightPanelKind === "plan"; | |
| 1611 | ||
| 1612 | 1597 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| 1613 | 1598 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 1614 | 1599 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| 1615 | 1600 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); |
| 1616 | 1601 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 1617 | const sourcePlanThreadRef = useMemo(() => { | |
| 1618 | const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; | |
| 1619 | if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { | |
| 1620 | return null; | |
| 1621 | } | |
| 1622 | return scopeThreadRef(activeThread.environmentId, sourceThreadId); | |
| 1623 | }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); | |
| 1624 | const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); | |
| 1625 | const threadPlanCatalog = useMemo<ThreadPlanCatalogEntry[]>(() => { | |
| 1626 | if (!activeThread) { | |
| 1627 | return []; | |
| 1628 | } | |
| 1629 | const entries: ThreadPlanCatalogEntry[] = [ | |
| 1630 | { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, | |
| 1631 | ]; | |
| 1632 | if (sourcePlanThreadRef) { | |
| 1633 | entries.push({ | |
| 1634 | id: sourcePlanThreadRef.threadId, | |
| 1635 | proposedPlans: sourceThreadProposedPlans, | |
| 1636 | }); | |
| 1637 | } | |
| 1638 | return entries; | |
| 1639 | }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); | |
| 1640 | 1602 | useEffect(() => { |
| 1641 | 1603 | setMountedTerminalThreadKeys((currentThreadIds) => { |
| 1642 | 1604 | const nextThreadIds = reconcileMountedTerminalThreadIds({ |
| ... | ... | @@ -2086,6 +2048,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2086 | 2048 | const phase = derivePhase(activeThread?.session ?? null); |
| 2087 | 2049 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; |
| 2088 | 2050 | const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); |
| 2051 | const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); | |
| 2089 | 2052 | // Native subagent fold: memoized by activity-list identity, shared by the |
| 2090 | 2053 | // Agents surface, live strip, and workflow cards. v2Projection is null |
| 2091 | 2054 | // until orchestration-v2 lands (source precedence lives in the derive). |
| ... | ... | @@ -2148,21 +2111,25 @@ function ChatViewContent(props: ChatViewProps) { |
| 2148 | 2111 | activeLatestTurn?.turnId ?? null, |
| 2149 | 2112 | ); |
| 2150 | 2113 | }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); |
| 2151 | const sidebarProposedPlan = useMemo( | |
| 2152 | () => | |
| 2153 | findSidebarProposedPlan({ | |
| 2154 | threads: threadPlanCatalog, | |
| 2155 | latestTurn: activeLatestTurn, | |
| 2156 | latestTurnSettled, | |
| 2157 | threadId: activeThread?.id ?? null, | |
| 2158 | }), | |
| 2159 | [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], | |
| 2160 | ); | |
| 2161 | 2114 | const activePlan = useMemo( |
| 2162 | 2115 | () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), |
| 2163 | 2116 | [activeLatestTurn?.turnId, threadActivities], |
| 2164 | 2117 | ); |
| 2165 | const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; | |
| 2118 | // Current step for the in-chat working row: only for the running turn's own | |
| 2119 | // plan (deriveActivePlanState falls back to older turns' plans, which must | |
| 2120 | // not label fresh work). Falls back to the first pending step so an | |
| 2121 | // all-pending freshly written plan labels the row, matching the chip and | |
| 2122 | // the server's planProgress. | |
| 2123 | const workingStepLabel = useMemo(() => { | |
| 2124 | if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) { | |
| 2125 | return null; | |
| 2126 | } | |
| 2127 | return ( | |
| 2128 | activePlan.steps.find((step) => step.status === "inProgress")?.step ?? | |
| 2129 | activePlan.steps.find((step) => step.status === "pending")?.step ?? | |
| 2130 | null | |
| 2131 | ); | |
| 2132 | }, [activeLatestTurn?.turnId, activePlan]); | |
| 2166 | 2133 | const showPlanFollowUpPrompt = |
| 2167 | 2134 | pendingUserInputs.length === 0 && |
| 2168 | 2135 | interactionMode === "plan" && |
| ... | ... | @@ -2431,8 +2398,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 2431 | 2398 | }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); |
| 2432 | 2399 | const timelineEntries = useMemo( |
| 2433 | 2400 | () => |
| 2434 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), | |
| 2435 | [activeThread?.proposedPlans, timelineMessages, workLogEntries], | |
| 2401 | deriveTimelineEntries( | |
| 2402 | timelineMessages, | |
| 2403 | activeThread?.proposedPlans ?? [], | |
| 2404 | workLogEntries, | |
| 2405 | turnPlans, | |
| 2406 | ), | |
| 2407 | [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries], | |
| 2436 | 2408 | ); |
| 2437 | 2409 | const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null); |
| 2438 | 2410 | const draftHeroDockRequested = |
| ... | ... | @@ -3142,47 +3114,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 3142 | 3114 | const toggleInteractionMode = useCallback(() => { |
| 3143 | 3115 | handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); |
| 3144 | 3116 | }, [handleInteractionModeChange, interactionMode]); |
| 3145 | const dismissPlanSidebarForCurrentTurn = useCallback(() => { | |
| 3146 | if (!activeThreadKey) return; | |
| 3147 | dismissPlanSidebarForTurn( | |
| 3148 | activeThreadKey, | |
| 3149 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__", | |
| 3150 | ); | |
| 3151 | }, [activeThreadKey, activePlan?.turnId, sidebarProposedPlan?.turnId]); | |
| 3152 | const togglePlanSidebar = useCallback(() => { | |
| 3153 | if (!activeThreadRef) return; | |
| 3154 | if (planSidebarOpen) { | |
| 3155 | dismissPlanSidebarForCurrentTurn(); | |
| 3156 | } else if (activeThreadKey) { | |
| 3157 | clearPlanSidebarDismissal(activeThreadKey); | |
| 3158 | } | |
| 3159 | useRightPanelStore.getState().toggle(activeThreadRef, "plan"); | |
| 3160 | }, [activeThreadKey, activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); | |
| 3161 | const closePlanSidebar = useCallback(() => { | |
| 3162 | if (!activeThreadRef) return; | |
| 3163 | setMaximizedRightPanelThreadKey(null); | |
| 3164 | useRightPanelStore.getState().close(activeThreadRef); | |
| 3165 | dismissPlanSidebarForCurrentTurn(); | |
| 3166 | }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); | |
| 3167 | 3117 | const createBrowserSurface = useCallback(() => { |
| 3168 | 3118 | if (!activeThreadRef) return; |
| 3169 | 3119 | void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); |
| 3170 | 3120 | }, [activeThreadRef, openPreview]); |
| 3171 | 3121 | const addDiffSurface = useCallback(() => { |
| 3172 | 3122 | if (!activeThreadRef || !isServerThread || !isGitRepo) return; |
| 3173 | if (planSidebarOpen) { | |
| 3174 | dismissPlanSidebarForCurrentTurn(); | |
| 3175 | } | |
| 3176 | 3123 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| 3177 | 3124 | onDiffPanelOpen?.(); |
| 3178 | }, [ | |
| 3179 | activeThreadRef, | |
| 3180 | dismissPlanSidebarForCurrentTurn, | |
| 3181 | isGitRepo, | |
| 3182 | isServerThread, | |
| 3183 | onDiffPanelOpen, | |
| 3184 | planSidebarOpen, | |
| 3185 | ]); | |
| 3125 | }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); | |
| 3186 | 3126 | const addFilesSurface = useCallback(() => { |
| 3187 | 3127 | if (!activeThreadRef || !activeProject) return; |
| 3188 | 3128 | useRightPanelStore.getState().open(activeThreadRef, "files"); |
| ... | ... | @@ -3318,11 +3258,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 3318 | 3258 | const activateRightPanelSurface = useCallback( |
| 3319 | 3259 | (surface: RightPanelSurface) => { |
| 3320 | 3260 | if (!activeThreadRef) return; |
| 3321 | if (surface.kind === "plan") { | |
| 3322 | clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); | |
| 3323 | } else if (planSidebarOpen) { | |
| 3324 | dismissPlanSidebarForCurrentTurn(); | |
| 3325 | } | |
| 3326 | 3261 | useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); |
| 3327 | 3262 | if (surface.kind === "preview" && surface.resourceId) { |
| 3328 | 3263 | setActivePreviewTab(activeThreadRef, surface.resourceId); |
| ... | ... | @@ -3334,20 +3269,16 @@ function ChatViewContent(props: ChatViewProps) { |
| 3334 | 3269 | onDiffPanelOpen?.(); |
| 3335 | 3270 | } |
| 3336 | 3271 | }, |
| 3337 | [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], | |
| 3272 | [activeThreadRef, diffOpen, onDiffPanelOpen], | |
| 3338 | 3273 | ); |
| 3339 | 3274 | const toggleRightPanel = useCallback(() => { |
| 3340 | 3275 | if (!activeThreadRef) return; |
| 3341 | 3276 | if (rightPanelOpen) { |
| 3342 | if (planSidebarOpen) { | |
| 3343 | closePlanSidebar(); | |
| 3344 | } else { | |
| 3345 | closePreviewPanel(); | |
| 3346 | } | |
| 3277 | closePreviewPanel(); | |
| 3347 | 3278 | return; |
| 3348 | 3279 | } |
| 3349 | 3280 | useRightPanelStore.getState().toggleVisibility(activeThreadRef); |
| 3350 | }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); | |
| 3281 | }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); | |
| 3351 | 3282 | const toggleRightPanelMaximized = useCallback(() => { |
| 3352 | 3283 | if (!canMaximizeRightPanel) return; |
| 3353 | 3284 | setMaximizedRightPanelThreadKey((threadKey) => |
| ... | ... | @@ -3357,10 +3288,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 3357 | 3288 | const cleanupRightPanelSurfaces = useCallback( |
| 3358 | 3289 | (surfaces: readonly RightPanelSurface[]) => { |
| 3359 | 3290 | if (!activeThreadRef) return; |
| 3360 | if (surfaces.some((surface) => surface.kind === "plan")) { | |
| 3361 | dismissPlanSidebarForCurrentTurn(); | |
| 3362 | } | |
| 3363 | ||
| 3364 | 3291 | for (const surface of surfaces) { |
| 3365 | 3292 | if (surface.kind === "preview" && surface.resourceId) { |
| 3366 | 3293 | void closePreviewSession({ |
| ... | ... | @@ -3386,7 +3313,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 3386 | 3313 | activePreviewState.sessions, |
| 3387 | 3314 | closePreview, |
| 3388 | 3315 | closeTerminalMutation, |
| 3389 | dismissPlanSidebarForCurrentTurn, | |
| 3390 | 3316 | storeCloseTerminal, |
| 3391 | 3317 | ], |
| 3392 | 3318 | ); |
| ... | ... | @@ -3976,37 +3902,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 3976 | 3902 | activeTimelineAnchorIndexRef.current = null; |
| 3977 | 3903 | showScrollDebouncer.current.cancel(); |
| 3978 | 3904 | setShowScrollToBottom(false); |
| 3979 | if (planSidebarOpenOnNextThreadRef.current) { | |
| 3980 | planSidebarOpenOnNextThreadRef.current = false; | |
| 3981 | if (activeThreadRef) { | |
| 3982 | clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); | |
| 3983 | useRightPanelStore.getState().open(activeThreadRef, "plan"); | |
| 3984 | } | |
| 3985 | } | |
| 3986 | 3905 | // activeThreadRef resets transitively with the active thread. |
| 3987 | 3906 | }, [activeThread?.id]); |
| 3988 | 3907 | |
| 3989 | // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. | |
| 3990 | // Don't auto-open for plans carried over from a previous turn (the user can open manually). | |
| 3991 | useEffect(() => { | |
| 3992 | if (!autoOpenPlanSidebar) return; | |
| 3993 | if (!activePlan) return; | |
| 3994 | if (planSidebarOpen) return; | |
| 3995 | const latestTurnId = activeLatestTurn?.turnId ?? null; | |
| 3996 | if (latestTurnId && activePlan.turnId !== latestTurnId) return; | |
| 3997 | const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | |
| 3998 | if (!activeThreadRef) return; | |
| 3999 | if (isPlanSidebarDismissedForTurn(scopedThreadKey(activeThreadRef), turnKey)) return; | |
| 4000 | useRightPanelStore.getState().open(activeThreadRef, "plan"); | |
| 4001 | }, [ | |
| 4002 | activePlan, | |
| 4003 | activeLatestTurn?.turnId, | |
| 4004 | activeThreadRef, | |
| 4005 | autoOpenPlanSidebar, | |
| 4006 | planSidebarOpen, | |
| 4007 | sidebarProposedPlan?.turnId, | |
| 4008 | ]); | |
| 4009 | ||
| 4010 | 3908 | useEffect(() => { |
| 4011 | 3909 | setIsRevertingCheckpoint(false); |
| 4012 | 3910 | }, [activeThread?.id]); |
| ... | ... | @@ -5556,15 +5454,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 5556 | 5454 | |
| 5557 | 5455 | if (failure === null) { |
| 5558 | 5456 | acknowledgeActiveThreadWoke(); |
| 5559 | // Optimistically open the plan sidebar when implementing (not refining). | |
| 5560 | // "default" mode here means the agent is executing the plan, which produces | |
| 5561 | // step-tracking activities that the sidebar will display. | |
| 5562 | if (nextInteractionMode === "default" && autoOpenPlanSidebar) { | |
| 5563 | if (activeThreadRef) { | |
| 5564 | clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); | |
| 5565 | useRightPanelStore.getState().open(activeThreadRef, "plan"); | |
| 5566 | } | |
| 5567 | } | |
| 5568 | 5457 | sendInFlightRef.current = false; |
| 5569 | 5458 | return; |
| 5570 | 5459 | } |
| ... | ... | @@ -5597,7 +5486,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 5597 | 5486 | setComposerDraftInteractionMode, |
| 5598 | 5487 | setThreadError, |
| 5599 | 5488 | startThreadTurn, |
| 5600 | autoOpenPlanSidebar, | |
| 5601 | 5489 | environmentId, |
| 5602 | 5490 | composerRef, |
| 5603 | 5491 | ], |
| ... | ... | @@ -5700,8 +5588,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 5700 | 5588 | } |
| 5701 | 5589 | |
| 5702 | 5590 | if (failure === null) { |
| 5703 | // Signal that the plan sidebar should open on the new thread when enabled. | |
| 5704 | planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; | |
| 5705 | 5591 | const navigateResult = await settlePromise(() => |
| 5706 | 5592 | navigate({ |
| 5707 | 5593 | to: "/$environmentId/$threadId", |
| ... | ... | @@ -5758,7 +5644,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 5758 | 5644 | resetLocalDispatch, |
| 5759 | 5645 | runtimeMode, |
| 5760 | 5646 | startThreadTurn, |
| 5761 | autoOpenPlanSidebar, | |
| 5762 | 5647 | environmentId, |
| 5763 | 5648 | composerRef, |
| 5764 | 5649 | ]); |
| ... | ... | @@ -5950,12 +5835,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 5950 | 5835 | <div |
| 5951 | 5836 | className={cn( |
| 5952 | 5837 | "workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]", |
| 5953 | rightPanelOpen && !shouldUsePlanSidebarSheet | |
| 5838 | rightPanelOpen && !shouldUseRightPanelSheet | |
| 5954 | 5839 | ? "right-2 wco:right-[var(--workspace-controls-right)]" |
| 5955 | 5840 | : "mr-px", |
| 5956 | 5841 | )} |
| 5957 | 5842 | > |
| 5958 | {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( | |
| 5843 | {rightPanelOpen && !shouldUseRightPanelSheet ? ( | |
| 5959 | 5844 | <RightPanelMaximizeControl |
| 5960 | 5845 | maximized={rightPanelMaximized} |
| 5961 | 5846 | onToggle={toggleRightPanelMaximized} |
| ... | ... | @@ -6005,18 +5890,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 6005 | 5890 | initialGitScope={initialDiffPanelGitScope} |
| 6006 | 5891 | /> |
| 6007 | 5892 | </Suspense> |
| 6008 | ) : activeRightPanelSurface?.kind === "plan" ? ( | |
| 6009 | <PlanSidebar | |
| 6010 | activePlan={activePlan} | |
| 6011 | activeProposedPlan={sidebarProposedPlan} | |
| 6012 | label={planSidebarLabel} | |
| 6013 | environmentId={environmentId} | |
| 6014 | threadRef={activeThreadRef} | |
| 6015 | markdownCwd={gitCwd ?? undefined} | |
| 6016 | workspaceRoot={activeWorkspaceRoot} | |
| 6017 | timestampFormat={timestampFormat} | |
| 6018 | mode="embedded" | |
| 6019 | /> | |
| 6020 | 5893 | ) : activeRightPanelSurface?.kind === "agents" ? ( |
| 6021 | 5894 | <AgentsPanel |
| 6022 | 5895 | model={agentPanelModel} |
| ... | ... | @@ -6050,7 +5923,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6050 | 5923 | |
| 6051 | 5924 | return ( |
| 6052 | 5925 | <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"> |
| 6053 | {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} | |
| 5926 | {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null} | |
| 6054 | 5927 | <div |
| 6055 | 5928 | className={cn( |
| 6056 | 5929 | "flex min-h-0 min-w-0 flex-col overflow-x-hidden", |
| ... | ... | @@ -6122,6 +5995,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6122 | 5995 | onOpenAgents={addAgentsSurface} |
| 6123 | 5996 | key={activeThread.id} |
| 6124 | 5997 | isWorking={isWorking} |
| 5998 | workingStepLabel={workingStepLabel} | |
| 6125 | 5999 | activeTurnInProgress={isWorking || !latestTurnSettled} |
| 6126 | 6000 | activeTurnStartedAt={activeWorkStartedAt} |
| 6127 | 6001 | listRef={legendListRef} |
| ... | ... | @@ -6264,10 +6138,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 6264 | 6138 | respondingRequestIds={respondingRequestIds} |
| 6265 | 6139 | showPlanFollowUpPrompt={showPlanFollowUpPrompt} |
| 6266 | 6140 | activeProposedPlan={activeProposedPlan} |
| 6267 | activePlan={activePlan as { turnId?: TurnId } | null} | |
| 6268 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} | |
| 6269 | planSidebarLabel={planSidebarLabel} | |
| 6270 | planSidebarOpen={planSidebarOpen} | |
| 6271 | 6141 | runtimeMode={runtimeMode} |
| 6272 | 6142 | interactionMode={interactionMode} |
| 6273 | 6143 | lockedProvider={lockedProvider} |
| ... | ... | @@ -6305,7 +6175,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 6305 | 6175 | toggleInteractionMode={toggleInteractionMode} |
| 6306 | 6176 | handleRuntimeModeChange={handleRuntimeModeChange} |
| 6307 | 6177 | handleInteractionModeChange={handleInteractionModeChange} |
| 6308 | togglePlanSidebar={togglePlanSidebar} | |
| 6309 | 6178 | focusComposer={focusComposer} |
| 6310 | 6179 | scheduleComposerFocus={scheduleComposerFocus} |
| 6311 | 6180 | setThreadError={setThreadError} |
| ... | ... | @@ -6440,7 +6309,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6440 | 6309 | ))} |
| 6441 | 6310 | </div> |
| 6442 | 6311 | |
| 6443 | {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( | |
| 6312 | {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( | |
| 6444 | 6313 | <RightPanelTabs |
| 6445 | 6314 | mode="inline" |
| 6446 | 6315 | maximized={rightPanelMaximized} |
| ... | ... | @@ -6467,8 +6336,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 6467 | 6336 | {rightPanelContent} |
| 6468 | 6337 | </RightPanelTabs> |
| 6469 | 6338 | ) : null} |
| 6470 | {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( | |
| 6471 | <RightPanelSheet open onClose={planSidebarOpen ? closePlanSidebar : closePreviewPanel}> | |
| 6339 | {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( | |
| 6340 | <RightPanelSheet open onClose={closePreviewPanel}> | |
| 6472 | 6341 | <RightPanelTabs |
| 6473 | 6342 | mode="sheet" |
| 6474 | 6343 | layoutControls={panelToggleControls} |
apps/web/src/components/PlanSidebar.tsx deleted-284| ... | ... | @@ -1,284 +0,0 @@ |
| 1 | import { memo, useState, useCallback } from "react"; | |
| 2 | import { | |
| 3 | isAtomCommandInterrupted, | |
| 4 | squashAtomCommandFailure, | |
| 5 | } from "@t3tools/client-runtime/state/runtime"; | |
| 6 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 7 | import { type TimestampFormat } from "@t3tools/contracts/settings"; | |
| 8 | import { Badge } from "./ui/badge"; | |
| 9 | import { Button } from "./ui/button"; | |
| 10 | import { ScrollArea } from "./ui/scroll-area"; | |
| 11 | import ChatMarkdown from "./ChatMarkdown"; | |
| 12 | import { | |
| 13 | CheckIcon, | |
| 14 | ChevronDownIcon, | |
| 15 | ChevronRightIcon, | |
| 16 | EllipsisIcon, | |
| 17 | LoaderIcon, | |
| 18 | } from "lucide-react"; | |
| 19 | import { cn } from "~/lib/utils"; | |
| 20 | import type { ActivePlanState } from "../session-logic"; | |
| 21 | import type { LatestProposedPlanState } from "../session-logic"; | |
| 22 | import { formatTimestamp } from "../timestampFormat"; | |
| 23 | import { | |
| 24 | proposedPlanTitle, | |
| 25 | buildProposedPlanMarkdownFilename, | |
| 26 | normalizePlanMarkdownForExport, | |
| 27 | downloadPlanAsTextFile, | |
| 28 | stripDisplayedPlanMarkdown, | |
| 29 | } from "../proposedPlan"; | |
| 30 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; | |
| 31 | import { projectEnvironment } from "~/state/projects"; | |
| 32 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 33 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | |
| 34 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 35 | ||
| 36 | function stepStatusIcon(status: string): React.ReactNode { | |
| 37 | if (status === "completed") { | |
| 38 | return ( | |
| 39 | <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-success/10 text-success-foreground"> | |
| 40 | <CheckIcon className="size-3" /> | |
| 41 | </span> | |
| 42 | ); | |
| 43 | } | |
| 44 | if (status === "inProgress") { | |
| 45 | return ( | |
| 46 | <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"> | |
| 47 | <LoaderIcon className="size-3 animate-spin" /> | |
| 48 | </span> | |
| 49 | ); | |
| 50 | } | |
| 51 | return ( | |
| 52 | <span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30"> | |
| 53 | <span className="size-1.5 rounded-full bg-muted-foreground/30" /> | |
| 54 | </span> | |
| 55 | ); | |
| 56 | } | |
| 57 | ||
| 58 | interface PlanSidebarProps { | |
| 59 | activePlan: ActivePlanState | null; | |
| 60 | activeProposedPlan: LatestProposedPlanState | null; | |
| 61 | label?: string; | |
| 62 | environmentId: EnvironmentId; | |
| 63 | threadRef?: ScopedThreadRef | undefined; | |
| 64 | markdownCwd: string | undefined; | |
| 65 | workspaceRoot: string | undefined; | |
| 66 | timestampFormat: TimestampFormat; | |
| 67 | mode?: "sheet" | "sidebar" | "embedded"; | |
| 68 | } | |
| 69 | ||
| 70 | const PlanSidebar = memo(function PlanSidebar({ | |
| 71 | activePlan, | |
| 72 | activeProposedPlan, | |
| 73 | label = "Plan", | |
| 74 | environmentId, | |
| 75 | threadRef, | |
| 76 | markdownCwd, | |
| 77 | workspaceRoot, | |
| 78 | timestampFormat, | |
| 79 | mode = "sidebar", | |
| 80 | }: PlanSidebarProps) { | |
| 81 | const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); | |
| 82 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); | |
| 83 | const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { | |
| 84 | reportFailure: false, | |
| 85 | }); | |
| 86 | const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); | |
| 87 | ||
| 88 | const planMarkdown = activeProposedPlan?.planMarkdown ?? null; | |
| 89 | const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; | |
| 90 | const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; | |
| 91 | ||
| 92 | const handleCopyPlan = useCallback(() => { | |
| 93 | if (!planMarkdown) return; | |
| 94 | copyToClipboard(planMarkdown); | |
| 95 | }, [planMarkdown, copyToClipboard]); | |
| 96 | ||
| 97 | const handleDownload = useCallback(() => { | |
| 98 | if (!planMarkdown) return; | |
| 99 | const filename = buildProposedPlanMarkdownFilename(planMarkdown); | |
| 100 | downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); | |
| 101 | }, [planMarkdown]); | |
| 102 | ||
| 103 | const handleSaveToWorkspace = useCallback(() => { | |
| 104 | if (!workspaceRoot || !planMarkdown) return; | |
| 105 | const filename = buildProposedPlanMarkdownFilename(planMarkdown); | |
| 106 | setIsSavingToWorkspace(true); | |
| 107 | void (async () => { | |
| 108 | const result = await writeProjectFile({ | |
| 109 | environmentId, | |
| 110 | input: { | |
| 111 | cwd: workspaceRoot, | |
| 112 | relativePath: filename, | |
| 113 | contents: normalizePlanMarkdownForExport(planMarkdown), | |
| 114 | }, | |
| 115 | }); | |
| 116 | setIsSavingToWorkspace(false); | |
| 117 | if (result._tag === "Success") { | |
| 118 | toastManager.add({ | |
| 119 | type: "success", | |
| 120 | title: "Plan saved", | |
| 121 | description: result.value.relativePath, | |
| 122 | }); | |
| 123 | return; | |
| 124 | } | |
| 125 | if (!isAtomCommandInterrupted(result)) { | |
| 126 | const error = squashAtomCommandFailure(result); | |
| 127 | toastManager.add( | |
| 128 | stackedThreadToast({ | |
| 129 | type: "error", | |
| 130 | title: "Could not save plan", | |
| 131 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 132 | }), | |
| 133 | ); | |
| 134 | } | |
| 135 | })(); | |
| 136 | }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); | |
| 137 | ||
| 138 | return ( | |
| 139 | <div | |
| 140 | className={cn( | |
| 141 | "flex min-h-0 flex-col bg-card/50", | |
| 142 | mode === "sidebar" | |
| 143 | ? "h-full w-[340px] shrink-0 border-l border-border/70" | |
| 144 | : "h-full w-full", | |
| 145 | )} | |
| 146 | > | |
| 147 | {/* Header */} | |
| 148 | <div className="flex h-12 shrink-0 items-center justify-between border-b border-border/60 px-3"> | |
| 149 | <div className="flex items-center gap-2"> | |
| 150 | <Badge | |
| 151 | variant="info" | |
| 152 | size="sm" | |
| 153 | className="rounded-md px-1.5 py-0 font-semibold tracking-wide uppercase" | |
| 154 | > | |
| 155 | {label} | |
| 156 | </Badge> | |
| 157 | {activePlan ? ( | |
| 158 | <span className="text-[11px] text-muted-foreground/60 tabular-nums"> | |
| 159 | {formatTimestamp(activePlan.createdAt, timestampFormat)} | |
| 160 | </span> | |
| 161 | ) : null} | |
| 162 | </div> | |
| 163 | <div className="flex items-center gap-1"> | |
| 164 | {planMarkdown ? ( | |
| 165 | <Menu> | |
| 166 | <MenuTrigger | |
| 167 | render={ | |
| 168 | <Button | |
| 169 | size="icon-xs" | |
| 170 | variant="ghost" | |
| 171 | className="text-muted-foreground/50 hover:text-foreground/70" | |
| 172 | aria-label="Plan actions" | |
| 173 | /> | |
| 174 | } | |
| 175 | > | |
| 176 | <EllipsisIcon className="size-3.5" /> | |
| 177 | </MenuTrigger> | |
| 178 | <MenuPopup align="end"> | |
| 179 | <MenuItem onClick={handleCopyPlan}> | |
| 180 | {isCopied ? "Copied!" : "Copy to clipboard"} | |
| 181 | </MenuItem> | |
| 182 | <MenuItem onClick={handleDownload}>Download as markdown</MenuItem> | |
| 183 | <MenuItem | |
| 184 | onClick={handleSaveToWorkspace} | |
| 185 | disabled={!workspaceRoot || isSavingToWorkspace} | |
| 186 | > | |
| 187 | Save to workspace | |
| 188 | </MenuItem> | |
| 189 | </MenuPopup> | |
| 190 | </Menu> | |
| 191 | ) : null} | |
| 192 | </div> | |
| 193 | </div> | |
| 194 | ||
| 195 | {/* Content */} | |
| 196 | <ScrollArea className="min-h-0 flex-1"> | |
| 197 | <div className="p-3 space-y-4"> | |
| 198 | {/* Explanation */} | |
| 199 | {activePlan?.explanation ? ( | |
| 200 | <p className="text-[13px] leading-relaxed text-muted-foreground/80"> | |
| 201 | {activePlan.explanation} | |
| 202 | </p> | |
| 203 | ) : null} | |
| 204 | ||
| 205 | {/* Plan Steps */} | |
| 206 | {activePlan && activePlan.steps.length > 0 ? ( | |
| 207 | <div className="space-y-1"> | |
| 208 | <p className="mb-2 text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase"> | |
| 209 | Steps | |
| 210 | </p> | |
| 211 | {activePlan.steps.map((step) => ( | |
| 212 | <div | |
| 213 | key={`${step.status}:${step.step}`} | |
| 214 | className={cn( | |
| 215 | "flex items-center gap-2.5 rounded-lg px-2.5 py-2 transition-colors duration-200", | |
| 216 | step.status === "inProgress" && "bg-blue-500/5", | |
| 217 | step.status === "completed" && "bg-emerald-500/5", | |
| 218 | )} | |
| 219 | > | |
| 220 | {stepStatusIcon(step.status)} | |
| 221 | <p | |
| 222 | className={cn( | |
| 223 | "text-[13px] leading-snug", | |
| 224 | step.status === "completed" | |
| 225 | ? "text-muted-foreground/50 line-through decoration-muted-foreground/20" | |
| 226 | : step.status === "inProgress" | |
| 227 | ? "text-foreground/90" | |
| 228 | : "text-muted-foreground/70", | |
| 229 | )} | |
| 230 | > | |
| 231 | {step.step} | |
| 232 | </p> | |
| 233 | </div> | |
| 234 | ))} | |
| 235 | </div> | |
| 236 | ) : null} | |
| 237 | ||
| 238 | {/* Proposed Plan Markdown */} | |
| 239 | {planMarkdown ? ( | |
| 240 | <div className="space-y-2"> | |
| 241 | <button | |
| 242 | type="button" | |
| 243 | className="group flex w-full items-center gap-1.5 text-left" | |
| 244 | onClick={() => setProposedPlanExpanded((v) => !v)} | |
| 245 | > | |
| 246 | {proposedPlanExpanded ? ( | |
| 247 | <ChevronDownIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" /> | |
| 248 | ) : ( | |
| 249 | <ChevronRightIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" /> | |
| 250 | )} | |
| 251 | <span className="text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase group-hover:text-muted-foreground/60"> | |
| 252 | {planTitle ?? "Full Plan"} | |
| 253 | </span> | |
| 254 | </button> | |
| 255 | {proposedPlanExpanded ? ( | |
| 256 | <div className="rounded-lg border border-border/50 bg-background/50 p-3"> | |
| 257 | <ChatMarkdown | |
| 258 | text={displayedPlanMarkdown ?? ""} | |
| 259 | cwd={markdownCwd} | |
| 260 | threadRef={threadRef} | |
| 261 | isStreaming={false} | |
| 262 | /> | |
| 263 | </div> | |
| 264 | ) : null} | |
| 265 | </div> | |
| 266 | ) : null} | |
| 267 | ||
| 268 | {/* Empty state */} | |
| 269 | {!activePlan && !planMarkdown ? ( | |
| 270 | <div className="flex flex-col items-center justify-center py-12 text-center"> | |
| 271 | <p className="text-[13px] text-muted-foreground/40">No active plan yet.</p> | |
| 272 | <p className="mt-1 text-[11px] text-muted-foreground/30"> | |
| 273 | Plans will appear here when generated. | |
| 274 | </p> | |
| 275 | </div> | |
| 276 | ) : null} | |
| 277 | </div> | |
| 278 | </ScrollArea> | |
| 279 | </div> | |
| 280 | ); | |
| 281 | }); | |
| 282 | ||
| 283 | export default PlanSidebar; | |
| 284 | export type { PlanSidebarProps }; |
apps/web/src/components/RightPanelTabs.tsx+1-5| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; |
| 2 | 2 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 3 | import { Bot, ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; | |
| 3 | import { Bot, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; | |
| 4 | 4 | import { |
| 5 | 5 | type MouseEvent as ReactMouseEvent, |
| 6 | 6 | type ReactElement, |
| ... | ... | @@ -213,8 +213,6 @@ function surfaceTitle( |
| 213 | 213 | terminalLabelsById.get(surface.activeTerminalId) ?? |
| 214 | 214 | getTerminalLabel(surface.activeTerminalId) |
| 215 | 215 | ); |
| 216 | case "plan": | |
| 217 | return "Plan"; | |
| 218 | 216 | case "agents": |
| 219 | 217 | return "Agents"; |
| 220 | 218 | case "preview": { |
| ... | ... | @@ -276,8 +274,6 @@ function SurfaceIcon({ |
| 276 | 274 | ); |
| 277 | 275 | case "terminal": |
| 278 | 276 | return <TerminalSquare className="size-3 shrink-0" />; |
| 279 | case "plan": | |
| 280 | return <ClipboardList className="size-3 shrink-0" />; | |
| 281 | 277 | case "agents": |
| 282 | 278 | return <Bot className="size-3 shrink-0" />; |
| 283 | 279 | } |
apps/web/src/components/SidebarV2.tsx+12-1| ... | ... | @@ -1098,7 +1098,18 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 1098 | 1098 | ) : null} |
| 1099 | 1099 | </div> |
| 1100 | 1100 | <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75"> |
| 1101 | {thread.branch ? ( | |
| 1101 | {/* While working, the current plan step outranks the branch: | |
| 1102 | it's the one line that says what the thread is doing. */} | |
| 1103 | {status === "working" && thread.planProgress ? ( | |
| 1104 | <span className="min-w-0 flex-1 truncate whitespace-nowrap"> | |
| 1105 | {thread.planProgress.step} | |
| 1106 | {/* Completed count, matching the transcript chip's n/m. */} | |
| 1107 | <span className="text-muted-foreground/50 tabular-nums"> | |
| 1108 | {" "} | |
| 1109 | {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} | |
| 1110 | </span> | |
| 1111 | </span> | |
| 1112 | ) : thread.branch ? ( | |
| 1102 | 1113 | <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span> |
| 1103 | 1114 | ) : ( |
| 1104 | 1115 | <span className="flex-1" /> |
apps/web/src/components/chat/ChatComposer.tsx-58| ... | ... | @@ -10,7 +10,6 @@ import type { |
| 10 | 10 | ScopedThreadRef, |
| 11 | 11 | ServerProvider, |
| 12 | 12 | ThreadId, |
| 13 | TurnId, | |
| 14 | 13 | } from "@t3tools/contracts"; |
| 15 | 14 | import { |
| 16 | 15 | ProviderDriverKind, |
| ... | ... | @@ -195,7 +194,6 @@ import { toastManager } from "../ui/toast"; |
| 195 | 194 | import { |
| 196 | 195 | BotIcon, |
| 197 | 196 | CircleAlertIcon, |
| 198 | ListTodoIcon, | |
| 199 | 197 | PencilRulerIcon, |
| 200 | 198 | type LucideIcon, |
| 201 | 199 | LockIcon, |
| ... | ... | @@ -300,12 +298,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 300 | 298 | showInteractionModeToggle: boolean; |
| 301 | 299 | interactionMode: ProviderInteractionMode; |
| 302 | 300 | runtimeMode: RuntimeMode; |
| 303 | showPlanToggle: boolean; | |
| 304 | planSidebarLabel: string; | |
| 305 | planSidebarOpen: boolean; | |
| 306 | 301 | onToggleInteractionMode: () => void; |
| 307 | 302 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| 308 | onTogglePlanSidebar: () => void; | |
| 309 | 303 | }) { |
| 310 | 304 | const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; |
| 311 | 305 | const RuntimeModeIcon = runtimeModeOption.icon; |
| ... | ... | @@ -313,9 +307,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 313 | 307 | props.interactionMode === "plan" |
| 314 | 308 | ? "Plan mode — click to return to normal build mode" |
| 315 | 309 | : "Default mode — click to enter plan mode"; |
| 316 | const planSidebarTooltip = props.planSidebarOpen | |
| 317 | ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` | |
| 318 | : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`; | |
| 319 | 310 | |
| 320 | 311 | const interactionModeToggle = props.showInteractionModeToggle ? ( |
| 321 | 312 | <> |
| ... | ... | @@ -391,36 +382,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 391 | 382 | </Tooltip> |
| 392 | 383 | |
| 393 | 384 | {interactionModeToggle} |
| 394 | ||
| 395 | {props.showPlanToggle ? ( | |
| 396 | <> | |
| 397 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 398 | <Tooltip> | |
| 399 | <TooltipTrigger | |
| 400 | render={ | |
| 401 | <ComposerControl | |
| 402 | className={cn( | |
| 403 | "shrink-0 whitespace-nowrap", | |
| 404 | props.planSidebarOpen | |
| 405 | ? "bg-blue-500/10 text-blue-400 hover:bg-blue-500/15 hover:text-blue-300" | |
| 406 | : "text-muted-foreground/70 hover:text-foreground/80", | |
| 407 | )} | |
| 408 | type="button" | |
| 409 | onClick={props.onTogglePlanSidebar} | |
| 410 | aria-label={planSidebarTooltip} | |
| 411 | /> | |
| 412 | } | |
| 413 | > | |
| 414 | <ComposerControlIcon | |
| 415 | icon={ListTodoIcon} | |
| 416 | className={props.planSidebarOpen ? "text-current opacity-100" : undefined} | |
| 417 | /> | |
| 418 | <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span> | |
| 419 | </TooltipTrigger> | |
| 420 | <TooltipPopup side="top">{planSidebarTooltip}</TooltipPopup> | |
| 421 | </Tooltip> | |
| 422 | </> | |
| 423 | ) : null} | |
| 424 | 385 | </> |
| 425 | 386 | ); |
| 426 | 387 | }); |
| ... | ... | @@ -576,10 +537,6 @@ export interface ChatComposerProps { |
| 576 | 537 | // Plan |
| 577 | 538 | showPlanFollowUpPrompt: boolean; |
| 578 | 539 | activeProposedPlan: Thread["proposedPlans"][number] | null; |
| 579 | activePlan: { turnId?: TurnId } | null; | |
| 580 | sidebarProposedPlan: { turnId?: TurnId } | null; | |
| 581 | planSidebarLabel: string; | |
| 582 | planSidebarOpen: boolean; | |
| 583 | 540 | |
| 584 | 541 | // Mode |
| 585 | 542 | runtimeMode: RuntimeMode; |
| ... | ... | @@ -632,7 +589,6 @@ export interface ChatComposerProps { |
| 632 | 589 | toggleInteractionMode: () => void; |
| 633 | 590 | handleRuntimeModeChange: (mode: RuntimeMode) => void; |
| 634 | 591 | handleInteractionModeChange: (mode: ProviderInteractionMode) => void; |
| 635 | togglePlanSidebar: () => void; | |
| 636 | 592 | |
| 637 | 593 | focusComposer: () => void; |
| 638 | 594 | scheduleComposerFocus: () => void; |
| ... | ... | @@ -675,10 +631,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 675 | 631 | respondingRequestIds, |
| 676 | 632 | showPlanFollowUpPrompt, |
| 677 | 633 | activeProposedPlan, |
| 678 | activePlan, | |
| 679 | sidebarProposedPlan, | |
| 680 | planSidebarLabel, | |
| 681 | planSidebarOpen, | |
| 682 | 634 | runtimeMode, |
| 683 | 635 | interactionMode, |
| 684 | 636 | lockedProvider, |
| ... | ... | @@ -709,7 +661,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 709 | 661 | toggleInteractionMode, |
| 710 | 662 | handleRuntimeModeChange, |
| 711 | 663 | handleInteractionModeChange, |
| 712 | togglePlanSidebar, | |
| 713 | 664 | focusComposer, |
| 714 | 665 | scheduleComposerFocus, |
| 715 | 666 | setThreadError, |
| ... | ... | @@ -1180,7 +1131,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1180 | 1131 | isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; |
| 1181 | 1132 | |
| 1182 | 1133 | const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; |
| 1183 | const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); | |
| 1184 | 1134 | const composerFooterActionLayoutKey = useMemo(() => { |
| 1185 | 1135 | if (activePendingProgress) { |
| 1186 | 1136 | return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; |
| ... | ... | @@ -3187,15 +3137,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3187 | 3137 | |
| 3188 | 3138 | {isComposerFooterCompact ? ( |
| 3189 | 3139 | <CompactComposerControlsMenu |
| 3190 | activePlan={showPlanSidebarToggle} | |
| 3191 | 3140 | interactionMode={interactionMode} |
| 3192 | planSidebarLabel={planSidebarLabel} | |
| 3193 | planSidebarOpen={planSidebarOpen} | |
| 3194 | 3141 | runtimeMode={runtimeMode} |
| 3195 | 3142 | showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} |
| 3196 | 3143 | traitsMenuContent={providerTraitsMenuContent} |
| 3197 | 3144 | onToggleInteractionMode={toggleInteractionMode} |
| 3198 | onTogglePlanSidebar={togglePlanSidebar} | |
| 3199 | 3145 | onRuntimeModeChange={handleRuntimeModeChange} |
| 3200 | 3146 | /> |
| 3201 | 3147 | ) : ( |
| ... | ... | @@ -3210,12 +3156,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3210 | 3156 | showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} |
| 3211 | 3157 | interactionMode={interactionMode} |
| 3212 | 3158 | runtimeMode={runtimeMode} |
| 3213 | showPlanToggle={showPlanSidebarToggle} | |
| 3214 | planSidebarLabel={planSidebarLabel} | |
| 3215 | planSidebarOpen={planSidebarOpen} | |
| 3216 | 3159 | onToggleInteractionMode={toggleInteractionMode} |
| 3217 | 3160 | onRuntimeModeChange={handleRuntimeModeChange} |
| 3218 | onTogglePlanSidebar={togglePlanSidebar} | |
| 3219 | 3161 | /> |
| 3220 | 3162 | </> |
| 3221 | 3163 | )} |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+1-17| ... | ... | @@ -1,10 +1,9 @@ |
| 1 | 1 | import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; |
| 2 | 2 | import { memo, type ReactNode } from "react"; |
| 3 | import { EllipsisIcon, ListTodoIcon } from "lucide-react"; | |
| 3 | import { EllipsisIcon } from "lucide-react"; | |
| 4 | 4 | import { Button } from "../ui/button"; |
| 5 | 5 | import { |
| 6 | 6 | Menu, |
| 7 | MenuItem, | |
| 8 | 7 | MenuPopup, |
| 9 | 8 | MenuRadioGroup, |
| 10 | 9 | MenuRadioItem, |
| ... | ... | @@ -13,15 +12,11 @@ import { |
| 13 | 12 | } from "../ui/menu"; |
| 14 | 13 | |
| 15 | 14 | export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { |
| 16 | activePlan: boolean; | |
| 17 | 15 | interactionMode: ProviderInteractionMode; |
| 18 | planSidebarLabel: string; | |
| 19 | planSidebarOpen: boolean; | |
| 20 | 16 | runtimeMode: RuntimeMode; |
| 21 | 17 | showInteractionModeToggle: boolean; |
| 22 | 18 | traitsMenuContent?: ReactNode; |
| 23 | 19 | onToggleInteractionMode: () => void; |
| 24 | onTogglePlanSidebar: () => void; | |
| 25 | 20 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| 26 | 21 | }) { |
| 27 | 22 | return ( |
| ... | ... | @@ -74,17 +69,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 74 | 69 | <MenuRadioItem value="auto">Auto</MenuRadioItem> |
| 75 | 70 | <MenuRadioItem value="full-access">Full access</MenuRadioItem> |
| 76 | 71 | </MenuRadioGroup> |
| 77 | {props.activePlan ? ( | |
| 78 | <> | |
| 79 | <MenuDivider /> | |
| 80 | <MenuItem onClick={props.onTogglePlanSidebar}> | |
| 81 | <ListTodoIcon className="size-4 shrink-0" /> | |
| 82 | {props.planSidebarOpen | |
| 83 | ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` | |
| 84 | : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`} | |
| 85 | </MenuItem> | |
| 86 | </> | |
| 87 | ) : null} | |
| 88 | 72 | </MenuPopup> |
| 89 | 73 | </Menu> |
| 90 | 74 | ); |
apps/web/src/components/chat/MessagesTimeline.logic.ts+24| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | workEntryIndicatesToolNeutralStatus, |
| 5 | 5 | workLogEntryIsToolLike, |
| 6 | 6 | type TimelineEntry, |
| 7 | type TurnPlanEntry, | |
| 7 | 8 | type WorkLogEntry, |
| 8 | 9 | } from "../../session-logic"; |
| 9 | 10 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; |
| ... | ... | @@ -201,6 +202,12 @@ export type MessagesTimelineRow = |
| 201 | 202 | createdAt: string; |
| 202 | 203 | proposedPlan: ProposedPlan; |
| 203 | 204 | } |
| 205 | | { | |
| 206 | kind: "turn-plan"; | |
| 207 | id: string; | |
| 208 | createdAt: string; | |
| 209 | turnPlan: TurnPlanEntry; | |
| 210 | } | |
| 204 | 211 | | { kind: "working"; id: string; createdAt: string | null }; |
| 205 | 212 | |
| 206 | 213 | export interface StableMessagesTimelineRowsState { |
| ... | ... | @@ -576,6 +583,16 @@ export function deriveMessagesTimelineRows(input: { |
| 576 | 583 | continue; |
| 577 | 584 | } |
| 578 | 585 | |
| 586 | if (timelineEntry.kind === "turn-plan") { | |
| 587 | nextRows.push({ | |
| 588 | kind: "turn-plan", | |
| 589 | id: timelineEntry.id, | |
| 590 | createdAt: timelineEntry.createdAt, | |
| 591 | turnPlan: timelineEntry.turnPlan, | |
| 592 | }); | |
| 593 | continue; | |
| 594 | } | |
| 595 | ||
| 579 | 596 | const assistantTurnStillInProgress = |
| 580 | 597 | timelineEntry.message.role === "assistant" && |
| 581 | 598 | unsettledTurnId !== null && |
| ... | ... | @@ -659,6 +676,13 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 659 | 676 | case "proposed-plan": |
| 660 | 677 | return a.proposedPlan === (b as typeof a).proposedPlan; |
| 661 | 678 | |
| 679 | case "turn-plan": { | |
| 680 | const bp = b as typeof a; | |
| 681 | // Plans rewrite in place: compare the snapshot's identity fields so an | |
| 682 | // unchanged plan keeps its row reference (virtualization stability). | |
| 683 | return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; | |
| 684 | } | |
| 685 | ||
| 662 | 686 | case "work": |
| 663 | 687 | return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); |
| 664 | 688 |
apps/web/src/components/chat/MessagesTimeline.tsx+115-4| ... | ... | @@ -152,6 +152,8 @@ interface TimelineRowActivityState { |
| 152 | 152 | isRevertingCheckpoint: boolean; |
| 153 | 153 | activeTurnInProgress: boolean; |
| 154 | 154 | latestTurnId: TurnId | null; |
| 155 | /** Current plan step label for the working row, when the turn has a plan. */ | |
| 156 | workingStepLabel: string | null; | |
| 155 | 157 | } |
| 156 | 158 | |
| 157 | 159 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| ... | ... | @@ -196,6 +198,7 @@ interface MessagesTimelineProps { |
| 196 | 198 | agentPanelModel?: AgentPanelModel; |
| 197 | 199 | onOpenAgents?: () => void; |
| 198 | 200 | isWorking: boolean; |
| 201 | workingStepLabel?: string | null; | |
| 199 | 202 | activeTurnInProgress: boolean; |
| 200 | 203 | activeTurnStartedAt: string | null; |
| 201 | 204 | listRef: React.RefObject<LegendListRef | null>; |
| ... | ... | @@ -240,6 +243,7 @@ interface MessagesTimelineProps { |
| 240 | 243 | |
| 241 | 244 | export const MessagesTimeline = memo(function MessagesTimeline({ |
| 242 | 245 | isWorking, |
| 246 | workingStepLabel = null, | |
| 243 | 247 | activeTurnInProgress, |
| 244 | 248 | activeTurnStartedAt, |
| 245 | 249 | agentPanelModel = EMPTY_AGENT_PANEL_MODEL, |
| ... | ... | @@ -508,8 +512,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 508 | 512 | isRevertingCheckpoint, |
| 509 | 513 | activeTurnInProgress, |
| 510 | 514 | latestTurnId: latestTurn?.turnId ?? null, |
| 515 | workingStepLabel, | |
| 511 | 516 | }), |
| 512 | [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], | |
| 517 | [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel], | |
| 513 | 518 | ); |
| 514 | 519 | |
| 515 | 520 | // Stable renderItem — no closure deps. Row components read shared state |
| ... | ... | @@ -911,7 +916,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 911 | 916 | // they sit closer to the work that follows them. |
| 912 | 917 | (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || |
| 913 | 918 | row.kind === "work" || |
| 914 | row.kind === "work-toggle" | |
| 919 | row.kind === "work-toggle" || | |
| 920 | row.kind === "turn-plan" | |
| 915 | 921 | ? "pb-2" |
| 916 | 922 | : "pb-4", |
| 917 | 923 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, |
| ... | ... | @@ -929,6 +935,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 929 | 935 | <AssistantTimelineRow row={row} /> |
| 930 | 936 | ) : null} |
| 931 | 937 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 938 | {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null} | |
| 932 | 939 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} |
| 933 | 940 | </div> |
| 934 | 941 | ); |
| ... | ... | @@ -1159,16 +1166,117 @@ function ProposedPlanTimelineRow({ |
| 1159 | 1166 | ); |
| 1160 | 1167 | } |
| 1161 | 1168 | |
| 1169 | /** | |
| 1170 | * Inline folded plan chip: one row per turn that produced plan/todo steps. | |
| 1171 | * Collapsed by default — a segment bar plus the in-progress step label — | |
| 1172 | * and expands in place to the full step list. Replaces the old plan sidebar. | |
| 1173 | */ | |
| 1174 | const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ | |
| 1175 | row, | |
| 1176 | }: { | |
| 1177 | row: Extract<TimelineRow, { kind: "turn-plan" }>; | |
| 1178 | }) { | |
| 1179 | const [expanded, setExpanded] = useState(false); | |
| 1180 | const { steps } = row.turnPlan.plan; | |
| 1181 | const completedCount = steps.filter((step) => step.status === "completed").length; | |
| 1182 | const allDone = completedCount === steps.length; | |
| 1183 | // Label priority: the in-progress step, else the next pending step (plan | |
| 1184 | // just created), else the last step (plan finished, rendered muted). | |
| 1185 | const label = | |
| 1186 | steps.find((step) => step.status === "inProgress")?.step ?? | |
| 1187 | steps.find((step) => step.status === "pending")?.step ?? | |
| 1188 | steps.at(-1)?.step ?? | |
| 1189 | "Plan"; | |
| 1190 | const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon; | |
| 1191 | ||
| 1192 | return ( | |
| 1193 | <div className="min-w-0 px-1 py-0.5"> | |
| 1194 | <button | |
| 1195 | type="button" | |
| 1196 | className="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" | |
| 1197 | aria-expanded={expanded} | |
| 1198 | onClick={() => setExpanded((value) => !value)} | |
| 1199 | > | |
| 1200 | <Chevron className="size-3.5 shrink-0 text-muted-foreground/65" /> | |
| 1201 | {steps.length > 1 ? ( | |
| 1202 | <span aria-hidden className="flex shrink-0 items-center gap-0.5"> | |
| 1203 | {steps.map((step) => ( | |
| 1204 | <span | |
| 1205 | key={step.step} | |
| 1206 | className={cn( | |
| 1207 | "h-[3px] w-2.5 rounded-full", | |
| 1208 | step.status === "completed" | |
| 1209 | ? "bg-success" | |
| 1210 | : step.status === "inProgress" | |
| 1211 | ? "bg-primary" | |
| 1212 | : "bg-muted-foreground/25", | |
| 1213 | )} | |
| 1214 | /> | |
| 1215 | ))} | |
| 1216 | </span> | |
| 1217 | ) : null} | |
| 1218 | <span | |
| 1219 | className={cn( | |
| 1220 | "min-w-0 truncate", | |
| 1221 | allDone ? "text-muted-foreground/65" : "font-medium text-foreground/85", | |
| 1222 | )} | |
| 1223 | > | |
| 1224 | {label} | |
| 1225 | </span> | |
| 1226 | {steps.length > 1 ? ( | |
| 1227 | <span className="shrink-0 text-muted-foreground/50 tabular-nums"> | |
| 1228 | {completedCount}/{steps.length} | |
| 1229 | </span> | |
| 1230 | ) : null} | |
| 1231 | </button> | |
| 1232 | {expanded ? ( | |
| 1233 | <div className="mt-0.5 space-y-px pl-6"> | |
| 1234 | {steps.map((step) => ( | |
| 1235 | <div key={step.step} className="flex items-baseline gap-2 text-[12px] leading-5"> | |
| 1236 | <span | |
| 1237 | className={cn( | |
| 1238 | "w-3 shrink-0 text-center font-mono text-[10px]", | |
| 1239 | step.status === "completed" | |
| 1240 | ? "text-success" | |
| 1241 | : step.status === "inProgress" | |
| 1242 | ? "text-primary" | |
| 1243 | : "text-muted-foreground/40", | |
| 1244 | )} | |
| 1245 | aria-hidden | |
| 1246 | > | |
| 1247 | {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"} | |
| 1248 | </span> | |
| 1249 | <span | |
| 1250 | className={cn( | |
| 1251 | "min-w-0", | |
| 1252 | step.status === "completed" | |
| 1253 | ? "text-muted-foreground/55" | |
| 1254 | : step.status === "inProgress" | |
| 1255 | ? "text-foreground/90" | |
| 1256 | : "text-muted-foreground/70", | |
| 1257 | )} | |
| 1258 | > | |
| 1259 | {step.step} | |
| 1260 | </span> | |
| 1261 | </div> | |
| 1262 | ))} | |
| 1263 | </div> | |
| 1264 | ) : null} | |
| 1265 | </div> | |
| 1266 | ); | |
| 1267 | }); | |
| 1268 | ||
| 1162 | 1269 | function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { |
| 1270 | const { workingStepLabel } = use(TimelineRowActivityCtx); | |
| 1163 | 1271 | return ( |
| 1164 | 1272 | <div className="py-0.5 pl-1.5"> |
| 1165 | <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums"> | |
| 1273 | <div className="flex min-w-0 items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums"> | |
| 1166 | 1274 | <span className="inline-flex items-center gap-[3px]"> |
| 1167 | 1275 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" /> |
| 1168 | 1276 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" /> |
| 1169 | 1277 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" /> |
| 1170 | 1278 | </span> |
| 1171 | <span> | |
| 1279 | <span className="shrink-0"> | |
| 1172 | 1280 | {row.createdAt ? ( |
| 1173 | 1281 | <> |
| 1174 | 1282 | Working for <WorkingTimer createdAt={row.createdAt} /> |
| ... | ... | @@ -1177,6 +1285,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 1177 | 1285 | "Working..." |
| 1178 | 1286 | )} |
| 1179 | 1287 | </span> |
| 1288 | {workingStepLabel ? ( | |
| 1289 | <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span> | |
| 1290 | ) : null} | |
| 1180 | 1291 | </div> |
| 1181 | 1292 | </div> |
| 1182 | 1293 | ); |
apps/web/src/components/settings/SettingsPanels.tsx-31| ... | ... | @@ -623,9 +623,6 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 623 | 623 | ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace |
| 624 | 624 | ? ["Diff whitespace changes"] |
| 625 | 625 | : []), |
| 626 | ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar | |
| 627 | ? ["Auto-open task panel"] | |
| 628 | : []), | |
| 629 | 626 | ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming |
| 630 | 627 | ? ["Assistant output"] |
| 631 | 628 | : []), |
| ... | ... | @@ -655,7 +652,6 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 655 | 652 | [ |
| 656 | 653 | isTextGenerationModelDirty, |
| 657 | 654 | isBackgroundActivityDirty, |
| 658 | settings.autoOpenPlanSidebar, | |
| 659 | 655 | settings.confirmThreadArchive, |
| 660 | 656 | settings.confirmThreadDelete, |
| 661 | 657 | settings.addProjectBaseDirectory, |
| ... | ... | @@ -701,7 +697,6 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 701 | 697 | glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, |
| 702 | 698 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, |
| 703 | 699 | sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, |
| 704 | autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, | |
| 705 | 700 | enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, |
| 706 | 701 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, |
| 707 | 702 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, |
| ... | ... | @@ -1897,32 +1892,6 @@ export function GeneralSettingsPanel() { |
| 1897 | 1892 | } |
| 1898 | 1893 | /> |
| 1899 | 1894 | |
| 1900 | <SettingsRow | |
| 1901 | {...searchableSetting("auto-open-task-panel")} | |
| 1902 | description="Open the right-side plan and task panel automatically when steps appear." | |
| 1903 | resetAction={ | |
| 1904 | settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ( | |
| 1905 | <SettingResetButton | |
| 1906 | label="auto-open task panel" | |
| 1907 | onClick={() => | |
| 1908 | updateSettings({ | |
| 1909 | autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, | |
| 1910 | }) | |
| 1911 | } | |
| 1912 | /> | |
| 1913 | ) : null | |
| 1914 | } | |
| 1915 | control={ | |
| 1916 | <Switch | |
| 1917 | checked={settings.autoOpenPlanSidebar} | |
| 1918 | onCheckedChange={(checked) => | |
| 1919 | updateSettings({ autoOpenPlanSidebar: Boolean(checked) }) | |
| 1920 | } | |
| 1921 | aria-label="Open the task panel automatically" | |
| 1922 | /> | |
| 1923 | } | |
| 1924 | /> | |
| 1925 | ||
| 1926 | 1895 | <SettingsRow |
| 1927 | 1896 | {...searchableSetting("new-threads")} |
| 1928 | 1897 | description="Pick the default workspace mode for newly created draft threads." |
apps/web/src/components/settings/settingsSearch.ts-5| ... | ... | @@ -110,11 +110,6 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 110 | 110 | title: "Provider update checks", |
| 111 | 111 | to: "/settings/general", |
| 112 | 112 | }, |
| 113 | { | |
| 114 | id: "auto-open-task-panel", | |
| 115 | title: "Auto-open task panel", | |
| 116 | to: "/settings/general", | |
| 117 | }, | |
| 118 | 113 | { |
| 119 | 114 | id: "new-threads", |
| 120 | 115 | title: "New threads", |
apps/web/src/planSidebarDismissal.ts deleted-21| ... | ... | @@ -1,21 +0,0 @@ |
| 1 | /** | |
| 2 | * Tracks which turn's plan sidebar the user explicitly dismissed, per thread. | |
| 3 | * | |
| 4 | * Kept outside React state so a dismissal survives leaving and re-entering a | |
| 5 | * thread (ChatView resets its per-thread refs on navigation). Dismissals are | |
| 6 | * keyed by turn, so when a new turn produces fresh plan steps the sidebar | |
| 7 | * still auto-opens. | |
| 8 | */ | |
| 9 | const dismissedTurnByThreadKey = new Map<string, string>(); | |
| 10 | ||
| 11 | export function dismissPlanSidebarForTurn(threadKey: string, turnKey: string): void { | |
| 12 | dismissedTurnByThreadKey.set(threadKey, turnKey); | |
| 13 | } | |
| 14 | ||
| 15 | export function clearPlanSidebarDismissal(threadKey: string): void { | |
| 16 | dismissedTurnByThreadKey.delete(threadKey); | |
| 17 | } | |
| 18 | ||
| 19 | export function isPlanSidebarDismissedForTurn(threadKey: string, turnKey: string): boolean { | |
| 20 | return dismissedTurnByThreadKey.get(threadKey) === turnKey; | |
| 21 | } |
apps/web/src/rightPanelStore.test.ts+47-12| ... | ... | @@ -102,6 +102,41 @@ describe("rightPanelStore", () => { |
| 102 | 102 | }); |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | it("drops persisted plan surfaces and does not reopen an empty panel", () => { | |
| 106 | expect( | |
| 107 | migratePersistedRightPanelState({ | |
| 108 | byThreadKey: { | |
| 109 | "env-1:thread-A": { | |
| 110 | isOpen: true, | |
| 111 | activeSurfaceId: "plan", | |
| 112 | surfaces: [{ id: "plan", kind: "plan" }], | |
| 113 | }, | |
| 114 | "env-1:thread-B": { | |
| 115 | isOpen: true, | |
| 116 | activeSurfaceId: "plan", | |
| 117 | surfaces: [ | |
| 118 | { id: "plan", kind: "plan" }, | |
| 119 | { id: "diff", kind: "diff" }, | |
| 120 | ], | |
| 121 | }, | |
| 122 | }, | |
| 123 | }), | |
| 124 | ).toEqual({ | |
| 125 | byThreadKey: { | |
| 126 | "env-1:thread-A": { | |
| 127 | isOpen: false, | |
| 128 | activeSurfaceId: null, | |
| 129 | surfaces: [], | |
| 130 | }, | |
| 131 | "env-1:thread-B": { | |
| 132 | isOpen: true, | |
| 133 | activeSurfaceId: "diff", | |
| 134 | surfaces: [{ id: "diff", kind: "diff" }], | |
| 135 | }, | |
| 136 | }, | |
| 137 | }); | |
| 138 | }); | |
| 139 | ||
| 105 | 140 | it("open sets the active panel for a thread", () => { |
| 106 | 141 | useRightPanelStore.getState().open(refA, "preview"); |
| 107 | 142 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); |
| ... | ... | @@ -109,7 +144,7 @@ describe("rightPanelStore", () => { |
| 109 | 144 | }); |
| 110 | 145 | |
| 111 | 146 | it("opening a different kind keeps both surfaces and activates the new one", () => { |
| 112 | useRightPanelStore.getState().open(refA, "plan"); | |
| 147 | useRightPanelStore.getState().open(refA, "agents"); | |
| 113 | 148 | useRightPanelStore.getState().open(refA, "preview"); |
| 114 | 149 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); |
| 115 | 150 | expect( |
| ... | ... | @@ -119,7 +154,7 @@ describe("rightPanelStore", () => { |
| 119 | 154 | |
| 120 | 155 | it("reopening an inactive singleton activates its existing surface", () => { |
| 121 | 156 | useRightPanelStore.getState().open(refA, "diff"); |
| 122 | useRightPanelStore.getState().open(refA, "plan"); | |
| 157 | useRightPanelStore.getState().open(refA, "agents"); | |
| 123 | 158 | useRightPanelStore.getState().open(refA, "diff"); |
| 124 | 159 | |
| 125 | 160 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ |
| ... | ... | @@ -127,7 +162,7 @@ describe("rightPanelStore", () => { |
| 127 | 162 | activeSurfaceId: "diff", |
| 128 | 163 | surfaces: [ |
| 129 | 164 | { id: "diff", kind: "diff" }, |
| 130 | { id: "plan", kind: "plan" }, | |
| 165 | { id: "agents", kind: "agents" }, | |
| 131 | 166 | ], |
| 132 | 167 | }); |
| 133 | 168 | }); |
| ... | ... | @@ -207,15 +242,15 @@ describe("rightPanelStore", () => { |
| 207 | 242 | |
| 208 | 243 | it("removes persisted file surfaces when their workspace no longer exists", () => { |
| 209 | 244 | useRightPanelStore.getState().openFile(refA, "src/index.ts"); |
| 210 | useRightPanelStore.getState().open(refA, "plan"); | |
| 245 | useRightPanelStore.getState().open(refA, "agents"); | |
| 211 | 246 | useRightPanelStore.getState().openFile(refA, "README.md"); |
| 212 | 247 | |
| 213 | 248 | useRightPanelStore.getState().reconcileFileSurfaces(refA, false); |
| 214 | 249 | |
| 215 | 250 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ |
| 216 | 251 | isOpen: true, |
| 217 | activeSurfaceId: "plan", | |
| 218 | surfaces: [{ id: "plan", kind: "plan" }], | |
| 252 | activeSurfaceId: "agents", | |
| 253 | surfaces: [{ id: "agents", kind: "agents" }], | |
| 219 | 254 | }); |
| 220 | 255 | |
| 221 | 256 | useRightPanelStore.getState().openFile(refB, "conductor.json"); |
| ... | ... | @@ -228,13 +263,13 @@ describe("rightPanelStore", () => { |
| 228 | 263 | }); |
| 229 | 264 | |
| 230 | 265 | it("close hides the panel without clearing its selected surface", () => { |
| 231 | useRightPanelStore.getState().open(refA, "plan"); | |
| 266 | useRightPanelStore.getState().open(refA, "agents"); | |
| 232 | 267 | useRightPanelStore.getState().close(refA); |
| 233 | 268 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); |
| 234 | 269 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ |
| 235 | 270 | isOpen: false, |
| 236 | activeSurfaceId: "plan", | |
| 237 | surfaces: [{ id: "plan", kind: "plan" }], | |
| 271 | activeSurfaceId: "agents", | |
| 272 | surfaces: [{ id: "agents", kind: "agents" }], | |
| 238 | 273 | }); |
| 239 | 274 | }); |
| 240 | 275 | |
| ... | ... | @@ -264,12 +299,12 @@ describe("rightPanelStore", () => { |
| 264 | 299 | |
| 265 | 300 | it("toggle to a different kind switches active", () => { |
| 266 | 301 | useRightPanelStore.getState().toggle(refA, "preview"); |
| 267 | useRightPanelStore.getState().toggle(refA, "plan"); | |
| 268 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); | |
| 302 | useRightPanelStore.getState().toggle(refA, "agents"); | |
| 303 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("agents"); | |
| 269 | 304 | }); |
| 270 | 305 | |
| 271 | 306 | it("removeThread clears persisted state", () => { |
| 272 | useRightPanelStore.getState().open(refA, "plan"); | |
| 307 | useRightPanelStore.getState().open(refA, "agents"); | |
| 273 | 308 | useRightPanelStore.getState().removeThread(refA); |
| 274 | 309 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); |
| 275 | 310 | }); |
apps/web/src/rightPanelStore.ts+17-9| ... | ... | @@ -5,7 +5,7 @@ |
| 5 | 5 | * surface descriptors and the active surface, while each feature continues to |
| 6 | 6 | * own its durable resource state. Browser surfaces point at preview tab ids, |
| 7 | 7 | * terminal surfaces point at terminal session ids, file surfaces point at |
| 8 | * workspace paths, and diff/plan/files remain singleton surfaces. | |
| 8 | * workspace paths, and diff/files remain singleton surfaces. | |
| 9 | 9 | */ |
| 10 | 10 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 11 | 11 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| ... | ... | @@ -15,7 +15,6 @@ import { createJSONStorage, persist } from "zustand/middleware"; |
| 15 | 15 | import { resolveStorage } from "./lib/storage"; |
| 16 | 16 | |
| 17 | 17 | export const RIGHT_PANEL_KINDS = [ |
| 18 | "plan", | |
| 19 | 18 | "diff", |
| 20 | 19 | "files", |
| 21 | 20 | "file", |
| ... | ... | @@ -45,11 +44,11 @@ export type RightPanelSurface = |
| 45 | 44 | revealLine: number | null; |
| 46 | 45 | revealRequestId: number; |
| 47 | 46 | } |
| 48 | | { id: "plan"; kind: "plan" } | |
| 49 | 47 | | { id: "agents"; kind: "agents" }; |
| 50 | 48 | |
| 51 | 49 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; |
| 52 | const RIGHT_PANEL_STORAGE_VERSION = 8; | |
| 50 | // v9 removed the "plan" surface kind (plans render inline in the transcript). | |
| 51 | const RIGHT_PANEL_STORAGE_VERSION = 9; | |
| 53 | 52 | |
| 54 | 53 | export interface ThreadRightPanelState { |
| 55 | 54 | isOpen: boolean; |
| ... | ... | @@ -99,8 +98,6 @@ const singletonSurface = ( |
| 99 | 98 | return { id: "diff", kind }; |
| 100 | 99 | case "files": |
| 101 | 100 | return { id: "files", kind }; |
| 102 | case "plan": | |
| 103 | return { id: "plan", kind }; | |
| 104 | 101 | case "agents": |
| 105 | 102 | return { id: "agents", kind }; |
| 106 | 103 | } |
| ... | ... | @@ -181,6 +178,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): { |
| 181 | 178 | threadState && typeof threadState === "object" ? threadState : null; |
| 182 | 179 | const surfaces = Array.isArray(validThreadState?.surfaces) |
| 183 | 180 | ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => { |
| 181 | // Dropped surface kind: plans now render inline in the | |
| 182 | // transcript (v9). | |
| 183 | if ((surface as { kind?: string }).kind === "plan") return []; | |
| 184 | 184 | if (surface.kind === "file") { |
| 185 | 185 | const revealLine = |
| 186 | 186 | typeof surface.revealLine === "number" && |
| ... | ... | @@ -229,15 +229,23 @@ export function migratePersistedRightPanelState(persistedState: unknown): { |
| 229 | 229 | ]; |
| 230 | 230 | }) |
| 231 | 231 | : []; |
| 232 | const activeSurfaceId = surfaces.some( | |
| 232 | const persistedActiveSurfaceId = surfaces.some( | |
| 233 | 233 | (surface) => surface.id === validThreadState?.activeSurfaceId, |
| 234 | 234 | ) |
| 235 | 235 | ? (validThreadState?.activeSurfaceId ?? null) |
| 236 | 236 | : null; |
| 237 | // A migration that dropped every surface (e.g. plan-only panels | |
| 238 | // in v9) must not reopen an empty panel. | |
| 237 | 239 | const isOpen = |
| 238 | typeof validThreadState?.isOpen === "boolean" | |
| 240 | surfaces.length > 0 && | |
| 241 | (typeof validThreadState?.isOpen === "boolean" | |
| 239 | 242 | ? validThreadState.isOpen |
| 240 | : activeSurfaceId !== null; | |
| 243 | : persistedActiveSurfaceId !== null); | |
| 244 | // An open panel needs an active surface: if migration dropped | |
| 245 | // the persisted one (e.g. plan was active), fall back to the | |
| 246 | // first survivor instead of rendering an open empty panel. | |
| 247 | const activeSurfaceId = | |
| 248 | persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); | |
| 241 | 249 | return [threadKey, { isOpen, surfaces, activeSurfaceId }]; |
| 242 | 250 | }, |
| 243 | 251 | ), |
apps/web/src/session-logic.test.ts+90-98| ... | ... | @@ -11,12 +11,12 @@ import { describe, expect, it } from "vite-plus/test"; |
| 11 | 11 | import { |
| 12 | 12 | deriveActiveWorkStartedAt, |
| 13 | 13 | deriveActivePlanState, |
| 14 | deriveTurnPlans, | |
| 14 | 15 | derivePendingApprovals, |
| 15 | 16 | derivePendingUserInputs, |
| 16 | 17 | deriveTimelineEntries, |
| 17 | 18 | deriveWorkLogEntries, |
| 18 | 19 | findLatestProposedPlan, |
| 19 | findSidebarProposedPlan, | |
| 20 | 20 | hasActionableProposedPlan, |
| 21 | 21 | isLatestTurnSettled, |
| 22 | 22 | workEntryIndicatesToolFailure, |
| ... | ... | @@ -410,6 +410,95 @@ describe("deriveActivePlanState", () => { |
| 410 | 410 | }); |
| 411 | 411 | }); |
| 412 | 412 | |
| 413 | describe("deriveTurnPlans", () => { | |
| 414 | it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => { | |
| 415 | const activities: OrchestrationThreadActivity[] = [ | |
| 416 | makeActivity({ | |
| 417 | id: "plan-1a", | |
| 418 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 419 | kind: "turn.plan.updated", | |
| 420 | summary: "Plan updated", | |
| 421 | tone: "info", | |
| 422 | turnId: "turn-1", | |
| 423 | payload: { | |
| 424 | plan: [{ step: "Inspect code", status: "inProgress" }], | |
| 425 | }, | |
| 426 | }), | |
| 427 | makeActivity({ | |
| 428 | id: "plan-1b", | |
| 429 | createdAt: "2026-02-23T00:00:05.000Z", | |
| 430 | kind: "turn.plan.updated", | |
| 431 | summary: "Plan updated", | |
| 432 | tone: "info", | |
| 433 | turnId: "turn-1", | |
| 434 | payload: { | |
| 435 | plan: [{ step: "Inspect code", status: "completed" }], | |
| 436 | }, | |
| 437 | }), | |
| 438 | makeActivity({ | |
| 439 | id: "plan-2a", | |
| 440 | createdAt: "2026-02-23T00:01:00.000Z", | |
| 441 | kind: "turn.plan.updated", | |
| 442 | summary: "Plan updated", | |
| 443 | tone: "info", | |
| 444 | turnId: "turn-2", | |
| 445 | payload: { | |
| 446 | plan: [{ step: "Ship it", status: "pending" }], | |
| 447 | }, | |
| 448 | }), | |
| 449 | ]; | |
| 450 | ||
| 451 | const turnPlans = deriveTurnPlans(activities); | |
| 452 | expect(turnPlans).toHaveLength(2); | |
| 453 | expect(turnPlans[0]).toMatchObject({ | |
| 454 | id: "turn-plan:turn-1", | |
| 455 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 456 | turnId: "turn-1", | |
| 457 | }); | |
| 458 | expect(turnPlans[0]?.plan.steps).toEqual([{ step: "Inspect code", status: "completed" }]); | |
| 459 | expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]); | |
| 460 | }); | |
| 461 | ||
| 462 | it("skips activities without parseable steps", () => { | |
| 463 | const activities: OrchestrationThreadActivity[] = [ | |
| 464 | makeActivity({ | |
| 465 | id: "plan-bad", | |
| 466 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 467 | kind: "turn.plan.updated", | |
| 468 | summary: "Plan updated", | |
| 469 | tone: "info", | |
| 470 | turnId: "turn-1", | |
| 471 | payload: { plan: [] }, | |
| 472 | }), | |
| 473 | ]; | |
| 474 | expect(deriveTurnPlans(activities)).toEqual([]); | |
| 475 | }); | |
| 476 | ||
| 477 | it("drops a turn's chip when a later snapshot clears the plan", () => { | |
| 478 | const activities: OrchestrationThreadActivity[] = [ | |
| 479 | makeActivity({ | |
| 480 | id: "plan-set", | |
| 481 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 482 | kind: "turn.plan.updated", | |
| 483 | summary: "Plan updated", | |
| 484 | tone: "info", | |
| 485 | turnId: "turn-1", | |
| 486 | payload: { plan: [{ step: "Inspect code", status: "inProgress" }] }, | |
| 487 | }), | |
| 488 | makeActivity({ | |
| 489 | id: "plan-clear", | |
| 490 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 491 | kind: "turn.plan.updated", | |
| 492 | summary: "Plan updated", | |
| 493 | tone: "info", | |
| 494 | turnId: "turn-1", | |
| 495 | payload: { plan: [] }, | |
| 496 | }), | |
| 497 | ]; | |
| 498 | expect(deriveTurnPlans(activities)).toEqual([]); | |
| 499 | }); | |
| 500 | }); | |
| 501 | ||
| 413 | 502 | describe("findLatestProposedPlan", () => { |
| 414 | 503 | it("prefers the latest proposed plan for the active turn", () => { |
| 415 | 504 | expect( |
| ... | ... | @@ -515,103 +604,6 @@ describe("hasActionableProposedPlan", () => { |
| 515 | 604 | }); |
| 516 | 605 | }); |
| 517 | 606 | |
| 518 | describe("findSidebarProposedPlan", () => { | |
| 519 | it("prefers the running turn source proposed plan when available on the same thread", () => { | |
| 520 | expect( | |
| 521 | findSidebarProposedPlan({ | |
| 522 | threads: [ | |
| 523 | { | |
| 524 | id: ThreadId.make("thread-1"), | |
| 525 | proposedPlans: [ | |
| 526 | { | |
| 527 | id: "plan-1", | |
| 528 | turnId: TurnId.make("turn-plan"), | |
| 529 | planMarkdown: "# Source plan", | |
| 530 | implementedAt: "2026-02-23T00:00:03.000Z", | |
| 531 | implementationThreadId: ThreadId.make("thread-2"), | |
| 532 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 533 | updatedAt: "2026-02-23T00:00:02.000Z", | |
| 534 | }, | |
| 535 | ], | |
| 536 | }, | |
| 537 | { | |
| 538 | id: ThreadId.make("thread-2"), | |
| 539 | proposedPlans: [ | |
| 540 | { | |
| 541 | id: "plan-2", | |
| 542 | turnId: TurnId.make("turn-other"), | |
| 543 | planMarkdown: "# Latest elsewhere", | |
| 544 | implementedAt: null, | |
| 545 | implementationThreadId: null, | |
| 546 | createdAt: "2026-02-23T00:00:04.000Z", | |
| 547 | updatedAt: "2026-02-23T00:00:05.000Z", | |
| 548 | }, | |
| 549 | ], | |
| 550 | }, | |
| 551 | ], | |
| 552 | latestTurn: { | |
| 553 | turnId: TurnId.make("turn-implementation"), | |
| 554 | sourceProposedPlan: { | |
| 555 | threadId: ThreadId.make("thread-1"), | |
| 556 | planId: "plan-1", | |
| 557 | }, | |
| 558 | }, | |
| 559 | latestTurnSettled: false, | |
| 560 | threadId: ThreadId.make("thread-1"), | |
| 561 | }), | |
| 562 | ).toEqual({ | |
| 563 | id: "plan-1", | |
| 564 | turnId: "turn-plan", | |
| 565 | planMarkdown: "# Source plan", | |
| 566 | implementedAt: "2026-02-23T00:00:03.000Z", | |
| 567 | implementationThreadId: "thread-2", | |
| 568 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 569 | updatedAt: "2026-02-23T00:00:02.000Z", | |
| 570 | }); | |
| 571 | }); | |
| 572 | ||
| 573 | it("falls back to the latest proposed plan once the turn is settled", () => { | |
| 574 | expect( | |
| 575 | findSidebarProposedPlan({ | |
| 576 | threads: [ | |
| 577 | { | |
| 578 | id: ThreadId.make("thread-1"), | |
| 579 | proposedPlans: [ | |
| 580 | { | |
| 581 | id: "plan-1", | |
| 582 | turnId: TurnId.make("turn-plan"), | |
| 583 | planMarkdown: "# Older", | |
| 584 | implementedAt: null, | |
| 585 | implementationThreadId: null, | |
| 586 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 587 | updatedAt: "2026-02-23T00:00:02.000Z", | |
| 588 | }, | |
| 589 | { | |
| 590 | id: "plan-2", | |
| 591 | turnId: TurnId.make("turn-latest"), | |
| 592 | planMarkdown: "# Latest", | |
| 593 | implementedAt: null, | |
| 594 | implementationThreadId: null, | |
| 595 | createdAt: "2026-02-23T00:00:03.000Z", | |
| 596 | updatedAt: "2026-02-23T00:00:04.000Z", | |
| 597 | }, | |
| 598 | ], | |
| 599 | }, | |
| 600 | ], | |
| 601 | latestTurn: { | |
| 602 | turnId: TurnId.make("turn-implementation"), | |
| 603 | sourceProposedPlan: { | |
| 604 | threadId: ThreadId.make("thread-1"), | |
| 605 | planId: "plan-1", | |
| 606 | }, | |
| 607 | }, | |
| 608 | latestTurnSettled: true, | |
| 609 | threadId: ThreadId.make("thread-1"), | |
| 610 | })?.planMarkdown, | |
| 611 | ).toBe("# Latest"); | |
| 612 | }); | |
| 613 | }); | |
| 614 | ||
| 615 | 607 | describe("workEntryIndicatesToolFailure", () => { |
| 616 | 608 | const base = { |
| 617 | 609 | id: "w1", |
apps/web/src/session-logic.ts+85-46| ... | ... | @@ -151,6 +151,12 @@ export type TimelineEntry = |
| 151 | 151 | createdAt: string; |
| 152 | 152 | proposedPlan: ProposedPlan; |
| 153 | 153 | } |
| 154 | | { | |
| 155 | id: string; | |
| 156 | kind: "turn-plan"; | |
| 157 | createdAt: string; | |
| 158 | turnPlan: TurnPlanEntry; | |
| 159 | } | |
| 154 | 160 | | { |
| 155 | 161 | id: string; |
| 156 | 162 | kind: "work"; |
| ... | ... | @@ -532,26 +538,10 @@ export function derivePendingUserInputs( |
| 532 | 538 | ); |
| 533 | 539 | } |
| 534 | 540 | |
| 535 | export function deriveActivePlanState( | |
| 536 | activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 537 | latestTurnId: TurnId | undefined, | |
| 538 | ): ActivePlanState | null { | |
| 539 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | |
| 540 | const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); | |
| 541 | // Prefer plan from the current turn; fall back to the most recent plan from any turn | |
| 542 | // so that TodoWrite tasks persist across follow-up messages. | |
| 543 | const latest = Option.firstSomeOf([ | |
| 544 | ...(latestTurnId | |
| 545 | ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) | |
| 546 | : Option.none()), | |
| 547 | Arr.last(allPlanActivities), | |
| 548 | ]).pipe(Option.getOrNull); | |
| 549 | if (!latest) { | |
| 550 | return null; | |
| 551 | } | |
| 541 | function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null { | |
| 552 | 542 | const payload = |
| 553 | latest.payload && typeof latest.payload === "object" | |
| 554 | ? (latest.payload as Record<string, unknown>) | |
| 543 | activity.payload && typeof activity.payload === "object" | |
| 544 | ? (activity.payload as Record<string, unknown>) | |
| 555 | 545 | : null; |
| 556 | 546 | const rawPlan = payload?.plan; |
| 557 | 547 | if (!Array.isArray(rawPlan)) { |
| ... | ... | @@ -580,8 +570,8 @@ export function deriveActivePlanState( |
| 580 | 570 | return null; |
| 581 | 571 | } |
| 582 | 572 | return { |
| 583 | createdAt: latest.createdAt, | |
| 584 | turnId: latest.turnId, | |
| 573 | createdAt: activity.createdAt, | |
| 574 | turnId: activity.turnId, | |
| 585 | 575 | ...(payload && "explanation" in payload |
| 586 | 576 | ? { explanation: payload.explanation as string | null } |
| 587 | 577 | : {}), |
| ... | ... | @@ -589,6 +579,72 @@ export function deriveActivePlanState( |
| 589 | 579 | }; |
| 590 | 580 | } |
| 591 | 581 | |
| 582 | export function deriveActivePlanState( | |
| 583 | activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 584 | latestTurnId: TurnId | undefined, | |
| 585 | ): ActivePlanState | null { | |
| 586 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | |
| 587 | const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); | |
| 588 | // Prefer plan from the current turn; fall back to the most recent plan from any turn | |
| 589 | // so that TodoWrite tasks persist across follow-up messages. | |
| 590 | const latest = Option.firstSomeOf([ | |
| 591 | ...(latestTurnId | |
| 592 | ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) | |
| 593 | : Option.none()), | |
| 594 | Arr.last(allPlanActivities), | |
| 595 | ]).pipe(Option.getOrNull); | |
| 596 | if (!latest) { | |
| 597 | return null; | |
| 598 | } | |
| 599 | return planStateFromActivity(latest); | |
| 600 | } | |
| 601 | ||
| 602 | export interface TurnPlanEntry { | |
| 603 | /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */ | |
| 604 | id: string; | |
| 605 | /** Anchor timestamp: the turn's FIRST plan activity, so the chip renders where planning began. */ | |
| 606 | createdAt: string; | |
| 607 | turnId: TurnId | null; | |
| 608 | plan: ActivePlanState; | |
| 609 | } | |
| 610 | ||
| 611 | /** | |
| 612 | * One inline plan chip per turn that produced plan/todo steps: the latest | |
| 613 | * snapshot for the turn, anchored at the first snapshot's timestamp. Turn-less | |
| 614 | * plan activities collapse into a single chip keyed by thread order. | |
| 615 | */ | |
| 616 | export function deriveTurnPlans( | |
| 617 | activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 618 | ): TurnPlanEntry[] { | |
| 619 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | |
| 620 | const byTurn = new Map<string, TurnPlanEntry>(); | |
| 621 | for (const activity of ordered) { | |
| 622 | if (activity.kind !== "turn.plan.updated") { | |
| 623 | continue; | |
| 624 | } | |
| 625 | const plan = planStateFromActivity(activity); | |
| 626 | const key = activity.turnId ?? "no-turn"; | |
| 627 | if (!plan) { | |
| 628 | // A later snapshot with no steps clears the turn's plan; keeping the | |
| 629 | // stale entry would freeze the chip on a withdrawn plan. | |
| 630 | byTurn.delete(key); | |
| 631 | continue; | |
| 632 | } | |
| 633 | const existing = byTurn.get(key); | |
| 634 | if (existing) { | |
| 635 | existing.plan = plan; | |
| 636 | } else { | |
| 637 | byTurn.set(key, { | |
| 638 | id: `turn-plan:${key}`, | |
| 639 | createdAt: activity.createdAt, | |
| 640 | turnId: activity.turnId, | |
| 641 | plan, | |
| 642 | }); | |
| 643 | } | |
| 644 | } | |
| 645 | return [...byTurn.values()]; | |
| 646 | } | |
| 647 | ||
| 592 | 648 | export function findLatestProposedPlan( |
| 593 | 649 | proposedPlans: ReadonlyArray<ProposedPlan>, |
| 594 | 650 | latestTurnId: TurnId | string | null | undefined, |
| ... | ... | @@ -619,30 +675,6 @@ export function findLatestProposedPlan( |
| 619 | 675 | return toLatestProposedPlanState(latestPlan); |
| 620 | 676 | } |
| 621 | 677 | |
| 622 | export function findSidebarProposedPlan(input: { | |
| 623 | threads: ReadonlyArray<Pick<Thread, "id" | "proposedPlans">>; | |
| 624 | latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "sourceProposedPlan"> | null; | |
| 625 | latestTurnSettled: boolean; | |
| 626 | threadId: ThreadId | string | null | undefined; | |
| 627 | }): LatestProposedPlanState | null { | |
| 628 | const activeThreadPlans = | |
| 629 | input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? []; | |
| 630 | ||
| 631 | if (!input.latestTurnSettled) { | |
| 632 | const sourceProposedPlan = input.latestTurn?.sourceProposedPlan; | |
| 633 | if (sourceProposedPlan) { | |
| 634 | const sourcePlan = input.threads | |
| 635 | .find((thread) => thread.id === sourceProposedPlan.threadId) | |
| 636 | ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId); | |
| 637 | if (sourcePlan) { | |
| 638 | return toLatestProposedPlanState(sourcePlan); | |
| 639 | } | |
| 640 | } | |
| 641 | } | |
| 642 | ||
| 643 | return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null); | |
| 644 | } | |
| 645 | ||
| 646 | 678 | export function hasActionableProposedPlan( |
| 647 | 679 | proposedPlan: LatestProposedPlanState | Pick<ProposedPlan, "implementedAt"> | null, |
| 648 | 680 | ): boolean { |
| ... | ... | @@ -1542,6 +1574,7 @@ export function deriveTimelineEntries( |
| 1542 | 1574 | messages: ReadonlyArray<ChatMessage>, |
| 1543 | 1575 | proposedPlans: ReadonlyArray<ProposedPlan>, |
| 1544 | 1576 | workEntries: ReadonlyArray<WorkLogEntry>, |
| 1577 | turnPlans: ReadonlyArray<TurnPlanEntry> = [], | |
| 1545 | 1578 | ): TimelineEntry[] { |
| 1546 | 1579 | const messageRows: TimelineEntry[] = messages.map((message) => ({ |
| 1547 | 1580 | id: message.id, |
| ... | ... | @@ -1555,13 +1588,19 @@ export function deriveTimelineEntries( |
| 1555 | 1588 | createdAt: proposedPlan.createdAt, |
| 1556 | 1589 | proposedPlan, |
| 1557 | 1590 | })); |
| 1591 | const turnPlanRows: TimelineEntry[] = turnPlans.map((turnPlan) => ({ | |
| 1592 | id: turnPlan.id, | |
| 1593 | kind: "turn-plan", | |
| 1594 | createdAt: turnPlan.createdAt, | |
| 1595 | turnPlan, | |
| 1596 | })); | |
| 1558 | 1597 | const workRows: TimelineEntry[] = workEntries.map((entry) => ({ |
| 1559 | 1598 | id: entry.id, |
| 1560 | 1599 | kind: "work", |
| 1561 | 1600 | createdAt: entry.createdAt, |
| 1562 | 1601 | entry, |
| 1563 | 1602 | })); |
| 1564 | return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => | |
| 1603 | return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) => | |
| 1565 | 1604 | a.createdAt.localeCompare(b.createdAt), |
| 1566 | 1605 | ); |
| 1567 | 1606 | } |
packages/contracts/src/orchestration.ts+14| ... | ... | @@ -446,6 +446,20 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 446 | 446 | * live work. Optional so old servers/clients interop; absent = none. |
| 447 | 447 | */ |
| 448 | 448 | backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))), |
| 449 | /** | |
| 450 | * Current plan step while a turn runs, for the Working indicators | |
| 451 | * (sidebar row, in-chat working line). Cleared when the turn settles — | |
| 452 | * never persists as stale UI. Optional so old servers/clients interop. | |
| 453 | */ | |
| 454 | planProgress: Schema.optional( | |
| 455 | Schema.NullOr( | |
| 456 | Schema.Struct({ | |
| 457 | step: TrimmedNonEmptyString, | |
| 458 | completedSteps: NonNegativeInt, | |
| 459 | totalSteps: NonNegativeInt, | |
| 460 | }), | |
| 461 | ), | |
| 462 | ), | |
| 449 | 463 | }); |
| 450 | 464 | export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; |
| 451 | 465 |
packages/contracts/src/settings.ts-2| ... | ... | @@ -111,7 +111,6 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) |
| 111 | 111 | export type FontFamilyPreference = typeof FontFamilyPreference.Type; |
| 112 | 112 | |
| 113 | 113 | export const ClientSettingsSchema = Schema.Struct({ |
| 114 | autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 115 | 114 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 116 | 115 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 117 | 116 | dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( |
| ... | ... | @@ -747,7 +746,6 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 747 | 746 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 748 | 747 | |
| 749 | 748 | export const ClientSettingsPatch = Schema.Struct({ |
| 750 | autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), | |
| 751 | 749 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 752 | 750 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |
| 753 | 751 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), |