authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 03:16:14-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 00:16:14-07:00
loga8cd2ad2ebb32ad789e8e0ecd2fc713c2edc38f4
treee5409886e55b9316364096126ee4f7a9efdb4979
parent4a07c1ca9d9033ef35161a1ffbea44d0418ca2bf
signature Signed by PGP key B5690EEEBB952194

fix(web): plans stop hijacking the UI, fold into chat instead (#5558)

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,7 +13,6 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import * as DesktopClientSettings from "./DesktopClientSettings.ts";13import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1414
15const clientSettings: ClientSettings = {15const clientSettings: ClientSettings = {
16 autoOpenPlanSidebar: false,
17 confirmThreadArchive: true,16 confirmThreadArchive: true,
18 confirmThreadDelete: false,17 confirmThreadDelete: false,
19 dismissedProviderUpdateNotificationKeys: [],18 dismissedProviderUpdateNotificationKeys: [],
apps/server/integration/OrchestrationEngineHarness.integration.ts+5-1
...@@ -51,6 +51,7 @@ import { OrchestrationEngineLive } from "../src/orchestration/Layers/Orchestrati...@@ -51,6 +51,7 @@ import { OrchestrationEngineLive } from "../src/orchestration/Layers/Orchestrati
51import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";51import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
52import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";52import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
53import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts";53import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts";
54import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts";
54import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";55import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
55import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";56import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
56import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";57import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
...@@ -310,7 +311,10 @@ export const makeOrchestrationIntegrationHarness = (...@@ -310,7 +311,10 @@ export const makeOrchestrationIntegrationHarness = (
310 checkpointStoreLayer,311 checkpointStoreLayer,
311 providerLayer,312 providerLayer,
312 RuntimeReceiptBusTest,313 RuntimeReceiptBusTest,
313 ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));314 ).pipe(
315 Layer.provideMerge(ThreadBackgroundLiveness.layer),
316 Layer.provideMerge(ThreadPlanProgress.layer),
317 );
314 const serverSettingsLayer = ServerSettingsService.layerTest();318 const serverSettingsLayer = ServerSettingsService.layerTest();
315 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(319 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
316 Layer.provideMerge(runtimeServicesLayer),320 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+3
...@@ -40,6 +40,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";...@@ -40,6 +40,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
40import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";40import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
41import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";41import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
42import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";42import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
43import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
43import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";44import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
44import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";45import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
45import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";46import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
...@@ -296,6 +297,7 @@ describe("CheckpointReactor", () => {...@@ -296,6 +297,7 @@ describe("CheckpointReactor", () => {
296 const orchestrationLayer = OrchestrationEngineLive.pipe(297 const orchestrationLayer = OrchestrationEngineLive.pipe(
297 Layer.provide(OrchestrationProjectionSnapshotQueryLive),298 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
298 Layer.provide(ThreadBackgroundLiveness.layer),299 Layer.provide(ThreadBackgroundLiveness.layer),
300 Layer.provide(ThreadPlanProgress.layer),
299 Layer.provide(OrchestrationProjectionPipelineLive),301 Layer.provide(OrchestrationProjectionPipelineLive),
300 Layer.provide(OrchestrationEventStoreLive),302 Layer.provide(OrchestrationEventStoreLive),
301 Layer.provide(OrchestrationCommandReceiptRepositoryLive),303 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
...@@ -304,6 +306,7 @@ describe("CheckpointReactor", () => {...@@ -304,6 +306,7 @@ describe("CheckpointReactor", () => {
304 );306 );
305 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(307 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
306 Layer.provide(ThreadBackgroundLiveness.layer),308 Layer.provide(ThreadBackgroundLiveness.layer),
309 Layer.provide(ThreadPlanProgress.layer),
307 Layer.provide(RepositoryIdentityResolver.layer),310 Layer.provide(RepositoryIdentityResolver.layer),
308 Layer.provide(SqlitePersistenceMemory),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,6 +32,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
32import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";32import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
33import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";33import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
34import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";34import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
35import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
35import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";36import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
36import {37import {
37 OrchestrationProjectionPipeline,38 OrchestrationProjectionPipeline,
...@@ -57,6 +58,7 @@ async function createOrchestrationSystem() {...@@ -57,6 +58,7 @@ async function createOrchestrationSystem() {
57 OrchestrationProjectionSnapshotQueryLive,58 OrchestrationProjectionSnapshotQueryLive,
58 ).pipe(59 ).pipe(
59 Layer.provide(ThreadBackgroundLiveness.layer),60 Layer.provide(ThreadBackgroundLiveness.layer),
61 Layer.provide(ThreadPlanProgress.layer),
60 Layer.provide(OrchestrationEventStoreLive),62 Layer.provide(OrchestrationEventStoreLive),
61 Layer.provide(OrchestrationCommandReceiptRepositoryLive),63 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
62 Layer.provide(RepositoryIdentityResolver.layer),64 Layer.provide(RepositoryIdentityResolver.layer),
...@@ -820,6 +822,7 @@ describe("OrchestrationEngine", () => {...@@ -820,6 +822,7 @@ describe("OrchestrationEngine", () => {
820 OrchestrationEngineLive.pipe(822 OrchestrationEngineLive.pipe(
821 Layer.provide(OrchestrationProjectionSnapshotQueryLive),823 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
822 Layer.provide(ThreadBackgroundLiveness.layer),824 Layer.provide(ThreadBackgroundLiveness.layer),
825 Layer.provide(ThreadPlanProgress.layer),
823 Layer.provide(OrchestrationProjectionPipelineLive),826 Layer.provide(OrchestrationProjectionPipelineLive),
824 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),827 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
825 Layer.provide(OrchestrationCommandReceiptRepositoryLive),828 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
...@@ -926,6 +929,7 @@ describe("OrchestrationEngine", () => {...@@ -926,6 +929,7 @@ describe("OrchestrationEngine", () => {
926 OrchestrationEngineLive.pipe(929 OrchestrationEngineLive.pipe(
927 Layer.provide(OrchestrationProjectionSnapshotQueryLive),930 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
928 Layer.provide(ThreadBackgroundLiveness.layer),931 Layer.provide(ThreadBackgroundLiveness.layer),
932 Layer.provide(ThreadPlanProgress.layer),
929 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),933 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
930 Layer.provide(OrchestrationEventStoreLive),934 Layer.provide(OrchestrationEventStoreLive),
931 Layer.provide(OrchestrationCommandReceiptRepositoryLive),935 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
...@@ -1070,6 +1074,7 @@ describe("OrchestrationEngine", () => {...@@ -1070,6 +1074,7 @@ describe("OrchestrationEngine", () => {
1070 OrchestrationEngineLive.pipe(1074 OrchestrationEngineLive.pipe(
1071 Layer.provide(OrchestrationProjectionSnapshotQueryLive),1075 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
1072 Layer.provide(ThreadBackgroundLiveness.layer),1076 Layer.provide(ThreadBackgroundLiveness.layer),
1077 Layer.provide(ThreadPlanProgress.layer),
1073 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),1078 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
1074 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),1079 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
1075 Layer.provide(OrchestrationCommandReceiptRepositoryLive),1080 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32} from "./ProjectionPipeline.ts";32} from "./ProjectionPipeline.ts";
33import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";33import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
34import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";34import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
35import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
35import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";36import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
36import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";37import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
37import { ServerConfig } from "../../config.ts";38import { ServerConfig } from "../../config.ts";
...@@ -2673,6 +2674,7 @@ const engineLayer = it.layer(...@@ -2673,6 +2674,7 @@ const engineLayer = it.layer(
2673 OrchestrationEngineLive.pipe(2674 OrchestrationEngineLive.pipe(
2674 Layer.provide(OrchestrationProjectionSnapshotQueryLive),2675 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
2675 Layer.provide(ThreadBackgroundLiveness.layer),2676 Layer.provide(ThreadBackgroundLiveness.layer),
2677 Layer.provide(ThreadPlanProgress.layer),
2676 Layer.provide(OrchestrationProjectionPipelineLive),2678 Layer.provide(OrchestrationProjectionPipelineLive),
2677 Layer.provide(OrchestrationEventStoreLive),2679 Layer.provide(OrchestrationEventStoreLive),
2678 Layer.provide(OrchestrationCommandReceiptRepositoryLive),2680 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+4
...@@ -18,6 +18,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes...@@ -18,6 +18,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
18import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";18import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
19import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";19import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
20import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";20import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
21import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
21import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";22import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
22import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts";23import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts";
2324
...@@ -30,6 +31,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val...@@ -30,6 +31,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val
30const projectionSnapshotLayer = it.layer(31const projectionSnapshotLayer = it.layer(
31 OrchestrationProjectionSnapshotQueryLive.pipe(32 OrchestrationProjectionSnapshotQueryLive.pipe(
32 Layer.provide(ThreadBackgroundLiveness.layer),33 Layer.provide(ThreadBackgroundLiveness.layer),
34 Layer.provide(ThreadPlanProgress.layer),
33 Layer.provideMerge(RepositoryIdentityResolver.layer),35 Layer.provideMerge(RepositoryIdentityResolver.layer),
34 Layer.provideMerge(SqlitePersistenceMemory),36 Layer.provideMerge(SqlitePersistenceMemory),
35 Layer.provideMerge(NodeServices.layer),37 Layer.provideMerge(NodeServices.layer),
...@@ -447,6 +449,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -447,6 +449,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
447 hasPendingUserInput: false,449 hasPendingUserInput: false,
448 hasActionableProposedPlan: false,450 hasActionableProposedPlan: false,
449 backgroundLiveness: null,451 backgroundLiveness: null,
452 planProgress: null,
450 },453 },
451 ]);454 ]);
452455
...@@ -1830,6 +1833,7 @@ it.effect(...@@ -1830,6 +1833,7 @@ it.effect(
1830 const resolveCalls: string[] = [];1833 const resolveCalls: string[] = [];
1831 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(1834 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
1832 Layer.provide(ThreadBackgroundLiveness.layer),1835 Layer.provide(ThreadBackgroundLiveness.layer),
1836 Layer.provide(ThreadPlanProgress.layer),
1833 Layer.provideMerge(1837 Layer.provideMerge(
1834 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {1838 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
1835 resolve: (cwd: string) =>1839 resolve: (cwd: string) =>
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+5
...@@ -44,6 +44,7 @@ import {...@@ -44,6 +44,7 @@ import {
44} from "../../persistence/Errors.ts";44} from "../../persistence/Errors.ts";
45import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";45import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
46import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";46import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
47import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
47import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";48import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
48import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";49import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
49import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";50import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
...@@ -344,6 +345,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st...@@ -344,6 +345,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
344345
345const makeProjectionSnapshotQuery = Effect.gen(function* () {346const makeProjectionSnapshotQuery = Effect.gen(function* () {
346 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;347 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
348 const threadPlanProgress = yield* ThreadPlanProgressService;
347 const sql = yield* SqlClient.SqlClient;349 const sql = yield* SqlClient.SqlClient;
348 const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;350 const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
349 const repositoryIdentityResolutionConcurrency = 4;351 const repositoryIdentityResolutionConcurrency = 4;
...@@ -1908,6 +1910,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1908,6 +1910,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1908 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(1910 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
1909 row.threadId,1911 row.threadId,
1910 ),1912 ),
1913 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
1911 } satisfies OrchestrationThreadShell)1914 } satisfies OrchestrationThreadShell)
1912 : Result.failVoid,1915 : Result.failVoid,
1913 ),1916 ),
...@@ -2051,6 +2054,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2051,6 +2054,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2051 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(2054 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2052 row.threadId,2055 row.threadId,
2053 ),2056 ),
2057 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
2054 }),2058 }),
2055 ),2059 ),
2056 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",2060 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
...@@ -2326,6 +2330,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2326,6 +2330,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2326 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(2330 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2327 threadRow.value.threadId,2331 threadRow.value.threadId,
2328 ),2332 ),
2333 planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId),
2329 } satisfies OrchestrationThreadShell);2334 } satisfies OrchestrationThreadShell);
2330 });2335 });
23312336
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3
...@@ -49,6 +49,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";...@@ -49,6 +49,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
49import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";49import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
50import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";50import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
51import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";51import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
52import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
52import {53import {
53 providerErrorLabel,54 providerErrorLabel,
54 providerErrorLabelFromInstanceHint,55 providerErrorLabelFromInstanceHint,
...@@ -347,6 +348,7 @@ describe("ProviderCommandReactor", () => {...@@ -347,6 +348,7 @@ describe("ProviderCommandReactor", () => {
347 const orchestrationLayer = OrchestrationEngineLive.pipe(348 const orchestrationLayer = OrchestrationEngineLive.pipe(
348 Layer.provide(OrchestrationProjectionSnapshotQueryLive),349 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
349 Layer.provide(ThreadBackgroundLiveness.layer),350 Layer.provide(ThreadBackgroundLiveness.layer),
351 Layer.provide(ThreadPlanProgress.layer),
350 Layer.provide(OrchestrationProjectionPipelineLive),352 Layer.provide(OrchestrationProjectionPipelineLive),
351 Layer.provide(OrchestrationEventStoreLive),353 Layer.provide(OrchestrationEventStoreLive),
352 Layer.provide(OrchestrationCommandReceiptRepositoryLive),354 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
...@@ -355,6 +357,7 @@ describe("ProviderCommandReactor", () => {...@@ -355,6 +357,7 @@ describe("ProviderCommandReactor", () => {
355 );357 );
356 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(358 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
357 Layer.provide(ThreadBackgroundLiveness.layer),359 Layer.provide(ThreadBackgroundLiveness.layer),
360 Layer.provide(ThreadPlanProgress.layer),
358 Layer.provide(RepositoryIdentityResolver.layer),361 Layer.provide(RepositoryIdentityResolver.layer),
359 Layer.provide(SqlitePersistenceMemory),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,6 +45,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
45import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";45import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
46import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";46import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
47import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";47import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
48import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
48import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";49import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
49import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";50import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
50import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";51import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
...@@ -241,6 +242,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -241,6 +242,7 @@ describe("ProviderRuntimeIngestion", () => {
241 // Single shared liveness instance across ingestion (writer), the242 // Single shared liveness instance across ingestion (writer), the
242 // engine, and the snapshot query (reader).243 // engine, and the snapshot query (reader).
243 Layer.provideMerge(ThreadBackgroundLiveness.layer),244 Layer.provideMerge(ThreadBackgroundLiveness.layer),
245 Layer.provideMerge(ThreadPlanProgress.layer),
244 Layer.provideMerge(SqlitePersistenceMemory),246 Layer.provideMerge(SqlitePersistenceMemory),
245 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),247 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
246 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),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,6 +35,7 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio
35import { isGitRepository } from "../../git/Utils.ts";35import { isGitRepository } from "../../git/Utils.ts";
36import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";36import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
37import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";37import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
38import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
38import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";39import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
39import {40import {
40 ProviderRuntimeIngestionService,41 ProviderRuntimeIngestionService,
...@@ -867,6 +868,7 @@ export function runtimeEventToActivities(...@@ -867,6 +868,7 @@ export function runtimeEventToActivities(
867868
868const make = Effect.gen(function* () {869const make = Effect.gen(function* () {
869 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;870 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
871 const threadPlanProgress = yield* ThreadPlanProgressService;
870 const crypto = yield* Crypto.Crypto;872 const crypto = yield* Crypto.Crypto;
871 const orchestrationEngine = yield* OrchestrationEngineService;873 const orchestrationEngine = yield* OrchestrationEngineService;
872 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;874 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
...@@ -1935,6 +1937,21 @@ const make = Effect.gen(function* () {...@@ -1935,6 +1937,21 @@ const make = Effect.gen(function* () {
1935 yield* rememberTaskDescription(thread.id, event.payload.taskId, description);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 // Sidebar background liveness: fed from the same lifecycle stream,1955 // Sidebar background liveness: fed from the same lifecycle stream,
1939 // read by the shell query at mapping time (no persistence).1956 // read by the shell query at mapping time (no persistence).
1940 switch (event.type) {1957 switch (event.type) {
apps/server/src/orchestration/ThreadPlanProgress.test.ts created+44
...@@ -0,0 +1,44 @@
1import { describe, expect, it } from "vite-plus/test";
2import * as ThreadPlanProgress from "./ThreadPlanProgress.ts";
3
4describe("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 */
14import * as Context from "effect/Context";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17
18export interface ThreadPlanProgress {
19 readonly step: string;
20 readonly completedSteps: number;
21 readonly totalSteps: number;
22}
23
24interface PlanStepInput {
25 readonly step: string;
26 readonly status: string;
27}
28
29export 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
45export 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
76export 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 +6,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
6import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";6import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
7import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";7import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";
8import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";8import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";
9import * as ThreadPlanProgress from "./ThreadPlanProgress.ts";
910
10export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(11export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
11 OrchestrationEventStoreLive,12 OrchestrationEventStoreLive,
...@@ -20,10 +21,14 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll(...@@ -20,10 +21,14 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll(
20 OrchestrationProjectionSnapshotQueryLive,21 OrchestrationProjectionSnapshotQueryLive,
21 OrchestrationEventInfrastructureLayerLive,22 OrchestrationEventInfrastructureLayerLive,
22 OrchestrationProjectionPipelineLayerLive,23 OrchestrationProjectionPipelineLayerLive,
23 // Shared background-liveness registry: written by runtime ingestion,24 // Shared background-liveness and plan-progress registries: written by
24 // read by the snapshot query. provideMerge feeds the same instance to25 // runtime ingestion, read by the snapshot query. provideMerge feeds the
25 // the snapshot query here and re-exports it for runtime ingestion.26 // same instance to the snapshot query here and re-exports it for runtime
26).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));27 // ingestion.
28).pipe(
29 Layer.provideMerge(ThreadBackgroundLiveness.layer),
30 Layer.provideMerge(ThreadPlanProgress.layer),
31);
2732
28export const OrchestrationLayerLive = Layer.mergeAll(33export const OrchestrationLayerLive = Layer.mergeAll(
29 OrchestrationInfrastructureLayerLive,34 OrchestrationInfrastructureLayerLive,
apps/web/src/components/ChatView.tsx+38-169
...@@ -85,7 +85,7 @@ import {...@@ -85,7 +85,7 @@ import {
85 deriveTimelineEntries,85 deriveTimelineEntries,
86 deriveActiveWorkStartedAt,86 deriveActiveWorkStartedAt,
87 deriveActivePlanState,87 deriveActivePlanState,
88 findSidebarProposedPlan,88 deriveTurnPlans,
89 findLatestProposedPlan,89 findLatestProposedPlan,
90 deriveWorkLogEntries,90 deriveWorkLogEntries,
91 hasActionableProposedPlan,91 hasActionableProposedPlan,
...@@ -121,11 +121,6 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";...@@ -121,11 +121,6 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
121import { isCommandPaletteOpen } from "../commandPaletteBus";121import { isCommandPaletteOpen } from "../commandPaletteBus";
122import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";122import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
123import { useMediaQuery } from "../hooks/useMediaQuery";123import { useMediaQuery } from "../hooks/useMediaQuery";
124import {
125 clearPlanSidebarDismissal,
126 dismissPlanSidebarForTurn,
127 isPlanSidebarDismissedForTurn,
128} from "../planSidebarDismissal";
129import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";124import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
130import {125import {
131 selectActiveRightPanel,126 selectActiveRightPanel,
...@@ -157,7 +152,6 @@ import {...@@ -157,7 +152,6 @@ import {
157import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";152import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
158import { BranchToolbar } from "./BranchToolbar";153import { BranchToolbar } from "./BranchToolbar";
159import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";154import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
160import PlanSidebar from "./PlanSidebar";
161import ThreadTerminalDrawer from "./ThreadTerminalDrawer";155import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
162import {156import {
163 AlarmClockIcon,157 AlarmClockIcon,
...@@ -234,7 +228,6 @@ import {...@@ -234,7 +228,6 @@ import {
234 useProject,228 useProject,
235 useProjects,229 useProjects,
236 useThread,230 useThread,
237 useThreadProposedPlans,
238 useThreadRefs,231 useThreadRefs,
239 useThreadShell,232 useThreadShell,
240} from "../state/entities";233} from "../state/entities";
...@@ -451,8 +444,6 @@ type EnvironmentUnavailableState = {...@@ -451,8 +444,6 @@ type EnvironmentUnavailableState = {
451 readonly connection: EnvironmentConnectionPresentation;444 readonly connection: EnvironmentConnectionPresentation;
452};445};
453446
454type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
455
456function eventPathContainsSelector(event: Event, selector: string): boolean {447function eventPathContainsSelector(event: Event, selector: string): boolean {
457 const path = event.composedPath();448 const path = event.composedPath();
458 if (path.length === 0 && event.target) {449 if (path.length === 0 && event.target) {
...@@ -1271,7 +1262,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1271,7 +1262,6 @@ function ChatViewContent(props: ChatViewProps) {
1271 (store) => store.setStickyModelSelection,1262 (store) => store.setStickyModelSelection,
1272 );1263 );
1273 const timestampFormat = settings.timestampFormat;1264 const timestampFormat = settings.timestampFormat;
1274 const autoOpenPlanSidebar = settings.autoOpenPlanSidebar;
1275 const navigate = useNavigate();1265 const navigate = useNavigate();
1276 const { resolvedTheme } = useTheme();1266 const { resolvedTheme } = useTheme();
1277 // Granular store selectors — avoid subscribing to prompt changes.1267 // Granular store selectors — avoid subscribing to prompt changes.
...@@ -1341,10 +1331,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1341,10 +1331,7 @@ function ChatViewContent(props: ChatViewProps) {
1341 >({});1331 >({});
1342 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =1332 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
1343 useState<Record<string, number>>({});1333 useState<Record<string, number>>({});
1344 const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);1334 const shouldUseRightPanelSheet = 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);
1348 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);1335 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
1349 const [pullRequestDialogState, setPullRequestDialogState] =1336 const [pullRequestDialogState, setPullRequestDialogState] =
1350 useState<PullRequestDialogState | null>(null);1337 useState<PullRequestDialogState | null>(null);
...@@ -1577,10 +1564,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1577,10 +1564,10 @@ function ChatViewContent(props: ChatViewProps) {
1577 );1564 );
1578 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();1565 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
1579 const rightPanelOpen = rightPanelState.isOpen;1566 const rightPanelOpen = rightPanelState.isOpen;
1580 const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet;1567 const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet;
1581 const rightPanelMaximized =1568 const rightPanelMaximized =
1582 canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey;1569 canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey;
1583 const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;1570 const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet;
15841571
1585 useEffect(() => {1572 useEffect(() => {
1586 if (!activeThreadRef) return;1573 if (!activeThreadRef) return;
...@@ -1607,36 +1594,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1607,36 +1594,11 @@ function ChatViewContent(props: ChatViewProps) {
1607 previewPanelOpen,1594 previewPanelOpen,
1608 ]);1595 ]);
16091596
1610 const planSidebarOpen = activeRightPanelKind === "plan";
1611
1612 const existingOpenTerminalThreadKeys = useMemo(() => {1597 const existingOpenTerminalThreadKeys = useMemo(() => {
1613 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);1598 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
1614 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));1599 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
1615 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);1600 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
1616 const activeLatestTurn = activeThread?.latestTurn ?? null;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 useEffect(() => {1602 useEffect(() => {
1641 setMountedTerminalThreadKeys((currentThreadIds) => {1603 setMountedTerminalThreadKeys((currentThreadIds) => {
1642 const nextThreadIds = reconcileMountedTerminalThreadIds({1604 const nextThreadIds = reconcileMountedTerminalThreadIds({
...@@ -2086,6 +2048,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2086,6 +2048,7 @@ function ChatViewContent(props: ChatViewProps) {
2086 const phase = derivePhase(activeThread?.session ?? null);2048 const phase = derivePhase(activeThread?.session ?? null);
2087 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;2049 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
2088 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);2050 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
2051 const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]);
2089 // Native subagent fold: memoized by activity-list identity, shared by the2052 // Native subagent fold: memoized by activity-list identity, shared by the
2090 // Agents surface, live strip, and workflow cards. v2Projection is null2053 // Agents surface, live strip, and workflow cards. v2Projection is null
2091 // until orchestration-v2 lands (source precedence lives in the derive).2054 // until orchestration-v2 lands (source precedence lives in the derive).
...@@ -2148,21 +2111,25 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2148,21 +2111,25 @@ function ChatViewContent(props: ChatViewProps) {
2148 activeLatestTurn?.turnId ?? null,2111 activeLatestTurn?.turnId ?? null,
2149 );2112 );
2150 }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]);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 const activePlan = useMemo(2114 const activePlan = useMemo(
2162 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),2115 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
2163 [activeLatestTurn?.turnId, threadActivities],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 const showPlanFollowUpPrompt =2133 const showPlanFollowUpPrompt =
2167 pendingUserInputs.length === 0 &&2134 pendingUserInputs.length === 0 &&
2168 interactionMode === "plan" &&2135 interactionMode === "plan" &&
...@@ -2431,8 +2398,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2431,8 +2398,13 @@ function ChatViewContent(props: ChatViewProps) {
2431 }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]);2398 }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]);
2432 const timelineEntries = useMemo(2399 const timelineEntries = useMemo(
2433 () =>2400 () =>
2434 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),2401 deriveTimelineEntries(
2435 [activeThread?.proposedPlans, timelineMessages, workLogEntries],2402 timelineMessages,
2403 activeThread?.proposedPlans ?? [],
2404 workLogEntries,
2405 turnPlans,
2406 ),
2407 [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries],
2436 );2408 );
2437 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);2409 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
2438 const draftHeroDockRequested =2410 const draftHeroDockRequested =
...@@ -3142,47 +3114,15 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3142,47 +3114,15 @@ function ChatViewContent(props: ChatViewProps) {
3142 const toggleInteractionMode = useCallback(() => {3114 const toggleInteractionMode = useCallback(() => {
3143 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");3115 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
3144 }, [handleInteractionModeChange, interactionMode]);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 const createBrowserSurface = useCallback(() => {3117 const createBrowserSurface = useCallback(() => {
3168 if (!activeThreadRef) return;3118 if (!activeThreadRef) return;
3169 void addBrowserSurface({ threadRef: activeThreadRef, openPreview });3119 void addBrowserSurface({ threadRef: activeThreadRef, openPreview });
3170 }, [activeThreadRef, openPreview]);3120 }, [activeThreadRef, openPreview]);
3171 const addDiffSurface = useCallback(() => {3121 const addDiffSurface = useCallback(() => {
3172 if (!activeThreadRef || !isServerThread || !isGitRepo) return;3122 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
3173 if (planSidebarOpen) {
3174 dismissPlanSidebarForCurrentTurn();
3175 }
3176 useRightPanelStore.getState().open(activeThreadRef, "diff");3123 useRightPanelStore.getState().open(activeThreadRef, "diff");
3177 onDiffPanelOpen?.();3124 onDiffPanelOpen?.();
3178 }, [3125 }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]);
3179 activeThreadRef,
3180 dismissPlanSidebarForCurrentTurn,
3181 isGitRepo,
3182 isServerThread,
3183 onDiffPanelOpen,
3184 planSidebarOpen,
3185 ]);
3186 const addFilesSurface = useCallback(() => {3126 const addFilesSurface = useCallback(() => {
3187 if (!activeThreadRef || !activeProject) return;3127 if (!activeThreadRef || !activeProject) return;
3188 useRightPanelStore.getState().open(activeThreadRef, "files");3128 useRightPanelStore.getState().open(activeThreadRef, "files");
...@@ -3318,11 +3258,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3318,11 +3258,6 @@ function ChatViewContent(props: ChatViewProps) {
3318 const activateRightPanelSurface = useCallback(3258 const activateRightPanelSurface = useCallback(
3319 (surface: RightPanelSurface) => {3259 (surface: RightPanelSurface) => {
3320 if (!activeThreadRef) return;3260 if (!activeThreadRef) return;
3321 if (surface.kind === "plan") {
3322 clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef));
3323 } else if (planSidebarOpen) {
3324 dismissPlanSidebarForCurrentTurn();
3325 }
3326 useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);3261 useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);
3327 if (surface.kind === "preview" && surface.resourceId) {3262 if (surface.kind === "preview" && surface.resourceId) {
3328 setActivePreviewTab(activeThreadRef, surface.resourceId);3263 setActivePreviewTab(activeThreadRef, surface.resourceId);
...@@ -3334,20 +3269,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3334,20 +3269,16 @@ function ChatViewContent(props: ChatViewProps) {
3334 onDiffPanelOpen?.();3269 onDiffPanelOpen?.();
3335 }3270 }
3336 },3271 },
3337 [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen],3272 [activeThreadRef, diffOpen, onDiffPanelOpen],
3338 );3273 );
3339 const toggleRightPanel = useCallback(() => {3274 const toggleRightPanel = useCallback(() => {
3340 if (!activeThreadRef) return;3275 if (!activeThreadRef) return;
3341 if (rightPanelOpen) {3276 if (rightPanelOpen) {
3342 if (planSidebarOpen) {3277 closePreviewPanel();
3343 closePlanSidebar();
3344 } else {
3345 closePreviewPanel();
3346 }
3347 return;3278 return;
3348 }3279 }
3349 useRightPanelStore.getState().toggleVisibility(activeThreadRef);3280 useRightPanelStore.getState().toggleVisibility(activeThreadRef);
3350 }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]);3281 }, [activeThreadRef, closePreviewPanel, rightPanelOpen]);
3351 const toggleRightPanelMaximized = useCallback(() => {3282 const toggleRightPanelMaximized = useCallback(() => {
3352 if (!canMaximizeRightPanel) return;3283 if (!canMaximizeRightPanel) return;
3353 setMaximizedRightPanelThreadKey((threadKey) =>3284 setMaximizedRightPanelThreadKey((threadKey) =>
...@@ -3357,10 +3288,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3357,10 +3288,6 @@ function ChatViewContent(props: ChatViewProps) {
3357 const cleanupRightPanelSurfaces = useCallback(3288 const cleanupRightPanelSurfaces = useCallback(
3358 (surfaces: readonly RightPanelSurface[]) => {3289 (surfaces: readonly RightPanelSurface[]) => {
3359 if (!activeThreadRef) return;3290 if (!activeThreadRef) return;
3360 if (surfaces.some((surface) => surface.kind === "plan")) {
3361 dismissPlanSidebarForCurrentTurn();
3362 }
3363
3364 for (const surface of surfaces) {3291 for (const surface of surfaces) {
3365 if (surface.kind === "preview" && surface.resourceId) {3292 if (surface.kind === "preview" && surface.resourceId) {
3366 void closePreviewSession({3293 void closePreviewSession({
...@@ -3386,7 +3313,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3386,7 +3313,6 @@ function ChatViewContent(props: ChatViewProps) {
3386 activePreviewState.sessions,3313 activePreviewState.sessions,
3387 closePreview,3314 closePreview,
3388 closeTerminalMutation,3315 closeTerminalMutation,
3389 dismissPlanSidebarForCurrentTurn,
3390 storeCloseTerminal,3316 storeCloseTerminal,
3391 ],3317 ],
3392 );3318 );
...@@ -3976,37 +3902,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3976,37 +3902,9 @@ function ChatViewContent(props: ChatViewProps) {
3976 activeTimelineAnchorIndexRef.current = null;3902 activeTimelineAnchorIndexRef.current = null;
3977 showScrollDebouncer.current.cancel();3903 showScrollDebouncer.current.cancel();
3978 setShowScrollToBottom(false);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 // activeThreadRef resets transitively with the active thread.3905 // activeThreadRef resets transitively with the active thread.
3987 }, [activeThread?.id]);3906 }, [activeThread?.id]);
39883907
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 useEffect(() => {3908 useEffect(() => {
4011 setIsRevertingCheckpoint(false);3909 setIsRevertingCheckpoint(false);
4012 }, [activeThread?.id]);3910 }, [activeThread?.id]);
...@@ -5556,15 +5454,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5556,15 +5454,6 @@ function ChatViewContent(props: ChatViewProps) {
55565454
5557 if (failure === null) {5455 if (failure === null) {
5558 acknowledgeActiveThreadWoke();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 sendInFlightRef.current = false;5457 sendInFlightRef.current = false;
5569 return;5458 return;
5570 }5459 }
...@@ -5597,7 +5486,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5597,7 +5486,6 @@ function ChatViewContent(props: ChatViewProps) {
5597 setComposerDraftInteractionMode,5486 setComposerDraftInteractionMode,
5598 setThreadError,5487 setThreadError,
5599 startThreadTurn,5488 startThreadTurn,
5600 autoOpenPlanSidebar,
5601 environmentId,5489 environmentId,
5602 composerRef,5490 composerRef,
5603 ],5491 ],
...@@ -5700,8 +5588,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5700,8 +5588,6 @@ function ChatViewContent(props: ChatViewProps) {
5700 }5588 }
57015589
5702 if (failure === null) {5590 if (failure === null) {
5703 // Signal that the plan sidebar should open on the new thread when enabled.
5704 planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar;
5705 const navigateResult = await settlePromise(() =>5591 const navigateResult = await settlePromise(() =>
5706 navigate({5592 navigate({
5707 to: "/$environmentId/$threadId",5593 to: "/$environmentId/$threadId",
...@@ -5758,7 +5644,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5758,7 +5644,6 @@ function ChatViewContent(props: ChatViewProps) {
5758 resetLocalDispatch,5644 resetLocalDispatch,
5759 runtimeMode,5645 runtimeMode,
5760 startThreadTurn,5646 startThreadTurn,
5761 autoOpenPlanSidebar,
5762 environmentId,5647 environmentId,
5763 composerRef,5648 composerRef,
5764 ]);5649 ]);
...@@ -5950,12 +5835,12 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5950,12 +5835,12 @@ function ChatViewContent(props: ChatViewProps) {
5950 <div5835 <div
5951 className={cn(5836 className={cn(
5952 "workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]",5837 "workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]",
5953 rightPanelOpen && !shouldUsePlanSidebarSheet5838 rightPanelOpen && !shouldUseRightPanelSheet
5954 ? "right-2 wco:right-[var(--workspace-controls-right)]"5839 ? "right-2 wco:right-[var(--workspace-controls-right)]"
5955 : "mr-px",5840 : "mr-px",
5956 )}5841 )}
5957 >5842 >
5958 {rightPanelOpen && !shouldUsePlanSidebarSheet ? (5843 {rightPanelOpen && !shouldUseRightPanelSheet ? (
5959 <RightPanelMaximizeControl5844 <RightPanelMaximizeControl
5960 maximized={rightPanelMaximized}5845 maximized={rightPanelMaximized}
5961 onToggle={toggleRightPanelMaximized}5846 onToggle={toggleRightPanelMaximized}
...@@ -6005,18 +5890,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6005,18 +5890,6 @@ function ChatViewContent(props: ChatViewProps) {
6005 initialGitScope={initialDiffPanelGitScope}5890 initialGitScope={initialDiffPanelGitScope}
6006 />5891 />
6007 </Suspense>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 ) : activeRightPanelSurface?.kind === "agents" ? (5893 ) : activeRightPanelSurface?.kind === "agents" ? (
6021 <AgentsPanel5894 <AgentsPanel
6022 model={agentPanelModel}5895 model={agentPanelModel}
...@@ -6050,7 +5923,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6050,7 +5923,7 @@ function ChatViewContent(props: ChatViewProps) {
60505923
6051 return (5924 return (
6052 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">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 <div5927 <div
6055 className={cn(5928 className={cn(
6056 "flex min-h-0 min-w-0 flex-col overflow-x-hidden",5929 "flex min-h-0 min-w-0 flex-col overflow-x-hidden",
...@@ -6122,6 +5995,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6122,6 +5995,7 @@ function ChatViewContent(props: ChatViewProps) {
6122 onOpenAgents={addAgentsSurface}5995 onOpenAgents={addAgentsSurface}
6123 key={activeThread.id}5996 key={activeThread.id}
6124 isWorking={isWorking}5997 isWorking={isWorking}
5998 workingStepLabel={workingStepLabel}
6125 activeTurnInProgress={isWorking || !latestTurnSettled}5999 activeTurnInProgress={isWorking || !latestTurnSettled}
6126 activeTurnStartedAt={activeWorkStartedAt}6000 activeTurnStartedAt={activeWorkStartedAt}
6127 listRef={legendListRef}6001 listRef={legendListRef}
...@@ -6264,10 +6138,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6264,10 +6138,6 @@ function ChatViewContent(props: ChatViewProps) {
6264 respondingRequestIds={respondingRequestIds}6138 respondingRequestIds={respondingRequestIds}
6265 showPlanFollowUpPrompt={showPlanFollowUpPrompt}6139 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
6266 activeProposedPlan={activeProposedPlan}6140 activeProposedPlan={activeProposedPlan}
6267 activePlan={activePlan as { turnId?: TurnId } | null}
6268 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
6269 planSidebarLabel={planSidebarLabel}
6270 planSidebarOpen={planSidebarOpen}
6271 runtimeMode={runtimeMode}6141 runtimeMode={runtimeMode}
6272 interactionMode={interactionMode}6142 interactionMode={interactionMode}
6273 lockedProvider={lockedProvider}6143 lockedProvider={lockedProvider}
...@@ -6305,7 +6175,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6305,7 +6175,6 @@ function ChatViewContent(props: ChatViewProps) {
6305 toggleInteractionMode={toggleInteractionMode}6175 toggleInteractionMode={toggleInteractionMode}
6306 handleRuntimeModeChange={handleRuntimeModeChange}6176 handleRuntimeModeChange={handleRuntimeModeChange}
6307 handleInteractionModeChange={handleInteractionModeChange}6177 handleInteractionModeChange={handleInteractionModeChange}
6308 togglePlanSidebar={togglePlanSidebar}
6309 focusComposer={focusComposer}6178 focusComposer={focusComposer}
6310 scheduleComposerFocus={scheduleComposerFocus}6179 scheduleComposerFocus={scheduleComposerFocus}
6311 setThreadError={setThreadError}6180 setThreadError={setThreadError}
...@@ -6440,7 +6309,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6440,7 +6309,7 @@ function ChatViewContent(props: ChatViewProps) {
6440 ))}6309 ))}
6441 </div>6310 </div>
64426311
6443 {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? (6312 {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? (
6444 <RightPanelTabs6313 <RightPanelTabs
6445 mode="inline"6314 mode="inline"
6446 maximized={rightPanelMaximized}6315 maximized={rightPanelMaximized}
...@@ -6467,8 +6336,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6467,8 +6336,8 @@ function ChatViewContent(props: ChatViewProps) {
6467 {rightPanelContent}6336 {rightPanelContent}
6468 </RightPanelTabs>6337 </RightPanelTabs>
6469 ) : null}6338 ) : null}
6470 {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? (6339 {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? (
6471 <RightPanelSheet open onClose={planSidebarOpen ? closePlanSidebar : closePreviewPanel}>6340 <RightPanelSheet open onClose={closePreviewPanel}>
6472 <RightPanelTabs6341 <RightPanelTabs
6473 mode="sheet"6342 mode="sheet"
6474 layoutControls={panelToggleControls}6343 layoutControls={panelToggleControls}
apps/web/src/components/PlanSidebar.tsx deleted-284
...@@ -1,284 +0,0 @@
1import { memo, useState, useCallback } from "react";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
7import { type TimestampFormat } from "@t3tools/contracts/settings";
8import { Badge } from "./ui/badge";
9import { Button } from "./ui/button";
10import { ScrollArea } from "./ui/scroll-area";
11import ChatMarkdown from "./ChatMarkdown";
12import {
13 CheckIcon,
14 ChevronDownIcon,
15 ChevronRightIcon,
16 EllipsisIcon,
17 LoaderIcon,
18} from "lucide-react";
19import { cn } from "~/lib/utils";
20import type { ActivePlanState } from "../session-logic";
21import type { LatestProposedPlanState } from "../session-logic";
22import { formatTimestamp } from "../timestampFormat";
23import {
24 proposedPlanTitle,
25 buildProposedPlanMarkdownFilename,
26 normalizePlanMarkdownForExport,
27 downloadPlanAsTextFile,
28 stripDisplayedPlanMarkdown,
29} from "../proposedPlan";
30import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
31import { projectEnvironment } from "~/state/projects";
32import { stackedThreadToast, toastManager } from "./ui/toast";
33import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
34import { useAtomCommand } from "~/state/use-atom-command";
35
36function 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
58interface 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
70const 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
283export default PlanSidebar;
284export type { PlanSidebarProps };
apps/web/src/components/RightPanelTabs.tsx+1-5
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts";1import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts";
2import { getTerminalLabel } from "@t3tools/shared/terminalLabels";2import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
3import { Bot, ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react";3import { Bot, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react";
4import {4import {
5 type MouseEvent as ReactMouseEvent,5 type MouseEvent as ReactMouseEvent,
6 type ReactElement,6 type ReactElement,
...@@ -213,8 +213,6 @@ function surfaceTitle(...@@ -213,8 +213,6 @@ function surfaceTitle(
213 terminalLabelsById.get(surface.activeTerminalId) ??213 terminalLabelsById.get(surface.activeTerminalId) ??
214 getTerminalLabel(surface.activeTerminalId)214 getTerminalLabel(surface.activeTerminalId)
215 );215 );
216 case "plan":
217 return "Plan";
218 case "agents":216 case "agents":
219 return "Agents";217 return "Agents";
220 case "preview": {218 case "preview": {
...@@ -276,8 +274,6 @@ function SurfaceIcon({...@@ -276,8 +274,6 @@ function SurfaceIcon({
276 );274 );
277 case "terminal":275 case "terminal":
278 return <TerminalSquare className="size-3 shrink-0" />;276 return <TerminalSquare className="size-3 shrink-0" />;
279 case "plan":
280 return <ClipboardList className="size-3 shrink-0" />;
281 case "agents":277 case "agents":
282 return <Bot className="size-3 shrink-0" />;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,7 +1098,18 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
1098 ) : null}1098 ) : null}
1099 </div>1099 </div>
1100 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75">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 <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span>1113 <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span>
1103 ) : (1114 ) : (
1104 <span className="flex-1" />1115 <span className="flex-1" />
apps/web/src/components/chat/ChatComposer.tsx-58
...@@ -10,7 +10,6 @@ import type {...@@ -10,7 +10,6 @@ import type {
10 ScopedThreadRef,10 ScopedThreadRef,
11 ServerProvider,11 ServerProvider,
12 ThreadId,12 ThreadId,
13 TurnId,
14} from "@t3tools/contracts";13} from "@t3tools/contracts";
15import {14import {
16 ProviderDriverKind,15 ProviderDriverKind,
...@@ -195,7 +194,6 @@ import { toastManager } from "../ui/toast";...@@ -195,7 +194,6 @@ import { toastManager } from "../ui/toast";
195import {194import {
196 BotIcon,195 BotIcon,
197 CircleAlertIcon,196 CircleAlertIcon,
198 ListTodoIcon,
199 PencilRulerIcon,197 PencilRulerIcon,
200 type LucideIcon,198 type LucideIcon,
201 LockIcon,199 LockIcon,
...@@ -300,12 +298,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -300,12 +298,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
300 showInteractionModeToggle: boolean;298 showInteractionModeToggle: boolean;
301 interactionMode: ProviderInteractionMode;299 interactionMode: ProviderInteractionMode;
302 runtimeMode: RuntimeMode;300 runtimeMode: RuntimeMode;
303 showPlanToggle: boolean;
304 planSidebarLabel: string;
305 planSidebarOpen: boolean;
306 onToggleInteractionMode: () => void;301 onToggleInteractionMode: () => void;
307 onRuntimeModeChange: (mode: RuntimeMode) => void;302 onRuntimeModeChange: (mode: RuntimeMode) => void;
308 onTogglePlanSidebar: () => void;
309}) {303}) {
310 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];304 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
311 const RuntimeModeIcon = runtimeModeOption.icon;305 const RuntimeModeIcon = runtimeModeOption.icon;
...@@ -313,9 +307,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -313,9 +307,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
313 props.interactionMode === "plan"307 props.interactionMode === "plan"
314 ? "Plan mode — click to return to normal build mode"308 ? "Plan mode — click to return to normal build mode"
315 : "Default mode — click to enter plan mode";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`;
319310
320 const interactionModeToggle = props.showInteractionModeToggle ? (311 const interactionModeToggle = props.showInteractionModeToggle ? (
321 <>312 <>
...@@ -391,36 +382,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -391,36 +382,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
391 </Tooltip>382 </Tooltip>
392383
393 {interactionModeToggle}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,10 +537,6 @@ export interface ChatComposerProps {
576 // Plan537 // Plan
577 showPlanFollowUpPrompt: boolean;538 showPlanFollowUpPrompt: boolean;
578 activeProposedPlan: Thread["proposedPlans"][number] | null;539 activeProposedPlan: Thread["proposedPlans"][number] | null;
579 activePlan: { turnId?: TurnId } | null;
580 sidebarProposedPlan: { turnId?: TurnId } | null;
581 planSidebarLabel: string;
582 planSidebarOpen: boolean;
583540
584 // Mode541 // Mode
585 runtimeMode: RuntimeMode;542 runtimeMode: RuntimeMode;
...@@ -632,7 +589,6 @@ export interface ChatComposerProps {...@@ -632,7 +589,6 @@ export interface ChatComposerProps {
632 toggleInteractionMode: () => void;589 toggleInteractionMode: () => void;
633 handleRuntimeModeChange: (mode: RuntimeMode) => void;590 handleRuntimeModeChange: (mode: RuntimeMode) => void;
634 handleInteractionModeChange: (mode: ProviderInteractionMode) => void;591 handleInteractionModeChange: (mode: ProviderInteractionMode) => void;
635 togglePlanSidebar: () => void;
636592
637 focusComposer: () => void;593 focusComposer: () => void;
638 scheduleComposerFocus: () => void;594 scheduleComposerFocus: () => void;
...@@ -675,10 +631,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -675,10 +631,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
675 respondingRequestIds,631 respondingRequestIds,
676 showPlanFollowUpPrompt,632 showPlanFollowUpPrompt,
677 activeProposedPlan,633 activeProposedPlan,
678 activePlan,
679 sidebarProposedPlan,
680 planSidebarLabel,
681 planSidebarOpen,
682 runtimeMode,634 runtimeMode,
683 interactionMode,635 interactionMode,
684 lockedProvider,636 lockedProvider,
...@@ -709,7 +661,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -709,7 +661,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
709 toggleInteractionMode,661 toggleInteractionMode,
710 handleRuntimeModeChange,662 handleRuntimeModeChange,
711 handleInteractionModeChange,663 handleInteractionModeChange,
712 togglePlanSidebar,
713 focusComposer,664 focusComposer,
714 scheduleComposerFocus,665 scheduleComposerFocus,
715 setThreadError,666 setThreadError,
...@@ -1180,7 +1131,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1180,7 +1131,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1180 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;1131 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
11811132
1182 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;1133 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
1183 const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen);
1184 const composerFooterActionLayoutKey = useMemo(() => {1134 const composerFooterActionLayoutKey = useMemo(() => {
1185 if (activePendingProgress) {1135 if (activePendingProgress) {
1186 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;1136 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
...@@ -3187,15 +3137,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3187,15 +3137,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31873137
3188 {isComposerFooterCompact ? (3138 {isComposerFooterCompact ? (
3189 <CompactComposerControlsMenu3139 <CompactComposerControlsMenu
3190 activePlan={showPlanSidebarToggle}
3191 interactionMode={interactionMode}3140 interactionMode={interactionMode}
3192 planSidebarLabel={planSidebarLabel}
3193 planSidebarOpen={planSidebarOpen}
3194 runtimeMode={runtimeMode}3141 runtimeMode={runtimeMode}
3195 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}3142 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
3196 traitsMenuContent={providerTraitsMenuContent}3143 traitsMenuContent={providerTraitsMenuContent}
3197 onToggleInteractionMode={toggleInteractionMode}3144 onToggleInteractionMode={toggleInteractionMode}
3198 onTogglePlanSidebar={togglePlanSidebar}
3199 onRuntimeModeChange={handleRuntimeModeChange}3145 onRuntimeModeChange={handleRuntimeModeChange}
3200 />3146 />
3201 ) : (3147 ) : (
...@@ -3210,12 +3156,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3210,12 +3156,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3210 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}3156 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
3211 interactionMode={interactionMode}3157 interactionMode={interactionMode}
3212 runtimeMode={runtimeMode}3158 runtimeMode={runtimeMode}
3213 showPlanToggle={showPlanSidebarToggle}
3214 planSidebarLabel={planSidebarLabel}
3215 planSidebarOpen={planSidebarOpen}
3216 onToggleInteractionMode={toggleInteractionMode}3159 onToggleInteractionMode={toggleInteractionMode}
3217 onRuntimeModeChange={handleRuntimeModeChange}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,10 +1,9 @@
1import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";1import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";
2import { memo, type ReactNode } from "react";2import { memo, type ReactNode } from "react";
3import { EllipsisIcon, ListTodoIcon } from "lucide-react";3import { EllipsisIcon } from "lucide-react";
4import { Button } from "../ui/button";4import { Button } from "../ui/button";
5import {5import {
6 Menu,6 Menu,
7 MenuItem,
8 MenuPopup,7 MenuPopup,
9 MenuRadioGroup,8 MenuRadioGroup,
10 MenuRadioItem,9 MenuRadioItem,
...@@ -13,15 +12,11 @@ import {...@@ -13,15 +12,11 @@ import {
13} from "../ui/menu";12} from "../ui/menu";
1413
15export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {14export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
16 activePlan: boolean;
17 interactionMode: ProviderInteractionMode;15 interactionMode: ProviderInteractionMode;
18 planSidebarLabel: string;
19 planSidebarOpen: boolean;
20 runtimeMode: RuntimeMode;16 runtimeMode: RuntimeMode;
21 showInteractionModeToggle: boolean;17 showInteractionModeToggle: boolean;
22 traitsMenuContent?: ReactNode;18 traitsMenuContent?: ReactNode;
23 onToggleInteractionMode: () => void;19 onToggleInteractionMode: () => void;
24 onTogglePlanSidebar: () => void;
25 onRuntimeModeChange: (mode: RuntimeMode) => void;20 onRuntimeModeChange: (mode: RuntimeMode) => void;
26}) {21}) {
27 return (22 return (
...@@ -74,17 +69,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls...@@ -74,17 +69,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
74 <MenuRadioItem value="auto">Auto</MenuRadioItem>69 <MenuRadioItem value="auto">Auto</MenuRadioItem>
75 <MenuRadioItem value="full-access">Full access</MenuRadioItem>70 <MenuRadioItem value="full-access">Full access</MenuRadioItem>
76 </MenuRadioGroup>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 </MenuPopup>72 </MenuPopup>
89 </Menu>73 </Menu>
90 );74 );
apps/web/src/components/chat/MessagesTimeline.logic.ts+24
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 workEntryIndicatesToolNeutralStatus,4 workEntryIndicatesToolNeutralStatus,
5 workLogEntryIsToolLike,5 workLogEntryIsToolLike,
6 type TimelineEntry,6 type TimelineEntry,
7 type TurnPlanEntry,
7 type WorkLogEntry,8 type WorkLogEntry,
8} from "../../session-logic";9} from "../../session-logic";
9import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";10import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
...@@ -201,6 +202,12 @@ export type MessagesTimelineRow =...@@ -201,6 +202,12 @@ export type MessagesTimelineRow =
201 createdAt: string;202 createdAt: string;
202 proposedPlan: ProposedPlan;203 proposedPlan: ProposedPlan;
203 }204 }
205 | {
206 kind: "turn-plan";
207 id: string;
208 createdAt: string;
209 turnPlan: TurnPlanEntry;
210 }
204 | { kind: "working"; id: string; createdAt: string | null };211 | { kind: "working"; id: string; createdAt: string | null };
205212
206export interface StableMessagesTimelineRowsState {213export interface StableMessagesTimelineRowsState {
...@@ -576,6 +583,16 @@ export function deriveMessagesTimelineRows(input: {...@@ -576,6 +583,16 @@ export function deriveMessagesTimelineRows(input: {
576 continue;583 continue;
577 }584 }
578585
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 const assistantTurnStillInProgress =596 const assistantTurnStillInProgress =
580 timelineEntry.message.role === "assistant" &&597 timelineEntry.message.role === "assistant" &&
581 unsettledTurnId !== null &&598 unsettledTurnId !== null &&
...@@ -659,6 +676,13 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -659,6 +676,13 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
659 case "proposed-plan":676 case "proposed-plan":
660 return a.proposedPlan === (b as typeof a).proposedPlan;677 return a.proposedPlan === (b as typeof a).proposedPlan;
661678
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 case "work":686 case "work":
663 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);687 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);
664688
apps/web/src/components/chat/MessagesTimeline.tsx+115-4
...@@ -152,6 +152,8 @@ interface TimelineRowActivityState {...@@ -152,6 +152,8 @@ interface TimelineRowActivityState {
152 isRevertingCheckpoint: boolean;152 isRevertingCheckpoint: boolean;
153 activeTurnInProgress: boolean;153 activeTurnInProgress: boolean;
154 latestTurnId: TurnId | null;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}
156158
157const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);159const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
...@@ -196,6 +198,7 @@ interface MessagesTimelineProps {...@@ -196,6 +198,7 @@ interface MessagesTimelineProps {
196 agentPanelModel?: AgentPanelModel;198 agentPanelModel?: AgentPanelModel;
197 onOpenAgents?: () => void;199 onOpenAgents?: () => void;
198 isWorking: boolean;200 isWorking: boolean;
201 workingStepLabel?: string | null;
199 activeTurnInProgress: boolean;202 activeTurnInProgress: boolean;
200 activeTurnStartedAt: string | null;203 activeTurnStartedAt: string | null;
201 listRef: React.RefObject<LegendListRef | null>;204 listRef: React.RefObject<LegendListRef | null>;
...@@ -240,6 +243,7 @@ interface MessagesTimelineProps {...@@ -240,6 +243,7 @@ interface MessagesTimelineProps {
240243
241export const MessagesTimeline = memo(function MessagesTimeline({244export const MessagesTimeline = memo(function MessagesTimeline({
242 isWorking,245 isWorking,
246 workingStepLabel = null,
243 activeTurnInProgress,247 activeTurnInProgress,
244 activeTurnStartedAt,248 activeTurnStartedAt,
245 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,249 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
...@@ -508,8 +512,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -508,8 +512,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
508 isRevertingCheckpoint,512 isRevertingCheckpoint,
509 activeTurnInProgress,513 activeTurnInProgress,
510 latestTurnId: latestTurn?.turnId ?? null,514 latestTurnId: latestTurn?.turnId ?? null,
515 workingStepLabel,
511 }),516 }),
512 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId],517 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel],
513 );518 );
514519
515 // Stable renderItem — no closure deps. Row components read shared state520 // Stable renderItem — no closure deps. Row components read shared state
...@@ -911,7 +916,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -911,7 +916,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
911 // they sit closer to the work that follows them.916 // they sit closer to the work that follows them.
912 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||917 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||
913 row.kind === "work" ||918 row.kind === "work" ||
914 row.kind === "work-toggle"919 row.kind === "work-toggle" ||
920 row.kind === "turn-plan"
915 ? "pb-2"921 ? "pb-2"
916 : "pb-4",922 : "pb-4",
917 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,923 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
...@@ -929,6 +935,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -929,6 +935,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
929 <AssistantTimelineRow row={row} />935 <AssistantTimelineRow row={row} />
930 ) : null}936 ) : null}
931 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}937 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
938 {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null}
932 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}939 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
933 </div>940 </div>
934 );941 );
...@@ -1159,16 +1166,117 @@ function ProposedPlanTimelineRow({...@@ -1159,16 +1166,117 @@ function ProposedPlanTimelineRow({
1159 );1166 );
1160}1167}
11611168
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 */
1174const 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
1162function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {1269function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1270 const { workingStepLabel } = use(TimelineRowActivityCtx);
1163 return (1271 return (
1164 <div className="py-0.5 pl-1.5">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 <span className="inline-flex items-center gap-[3px]">1274 <span className="inline-flex items-center gap-[3px]">
1167 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />1275 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />
1168 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" />1276 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" />
1169 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" />1277 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" />
1170 </span>1278 </span>
1171 <span>1279 <span className="shrink-0">
1172 {row.createdAt ? (1280 {row.createdAt ? (
1173 <>1281 <>
1174 Working for <WorkingTimer createdAt={row.createdAt} />1282 Working for <WorkingTimer createdAt={row.createdAt} />
...@@ -1177,6 +1285,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -1177,6 +1285,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
1177 "Working..."1285 "Working..."
1178 )}1286 )}
1179 </span>1287 </span>
1288 {workingStepLabel ? (
1289 <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span>
1290 ) : null}
1180 </div>1291 </div>
1181 </div>1292 </div>
1182 );1293 );
apps/web/src/components/settings/SettingsPanels.tsx-31
...@@ -623,9 +623,6 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -623,9 +623,6 @@ export function useSettingsRestore(onRestored?: () => void) {
623 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace623 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
624 ? ["Diff whitespace changes"]624 ? ["Diff whitespace changes"]
625 : []),625 : []),
626 ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
627 ? ["Auto-open task panel"]
628 : []),
629 ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming626 ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
630 ? ["Assistant output"]627 ? ["Assistant output"]
631 : []),628 : []),
...@@ -655,7 +652,6 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -655,7 +652,6 @@ export function useSettingsRestore(onRestored?: () => void) {
655 [652 [
656 isTextGenerationModelDirty,653 isTextGenerationModelDirty,
657 isBackgroundActivityDirty,654 isBackgroundActivityDirty,
658 settings.autoOpenPlanSidebar,
659 settings.confirmThreadArchive,655 settings.confirmThreadArchive,
660 settings.confirmThreadDelete,656 settings.confirmThreadDelete,
661 settings.addProjectBaseDirectory,657 settings.addProjectBaseDirectory,
...@@ -701,7 +697,6 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -701,7 +697,6 @@ export function useSettingsRestore(onRestored?: () => void) {
701 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,697 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
702 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,698 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
703 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,699 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
704 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
705 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,700 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
706 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,701 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
707 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,702 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
...@@ -1897,32 +1892,6 @@ export function GeneralSettingsPanel() {...@@ -1897,32 +1892,6 @@ export function GeneralSettingsPanel() {
1897 }1892 }
1898 />1893 />
18991894
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 <SettingsRow1895 <SettingsRow
1927 {...searchableSetting("new-threads")}1896 {...searchableSetting("new-threads")}
1928 description="Pick the default workspace mode for newly created draft threads."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,11 +110,6 @@ export const SETTINGS_SEARCH_ITEMS = [
110 title: "Provider update checks",110 title: "Provider update checks",
111 to: "/settings/general",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 id: "new-threads",114 id: "new-threads",
120 title: "New threads",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 */
9const dismissedTurnByThreadKey = new Map<string, string>();
10
11export function dismissPlanSidebarForTurn(threadKey: string, turnKey: string): void {
12 dismissedTurnByThreadKey.set(threadKey, turnKey);
13}
14
15export function clearPlanSidebarDismissal(threadKey: string): void {
16 dismissedTurnByThreadKey.delete(threadKey);
17}
18
19export 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,6 +102,41 @@ describe("rightPanelStore", () => {
102 });102 });
103 });103 });
104104
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 it("open sets the active panel for a thread", () => {140 it("open sets the active panel for a thread", () => {
106 useRightPanelStore.getState().open(refA, "preview");141 useRightPanelStore.getState().open(refA, "preview");
107 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");142 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
...@@ -109,7 +144,7 @@ describe("rightPanelStore", () => {...@@ -109,7 +144,7 @@ describe("rightPanelStore", () => {
109 });144 });
110145
111 it("opening a different kind keeps both surfaces and activates the new one", () => {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 useRightPanelStore.getState().open(refA, "preview");148 useRightPanelStore.getState().open(refA, "preview");
114 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");149 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
115 expect(150 expect(
...@@ -119,7 +154,7 @@ describe("rightPanelStore", () => {...@@ -119,7 +154,7 @@ describe("rightPanelStore", () => {
119154
120 it("reopening an inactive singleton activates its existing surface", () => {155 it("reopening an inactive singleton activates its existing surface", () => {
121 useRightPanelStore.getState().open(refA, "diff");156 useRightPanelStore.getState().open(refA, "diff");
122 useRightPanelStore.getState().open(refA, "plan");157 useRightPanelStore.getState().open(refA, "agents");
123 useRightPanelStore.getState().open(refA, "diff");158 useRightPanelStore.getState().open(refA, "diff");
124159
125 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({160 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
...@@ -127,7 +162,7 @@ describe("rightPanelStore", () => {...@@ -127,7 +162,7 @@ describe("rightPanelStore", () => {
127 activeSurfaceId: "diff",162 activeSurfaceId: "diff",
128 surfaces: [163 surfaces: [
129 { id: "diff", kind: "diff" },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,15 +242,15 @@ describe("rightPanelStore", () => {
207242
208 it("removes persisted file surfaces when their workspace no longer exists", () => {243 it("removes persisted file surfaces when their workspace no longer exists", () => {
209 useRightPanelStore.getState().openFile(refA, "src/index.ts");244 useRightPanelStore.getState().openFile(refA, "src/index.ts");
210 useRightPanelStore.getState().open(refA, "plan");245 useRightPanelStore.getState().open(refA, "agents");
211 useRightPanelStore.getState().openFile(refA, "README.md");246 useRightPanelStore.getState().openFile(refA, "README.md");
212247
213 useRightPanelStore.getState().reconcileFileSurfaces(refA, false);248 useRightPanelStore.getState().reconcileFileSurfaces(refA, false);
214249
215 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({250 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
216 isOpen: true,251 isOpen: true,
217 activeSurfaceId: "plan",252 activeSurfaceId: "agents",
218 surfaces: [{ id: "plan", kind: "plan" }],253 surfaces: [{ id: "agents", kind: "agents" }],
219 });254 });
220255
221 useRightPanelStore.getState().openFile(refB, "conductor.json");256 useRightPanelStore.getState().openFile(refB, "conductor.json");
...@@ -228,13 +263,13 @@ describe("rightPanelStore", () => {...@@ -228,13 +263,13 @@ describe("rightPanelStore", () => {
228 });263 });
229264
230 it("close hides the panel without clearing its selected surface", () => {265 it("close hides the panel without clearing its selected surface", () => {
231 useRightPanelStore.getState().open(refA, "plan");266 useRightPanelStore.getState().open(refA, "agents");
232 useRightPanelStore.getState().close(refA);267 useRightPanelStore.getState().close(refA);
233 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();268 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
234 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({269 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
235 isOpen: false,270 isOpen: false,
236 activeSurfaceId: "plan",271 activeSurfaceId: "agents",
237 surfaces: [{ id: "plan", kind: "plan" }],272 surfaces: [{ id: "agents", kind: "agents" }],
238 });273 });
239 });274 });
240275
...@@ -264,12 +299,12 @@ describe("rightPanelStore", () => {...@@ -264,12 +299,12 @@ describe("rightPanelStore", () => {
264299
265 it("toggle to a different kind switches active", () => {300 it("toggle to a different kind switches active", () => {
266 useRightPanelStore.getState().toggle(refA, "preview");301 useRightPanelStore.getState().toggle(refA, "preview");
267 useRightPanelStore.getState().toggle(refA, "plan");302 useRightPanelStore.getState().toggle(refA, "agents");
268 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan");303 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("agents");
269 });304 });
270305
271 it("removeThread clears persisted state", () => {306 it("removeThread clears persisted state", () => {
272 useRightPanelStore.getState().open(refA, "plan");307 useRightPanelStore.getState().open(refA, "agents");
273 useRightPanelStore.getState().removeThread(refA);308 useRightPanelStore.getState().removeThread(refA);
274 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();309 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
275 });310 });
apps/web/src/rightPanelStore.ts+17-9
...@@ -5,7 +5,7 @@...@@ -5,7 +5,7 @@
5 * surface descriptors and the active surface, while each feature continues to5 * surface descriptors and the active surface, while each feature continues to
6 * own its durable resource state. Browser surfaces point at preview tab ids,6 * own its durable resource state. Browser surfaces point at preview tab ids,
7 * terminal surfaces point at terminal session ids, file surfaces point at7 * 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 */
10import { scopedThreadKey } from "@t3tools/client-runtime/environment";10import { scopedThreadKey } from "@t3tools/client-runtime/environment";
11import type { ScopedThreadRef } from "@t3tools/contracts";11import type { ScopedThreadRef } from "@t3tools/contracts";
...@@ -15,7 +15,6 @@ import { createJSONStorage, persist } from "zustand/middleware";...@@ -15,7 +15,6 @@ import { createJSONStorage, persist } from "zustand/middleware";
15import { resolveStorage } from "./lib/storage";15import { resolveStorage } from "./lib/storage";
1616
17export const RIGHT_PANEL_KINDS = [17export const RIGHT_PANEL_KINDS = [
18 "plan",
19 "diff",18 "diff",
20 "files",19 "files",
21 "file",20 "file",
...@@ -45,11 +44,11 @@ export type RightPanelSurface =...@@ -45,11 +44,11 @@ export type RightPanelSurface =
45 revealLine: number | null;44 revealLine: number | null;
46 revealRequestId: number;45 revealRequestId: number;
47 }46 }
48 | { id: "plan"; kind: "plan" }
49 | { id: "agents"; kind: "agents" };47 | { id: "agents"; kind: "agents" };
5048
51const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";49const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
52const RIGHT_PANEL_STORAGE_VERSION = 8;50// v9 removed the "plan" surface kind (plans render inline in the transcript).
51const RIGHT_PANEL_STORAGE_VERSION = 9;
5352
54export interface ThreadRightPanelState {53export interface ThreadRightPanelState {
55 isOpen: boolean;54 isOpen: boolean;
...@@ -99,8 +98,6 @@ const singletonSurface = (...@@ -99,8 +98,6 @@ const singletonSurface = (
99 return { id: "diff", kind };98 return { id: "diff", kind };
100 case "files":99 case "files":
101 return { id: "files", kind };100 return { id: "files", kind };
102 case "plan":
103 return { id: "plan", kind };
104 case "agents":101 case "agents":
105 return { id: "agents", kind };102 return { id: "agents", kind };
106 }103 }
...@@ -181,6 +178,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): {...@@ -181,6 +178,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
181 threadState && typeof threadState === "object" ? threadState : null;178 threadState && typeof threadState === "object" ? threadState : null;
182 const surfaces = Array.isArray(validThreadState?.surfaces)179 const surfaces = Array.isArray(validThreadState?.surfaces)
183 ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => {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 if (surface.kind === "file") {184 if (surface.kind === "file") {
185 const revealLine =185 const revealLine =
186 typeof surface.revealLine === "number" &&186 typeof surface.revealLine === "number" &&
...@@ -229,15 +229,23 @@ export function migratePersistedRightPanelState(persistedState: unknown): {...@@ -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 (surface) => surface.id === validThreadState?.activeSurfaceId,233 (surface) => surface.id === validThreadState?.activeSurfaceId,
234 )234 )
235 ? (validThreadState?.activeSurfaceId ?? null)235 ? (validThreadState?.activeSurfaceId ?? null)
236 : null;236 : null;
237 // A migration that dropped every surface (e.g. plan-only panels
238 // in v9) must not reopen an empty panel.
237 const isOpen =239 const isOpen =
238 typeof validThreadState?.isOpen === "boolean"240 surfaces.length > 0 &&
241 (typeof validThreadState?.isOpen === "boolean"
239 ? validThreadState.isOpen242 ? 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 return [threadKey, { isOpen, surfaces, activeSurfaceId }];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,12 +11,12 @@ import { describe, expect, it } from "vite-plus/test";
11import {11import {
12 deriveActiveWorkStartedAt,12 deriveActiveWorkStartedAt,
13 deriveActivePlanState,13 deriveActivePlanState,
14 deriveTurnPlans,
14 derivePendingApprovals,15 derivePendingApprovals,
15 derivePendingUserInputs,16 derivePendingUserInputs,
16 deriveTimelineEntries,17 deriveTimelineEntries,
17 deriveWorkLogEntries,18 deriveWorkLogEntries,
18 findLatestProposedPlan,19 findLatestProposedPlan,
19 findSidebarProposedPlan,
20 hasActionableProposedPlan,20 hasActionableProposedPlan,
21 isLatestTurnSettled,21 isLatestTurnSettled,
22 workEntryIndicatesToolFailure,22 workEntryIndicatesToolFailure,
...@@ -410,6 +410,95 @@ describe("deriveActivePlanState", () => {...@@ -410,6 +410,95 @@ describe("deriveActivePlanState", () => {
410 });410 });
411});411});
412412
413describe("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
413describe("findLatestProposedPlan", () => {502describe("findLatestProposedPlan", () => {
414 it("prefers the latest proposed plan for the active turn", () => {503 it("prefers the latest proposed plan for the active turn", () => {
415 expect(504 expect(
...@@ -515,103 +604,6 @@ describe("hasActionableProposedPlan", () => {...@@ -515,103 +604,6 @@ describe("hasActionableProposedPlan", () => {
515 });604 });
516});605});
517606
518describe("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
615describe("workEntryIndicatesToolFailure", () => {607describe("workEntryIndicatesToolFailure", () => {
616 const base = {608 const base = {
617 id: "w1",609 id: "w1",
apps/web/src/session-logic.ts+85-46
...@@ -151,6 +151,12 @@ export type TimelineEntry =...@@ -151,6 +151,12 @@ export type TimelineEntry =
151 createdAt: string;151 createdAt: string;
152 proposedPlan: ProposedPlan;152 proposedPlan: ProposedPlan;
153 }153 }
154 | {
155 id: string;
156 kind: "turn-plan";
157 createdAt: string;
158 turnPlan: TurnPlanEntry;
159 }
154 | {160 | {
155 id: string;161 id: string;
156 kind: "work";162 kind: "work";
...@@ -532,26 +538,10 @@ export function derivePendingUserInputs(...@@ -532,26 +538,10 @@ export function derivePendingUserInputs(
532 );538 );
533}539}
534540
535export function deriveActivePlanState(541function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null {
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 }
552 const payload =542 const payload =
553 latest.payload && typeof latest.payload === "object"543 activity.payload && typeof activity.payload === "object"
554 ? (latest.payload as Record<string, unknown>)544 ? (activity.payload as Record<string, unknown>)
555 : null;545 : null;
556 const rawPlan = payload?.plan;546 const rawPlan = payload?.plan;
557 if (!Array.isArray(rawPlan)) {547 if (!Array.isArray(rawPlan)) {
...@@ -580,8 +570,8 @@ export function deriveActivePlanState(...@@ -580,8 +570,8 @@ export function deriveActivePlanState(
580 return null;570 return null;
581 }571 }
582 return {572 return {
583 createdAt: latest.createdAt,573 createdAt: activity.createdAt,
584 turnId: latest.turnId,574 turnId: activity.turnId,
585 ...(payload && "explanation" in payload575 ...(payload && "explanation" in payload
586 ? { explanation: payload.explanation as string | null }576 ? { explanation: payload.explanation as string | null }
587 : {}),577 : {}),
...@@ -589,6 +579,72 @@ export function deriveActivePlanState(...@@ -589,6 +579,72 @@ export function deriveActivePlanState(
589 };579 };
590}580}
591581
582export 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
602export 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 */
616export 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
592export function findLatestProposedPlan(648export function findLatestProposedPlan(
593 proposedPlans: ReadonlyArray<ProposedPlan>,649 proposedPlans: ReadonlyArray<ProposedPlan>,
594 latestTurnId: TurnId | string | null | undefined,650 latestTurnId: TurnId | string | null | undefined,
...@@ -619,30 +675,6 @@ export function findLatestProposedPlan(...@@ -619,30 +675,6 @@ export function findLatestProposedPlan(
619 return toLatestProposedPlanState(latestPlan);675 return toLatestProposedPlanState(latestPlan);
620}676}
621677
622export 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
646export function hasActionableProposedPlan(678export function hasActionableProposedPlan(
647 proposedPlan: LatestProposedPlanState | Pick<ProposedPlan, "implementedAt"> | null,679 proposedPlan: LatestProposedPlanState | Pick<ProposedPlan, "implementedAt"> | null,
648): boolean {680): boolean {
...@@ -1542,6 +1574,7 @@ export function deriveTimelineEntries(...@@ -1542,6 +1574,7 @@ export function deriveTimelineEntries(
1542 messages: ReadonlyArray<ChatMessage>,1574 messages: ReadonlyArray<ChatMessage>,
1543 proposedPlans: ReadonlyArray<ProposedPlan>,1575 proposedPlans: ReadonlyArray<ProposedPlan>,
1544 workEntries: ReadonlyArray<WorkLogEntry>,1576 workEntries: ReadonlyArray<WorkLogEntry>,
1577 turnPlans: ReadonlyArray<TurnPlanEntry> = [],
1545): TimelineEntry[] {1578): TimelineEntry[] {
1546 const messageRows: TimelineEntry[] = messages.map((message) => ({1579 const messageRows: TimelineEntry[] = messages.map((message) => ({
1547 id: message.id,1580 id: message.id,
...@@ -1555,13 +1588,19 @@ export function deriveTimelineEntries(...@@ -1555,13 +1588,19 @@ export function deriveTimelineEntries(
1555 createdAt: proposedPlan.createdAt,1588 createdAt: proposedPlan.createdAt,
1556 proposedPlan,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 const workRows: TimelineEntry[] = workEntries.map((entry) => ({1597 const workRows: TimelineEntry[] = workEntries.map((entry) => ({
1559 id: entry.id,1598 id: entry.id,
1560 kind: "work",1599 kind: "work",
1561 createdAt: entry.createdAt,1600 createdAt: entry.createdAt,
1562 entry,1601 entry,
1563 }));1602 }));
1564 return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) =>1603 return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) =>
1565 a.createdAt.localeCompare(b.createdAt),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,6 +446,20 @@ export const OrchestrationThreadShell = Schema.Struct({
446 * live work. Optional so old servers/clients interop; absent = none.446 * live work. Optional so old servers/clients interop; absent = none.
447 */447 */
448 backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))),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});
450export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;464export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;
451465
packages/contracts/src/settings.ts-2
...@@ -111,7 +111,6 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200))...@@ -111,7 +111,6 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200))
111export type FontFamilyPreference = typeof FontFamilyPreference.Type;111export type FontFamilyPreference = typeof FontFamilyPreference.Type;
112112
113export const ClientSettingsSchema = Schema.Struct({113export const ClientSettingsSchema = Schema.Struct({
114 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
115 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),114 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
116 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),115 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
117 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(116 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
...@@ -747,7 +746,6 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -747,7 +746,6 @@ export const ServerSettingsPatch = Schema.Struct({
747export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;746export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
748747
749export const ClientSettingsPatch = Schema.Struct({748export const ClientSettingsPatch = Schema.Struct({
750 autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean),
751 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),749 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
752 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),750 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
753 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),751 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),