From 485d4175fec7c479780070c289d356dc130a5ca1 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 10 Apr 2026 16:33:15 +0300 Subject: [PATCH 1/2] perf(bootstrap): lazy thread hydration with listing snapshot - Fetch lightweight listing snapshot (thread summaries only) on initial load - Sidebar renders immediately with hollow threads and skeleton indicators - Full thread data fetches on-demand via getThread(threadId) on navigation - Reduce initial JSON parsing and unblock UI during app startup - Add text reveal animation on message completion with smooth scroll anchoring - Add ResizeObserver for content height tracking and scroll synchronization - Threads hydrate incrementally from domain events or full snapshot fallback --- AGENTS.md | 13 +- .../Layers/CheckpointDiffQuery.test.ts | 6 + .../Layers/OrchestrationEngine.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 541 ++++++++++++++++++ .../Services/ProjectionSnapshotQuery.ts | 19 + apps/server/src/ws.ts | 31 + apps/web/src/components/ChatView.tsx | 62 +- apps/web/src/components/Sidebar.tsx | 26 +- .../components/chat/MessagesTimeline.test.tsx | 2 + .../src/components/chat/MessagesTimeline.tsx | 144 ++++- apps/web/src/components/chat/TextReveal.tsx | 54 +- apps/web/src/hooks/useSmoothReveal.ts | 239 ++++++++ apps/web/src/index.css | 66 ++- apps/web/src/routes/__root.tsx | 19 +- apps/web/src/routes/_chat.$threadId.tsx | 35 +- apps/web/src/store.ts | 89 +++ apps/web/src/wsNativeApi.ts | 2 + apps/web/src/wsRpcClient.ts | 8 + packages/contracts/src/ipc.ts | 5 + packages/contracts/src/orchestration.ts | 71 +++ packages/contracts/src/rpc.ts | 21 + 21 files changed, 1360 insertions(+), 97 deletions(-) create mode 100644 apps/web/src/hooks/useSmoothReveal.ts diff --git a/AGENTS.md b/AGENTS.md index 5240d0eb518a..db24f7651392 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,13 +74,24 @@ Docs: ## Performance: State & Rendering Architecture +### Two-Phase Bootstrap (`__root.tsx`, `store.ts`, `ProjectionSnapshotQuery.ts`) + +The initial data load uses a **two-phase bootstrap** to render the sidebar immediately: + +1. **Phase 1 — Listing Snapshot** (`getListingSnapshot()`): Fetches projects + lightweight `OrchestrationThreadSummary` (thread metadata, sessions, latest turns, pre-computed `latestUserMessageAt` via SQL aggregate). Skips messages, activities, checkpoints, and proposed plans. Sets `bootstrapComplete = true` so the sidebar renders immediately. Threads in the store are "hollow" (empty `messages[]`, `activities[]`, etc.). + +2. **Phase 2 — Lazy Thread Hydration** (`getThread(threadId)`): When the user navigates to a thread, `_chat.$threadId.tsx` checks `isThreadHydrated()` (messages exist or no turn has happened). If hollow, it calls `getThread()` to fetch full data for that single thread and calls `hydrateThread()` to replace the hollow thread in the store. + +Sidebar summary fields (`hasPendingApprovals`, `hasPendingUserInput`, `hasActionableProposedPlan`) default to `false` in the listing snapshot — real-time domain events correct these for active threads within seconds. + +Full `getSnapshot()` remains available as a fallback for `replay-failed` snapshot recovery. + ### Incremental domain event application (`__root.tsx`, `store.ts`) High-frequency events (`thread.message-sent`, `thread.activity-appended`, `thread.session-set`, `thread.turn-diff-completed`, `thread.proposed-plan-upserted`) are applied **incrementally** to the Zustand store from event payloads — no full snapshot fetch. This avoids blocking the main thread with JSON parsing and object reconstruction during active agent work. Full snapshot sync (`getSnapshot()`) only runs for: -- Non-incremental events (thread created/deleted/archived, etc.) via `nonIncrementalThrottler` (500ms) - Sequence gaps (missed events) - Deferred reconciliation safety net (every 10 seconds after last incremental event) - Welcome/reconnect diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index a556f3bc8dba..bb5bf7e9e4b5 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -82,6 +82,9 @@ describe("CheckpointDiffQueryLive", () => { Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getListingSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the listing snapshot"), + getThread: () => Effect.die("CheckpointDiffQuery should not request a single thread"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), @@ -136,6 +139,9 @@ describe("CheckpointDiffQueryLive", () => { Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getListingSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the listing snapshot"), + getThread: () => Effect.die("CheckpointDiffQuery should not request a single thread"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index f1cf6e668d1d..aa43e90f9bc3 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -144,6 +144,10 @@ describe("OrchestrationEngine", () => { Layer.provide( Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => Effect.succeed(projectionSnapshot), + getListingSnapshot: () => + Effect.die("OrchestrationEngine test should not request the listing snapshot"), + getThread: () => + Effect.die("OrchestrationEngine test should not request a single thread"), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e81f47d327b4..888b37bf4d37 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -5,6 +5,7 @@ import { MessageId, NonNegativeInt, OrchestrationCheckpointFile, + OrchestrationListingSnapshot, OrchestrationProposedPlanId, OrchestrationReadModel, ProjectScript, @@ -17,6 +18,7 @@ import { type OrchestrationSession, type OrchestrationThread, type OrchestrationThreadActivity, + type OrchestrationThreadSummary, ModelSelection, ProjectId, ThreadId, @@ -48,6 +50,7 @@ import { } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); +const decodeListingSnapshot = Schema.decodeUnknownEffect(OrchestrationListingSnapshot); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -441,6 +444,161 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const LatestUserMessageAtRowSchema = Schema.Struct({ + threadId: ProjectionThread.fields.threadId, + latestUserMessageAt: IsoDateTime, + }); + + const listLatestUserMessageAtRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: LatestUserMessageAtRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + MAX(created_at) AS "latestUserMessageAt" + FROM projection_thread_messages + WHERE role = 'user' + GROUP BY thread_id + `, + }); + + const getThreadRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + additional_directories_json AS "additionalDirectories", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + + const listThreadMessageRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, message_id ASC + `, + }); + + const listThreadProposedPlanRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadProposedPlanDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const listThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + + const getThreadSessionByThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadSessionDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + status, + provider_name AS "providerName", + provider_session_id AS "providerSessionId", + provider_thread_id AS "providerThreadId", + runtime_mode AS "runtimeMode", + active_turn_id AS "activeTurnId", + last_error AS "lastError", + updated_at AS "updatedAt" + FROM projection_thread_sessions + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + + const listLatestTurnRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionLatestTurnDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + state, + requested_at AS "requestedAt", + started_at AS "startedAt", + completed_at AS "completedAt", + assistant_message_id AS "assistantMessageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + ORDER BY requested_at DESC, turn_id DESC + LIMIT 1 + `, + }); + const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => sql .withTransaction( @@ -815,8 +973,391 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); }); + const getListingSnapshot: ProjectionSnapshotQueryShape["getListingSnapshot"] = () => + sql + .withTransaction( + Effect.gen(function* () { + const [ + projectRows, + threadRows, + sessionRows, + latestTurnRows, + stateRows, + userMessageAtRows, + ] = yield* Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getListingSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getListingSnapshot:listProjects:decodeRows", + ), + ), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getListingSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getListingSnapshot:listThreads:decodeRows", + ), + ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getListingSnapshot:listSessions:query", + "ProjectionSnapshotQuery.getListingSnapshot:listSessions:decodeRows", + ), + ), + ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getListingSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getListingSnapshot:listLatestTurns:decodeRows", + ), + ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getListingSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getListingSnapshot:listProjectionState:decodeRows", + ), + ), + ), + listLatestUserMessageAtRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getListingSnapshot:listUserMessageAt:query", + "ProjectionSnapshotQuery.getListingSnapshot:listUserMessageAt:decodeRows", + ), + ), + ), + ]); + + const sessionsByThread = new Map(); + const latestTurnByThread = new Map(); + const userMessageAtByThread = new Map(); + + let updatedAt: string | null = null; + + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + if (latestTurnByThread.has(row.threadId)) { + continue; + } + latestTurnByThread.set(row.threadId, { + turnId: row.turnId, + state: + row.state === "error" + ? "error" + : row.state === "interrupted" + ? "interrupted" + : row.state === "completed" + ? "completed" + : "running", + requestedAt: row.requestedAt, + startedAt: row.startedAt, + completedAt: row.completedAt, + assistantMessageId: row.assistantMessageId, + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: row.sourceProposedPlanThreadId, + planId: row.sourceProposedPlanId, + }, + } + : {}), + }); + } + + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + sessionsByThread.set(row.threadId, { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + runtimeMode: row.runtimeMode, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }); + } + + for (const row of userMessageAtRows) { + userMessageAtByThread.set(row.threadId, row.latestUserMessageAt); + } + + const projects: ReadonlyArray = projectRows.map((row) => ({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + jiraBoard: row.jiraBoard, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })); + + const threads: ReadonlyArray = threadRows.map((row) => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + additionalDirectories: row.additionalDirectories, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + session: sessionsByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + deletedAt: row.deletedAt, + latestUserMessageAt: userMessageAtByThread.get(row.threadId) ?? null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + })); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects, + threads, + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; + + return yield* decodeListingSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getListingSnapshot:decodeListingSnapshot", + ), + ), + ); + }), + ) + .pipe( + Effect.mapError((error) => { + if (isPersistenceError(error)) { + return error; + } + return toPersistenceSqlError("ProjectionSnapshotQuery.getListingSnapshot:query")(error); + }), + ); + + const getThread: ProjectionSnapshotQueryShape["getThread"] = (threadId) => + sql + .withTransaction( + Effect.gen(function* () { + const threadRowOpt = yield* getThreadRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThread:getThread:query", + "ProjectionSnapshotQuery.getThread:getThread:decodeRow", + ), + ), + ); + if (Option.isNone(threadRowOpt)) { + return Option.none(); + } + const row = threadRowOpt.value; + + const [ + messageRows, + proposedPlanRows, + activityRows, + sessionRowOpt, + checkpointRows, + latestTurnRows, + ] = yield* Effect.all([ + listThreadMessageRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThread:listMessages:query", + "ProjectionSnapshotQuery.getThread:listMessages:decodeRows", + ), + ), + ), + listThreadProposedPlanRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThread:listProposedPlans:query", + "ProjectionSnapshotQuery.getThread:listProposedPlans:decodeRows", + ), + ), + ), + listThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThread:listActivities:query", + "ProjectionSnapshotQuery.getThread:listActivities:decodeRows", + ), + ), + ), + getThreadSessionByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThread:getSession:query", + "ProjectionSnapshotQuery.getThread:getSession:decodeRow", + ), + ), + ), + listCheckpointRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThread:listCheckpoints:query", + "ProjectionSnapshotQuery.getThread:listCheckpoints:decodeRows", + ), + ), + ), + listLatestTurnRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThread:listLatestTurns:query", + "ProjectionSnapshotQuery.getThread:listLatestTurns:decodeRows", + ), + ), + ), + ]); + + const messages: OrchestrationMessage[] = messageRows.map((msgRow) => ({ + id: msgRow.messageId, + role: msgRow.role, + text: msgRow.text, + ...(msgRow.attachments !== null ? { attachments: msgRow.attachments } : {}), + turnId: msgRow.turnId, + streaming: msgRow.isStreaming === 1, + createdAt: msgRow.createdAt, + updatedAt: msgRow.updatedAt, + })); + + const proposedPlans: OrchestrationProposedPlan[] = proposedPlanRows.map((ppRow) => ({ + id: ppRow.planId, + turnId: ppRow.turnId, + planMarkdown: ppRow.planMarkdown, + implementedAt: ppRow.implementedAt, + implementationThreadId: ppRow.implementationThreadId, + createdAt: ppRow.createdAt, + updatedAt: ppRow.updatedAt, + })); + + const activities: OrchestrationThreadActivity[] = activityRows.map((actRow) => ({ + id: actRow.activityId, + tone: actRow.tone, + kind: actRow.kind, + summary: actRow.summary, + payload: actRow.payload, + turnId: actRow.turnId, + ...(actRow.sequence !== null ? { sequence: actRow.sequence } : {}), + createdAt: actRow.createdAt, + })); + + const checkpoints: OrchestrationCheckpointSummary[] = checkpointRows.map((cpRow) => ({ + turnId: cpRow.turnId, + checkpointTurnCount: cpRow.checkpointTurnCount, + checkpointRef: cpRow.checkpointRef, + status: cpRow.status, + files: cpRow.files, + assistantMessageId: cpRow.assistantMessageId, + completedAt: cpRow.completedAt, + })); + + const session: OrchestrationSession | null = Option.isSome(sessionRowOpt) + ? { + threadId: sessionRowOpt.value.threadId, + status: sessionRowOpt.value.status, + providerName: sessionRowOpt.value.providerName, + runtimeMode: sessionRowOpt.value.runtimeMode, + activeTurnId: sessionRowOpt.value.activeTurnId, + lastError: sessionRowOpt.value.lastError, + updatedAt: sessionRowOpt.value.updatedAt, + } + : null; + + const latestTurnRow = latestTurnRows[0] ?? null; + const latestTurn: OrchestrationLatestTurn | null = latestTurnRow + ? { + turnId: latestTurnRow.turnId, + state: + latestTurnRow.state === "error" + ? "error" + : latestTurnRow.state === "interrupted" + ? "interrupted" + : latestTurnRow.state === "completed" + ? "completed" + : "running", + requestedAt: latestTurnRow.requestedAt, + startedAt: latestTurnRow.startedAt, + completedAt: latestTurnRow.completedAt, + assistantMessageId: latestTurnRow.assistantMessageId, + ...(latestTurnRow.sourceProposedPlanThreadId !== null && + latestTurnRow.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: latestTurnRow.sourceProposedPlanThreadId, + planId: latestTurnRow.sourceProposedPlanId, + }, + } + : {}), + } + : null; + + const thread: OrchestrationThread = { + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + additionalDirectories: row.additionalDirectories, + latestTurn, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + deletedAt: row.deletedAt, + messages, + proposedPlans, + activities, + checkpoints, + session, + }; + + return Option.some(thread); + }), + ) + .pipe( + Effect.mapError((error) => { + if (isPersistenceError(error)) { + return error; + } + return toPersistenceSqlError("ProjectionSnapshotQuery.getThread:query")(error); + }), + ); + return { getSnapshot, + getListingSnapshot, + getThread, getCounts, getActiveProjectByWorkspaceRoot, getFirstActiveThreadIdByProjectId, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index cfb6965c4fee..088806067d36 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -8,8 +8,10 @@ */ import type { OrchestrationCheckpointSummary, + OrchestrationListingSnapshot, OrchestrationProject, OrchestrationReadModel, + OrchestrationThread, ProjectId, ThreadId, } from "@marcode/contracts"; @@ -44,6 +46,23 @@ export interface ProjectionSnapshotQueryShape { */ readonly getSnapshot: () => Effect.Effect; + /** + * Read a lightweight listing snapshot with thread summaries (no messages, + * activities, checkpoints, or proposed plans). Pre-computes sidebar-specific + * fields server-side for fast initial bootstrap. + */ + readonly getListingSnapshot: () => Effect.Effect< + OrchestrationListingSnapshot, + ProjectionRepositoryError + >; + + /** + * Read full data for a single thread by ID. + */ + readonly getThread: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read aggregate projection counts without hydrating the full read model. */ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4067e79db9dc..a6eae2574193 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -9,7 +9,9 @@ import { OrchestrationDispatchCommandError, type OrchestrationEvent, OrchestrationGetFullThreadDiffError, + OrchestrationGetListingSnapshotError, OrchestrationGetSnapshotError, + OrchestrationGetThreadError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, ProjectBrowseDirectoriesError, @@ -383,6 +385,35 @@ const WsRpcLayer = WsRpcGroup.toLayer( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getListingSnapshot]: (_input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getListingSnapshot, + projectionSnapshotQuery.getListingSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetListingSnapshotError({ + message: "Failed to load listing snapshot", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), + [ORCHESTRATION_WS_METHODS.getThread]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getThread, + projectionSnapshotQuery.getThread(input.threadId).pipe( + Effect.map((opt) => (Option.isNone(opt) ? null : opt.value)), + Effect.mapError( + (cause) => + new OrchestrationGetThreadError({ + message: "Failed to load thread", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b5bb1888b907..6cb8e22c9270 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -78,7 +78,7 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { useStore } from "../store"; +import { isThreadHydrated, useStore } from "../store"; import { useProjectById, useThreadById } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { @@ -842,6 +842,7 @@ export default function ChatView({ threadId }: ChatViewProps) { top: number; } | null>(null); const pendingInteractionAnchorFrameRef = useRef(null); + const lastContentHeightRef = useRef(0); const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); @@ -1312,6 +1313,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadError: activeThread?.error, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const isThreadHydrating = activeThread !== undefined && !isThreadHydrated(activeThread); const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -2366,6 +2368,33 @@ export default function ChatView({ threadId }: ChatViewProps) { scrollMessagesToBottom(); scheduleStickToBottom(); }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); + const REVEAL_SCROLL_VIEWPORT_FRACTION = 0.4; + const onRevealStart = useCallback( + (messageId: string) => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer || !shouldAutoScrollRef.current) return; + if (!isScrollContainerNearBottom(scrollContainer)) return; + + const rowElement = scrollContainer.querySelector( + `[data-row-message-id="${CSS.escape(messageId)}"]`, + ); + if (!rowElement) return; + + const rowHeight = rowElement.getBoundingClientRect().height; + const viewportHeight = scrollContainer.clientHeight; + if (rowHeight < viewportHeight * REVEAL_SCROLL_VIEWPORT_FRACTION) return; + + cancelPendingStickToBottom(); + pendingAutoScrollFrameRef.current = null; + + const rowOffsetTop = rowElement.offsetTop; + scrollContainer.scrollTo({ top: rowOffsetTop, behavior: "smooth" }); + lastKnownScrollTopRef.current = scrollContainer.scrollTop; + shouldAutoScrollRef.current = false; + setShowScrollToBottom(true); + }, + [cancelPendingStickToBottom], + ); const onMessagesScroll = useCallback(() => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; @@ -2531,6 +2560,35 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); }, [phase, scheduleStickToBottom, timelineEntries]); + useLayoutEffect(() => { + if (!messagesScrollElement || typeof ResizeObserver === "undefined") return; + const contentElement = messagesScrollElement.firstElementChild as HTMLElement | null; + if (!contentElement) return; + + lastContentHeightRef.current = contentElement.getBoundingClientRect().height; + + const observer = new ResizeObserver((entries) => { + const [entry] = entries; + if (!entry) return; + const nextHeight = entry.contentRect.height; + const previousHeight = lastContentHeightRef.current; + lastContentHeightRef.current = nextHeight; + + if (nextHeight <= previousHeight) return; + if (!shouldAutoScrollRef.current) return; + if (pendingInteractionAnchorRef.current) return; + if (!isScrollContainerNearBottom(messagesScrollElement)) return; + cancelPendingStickToBottom(); + pendingAutoScrollFrameRef.current = null; + const heightDelta = nextHeight - previousHeight; + scrollMessagesToBottom(heightDelta > 100 ? "smooth" : "auto"); + }); + + observer.observe(contentElement); + return () => { + observer.disconnect(); + }; + }, [messagesScrollElement, activeThread?.id, cancelPendingStickToBottom, scrollMessagesToBottom]); useEffect(() => { setExpandedWorkGroups({}); @@ -4632,6 +4690,7 @@ export default function ChatView({ threadId }: ChatViewProps) { key={activeThread.id} threadId={activeThread.id} hasMessages={timelineEntries.length > 0} + isHydrating={isThreadHydrating} isWorking={isWorking} activeTurnInProgress={isWorking || !latestTurnSettled} activeTurnStartedAt={activeWorkStartedAt} @@ -4665,6 +4724,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onCancelEditUserMessage={discardUserMessageEditSession} onSubmitEditUserMessage={onSubmitEditUserMessage} onReplyToSelection={onReplyToSelection} + onRevealStart={onRevealStart} /> diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d8c85e636042..85ec4e30cc4d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -59,6 +59,7 @@ import { APP_BASE_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; +import { Skeleton } from "./ui/skeleton"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -717,8 +718,30 @@ function SortableProjectItem({ ); } +function SidebarProjectsSkeleton() { + return ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + + +
+
+ + + {i === 0 && } +
+
+ ))} +
+ ); +} + export default function Sidebar() { const projects = useStore((store) => store.projects); + const bootstrapComplete = useStore((store) => store.bootstrapComplete); const sidebarThreadsById = useStore((store) => store.sidebarThreadsById); const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( @@ -2293,7 +2316,8 @@ export default function Sidebar() { )} - {projects.length === 0 && !shouldShowProjectPathEntry && ( + {!bootstrapComplete && projects.length === 0 && } + {bootstrapComplete && projects.length === 0 && !shouldShowProjectPathEntry && (
No projects yet
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0d09851e9d0e..24b009d1b811 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -53,6 +53,7 @@ describe("MessagesTimeline", () => { { +
+
+ +
+ + +
+
+ +
+
+
+ + + + + +
+
+ +
+ +
+ +
+
+ +
+
+
+ + + +
+
+
+
+ ); +} + interface MessagesTimelineProps { threadId: string; hasMessages: boolean; + isHydrating: boolean; isWorking: boolean; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; @@ -115,6 +159,7 @@ interface MessagesTimelineProps { onCancelEditUserMessage: () => void; onSubmitEditUserMessage: () => void | Promise; onReplyToSelection: (context: QuotedContext) => void; + onRevealStart?: (messageId: string) => void; onVirtualizerSnapshot?: (snapshot: { totalSize: number; measurements: ReadonlyArray<{ @@ -131,6 +176,7 @@ interface MessagesTimelineProps { export const MessagesTimeline = memo(function MessagesTimeline({ threadId, hasMessages, + isHydrating, isWorking, activeTurnInProgress: _activeTurnInProgress, activeTurnStartedAt, @@ -164,6 +210,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onCancelEditUserMessage, onSubmitEditUserMessage, onReplyToSelection, + onRevealStart, onVirtualizerSnapshot: _onVirtualizerSnapshot, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); @@ -206,11 +253,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const knownMessageIdsRef = useRef>(new Set()); + const pendingRevealRef = useRef>(new Set()); const prevThreadIdRef = useRef(null); + const wasHydratingRef = useRef(isHydrating); + const pendingHydrationSeedRef = useRef(isHydrating); const newAssistantMessageIds = useMemo(() => { + if (isHydrating && !pendingHydrationSeedRef.current) { + pendingHydrationSeedRef.current = true; + } + wasHydratingRef.current = isHydrating; + if (threadId !== prevThreadIdRef.current) { knownMessageIdsRef.current = new Set(); + pendingRevealRef.current = new Set(); + pendingHydrationSeedRef.current = isHydrating; for (const row of rows) { if (row.kind === "message" && row.message.role === "assistant") { knownMessageIdsRef.current.add(row.message.id); @@ -220,19 +277,50 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return new Set(); } + if (pendingHydrationSeedRef.current) { + for (const row of rows) { + if (row.kind === "message" && row.message.role === "assistant") { + knownMessageIdsRef.current.add(row.message.id); + } + } + const hasMessageRows = rows.some((row) => row.kind === "message"); + if (hasMessageRows && !isHydrating) { + pendingHydrationSeedRef.current = false; + } + return new Set(); + } + const fresh = new Set(); for (const row of rows) { - if ( - row.kind === "message" && - row.message.role === "assistant" && - !knownMessageIdsRef.current.has(row.message.id) - ) { - fresh.add(row.message.id); - knownMessageIdsRef.current.add(row.message.id); + if (row.kind !== "message" || row.message.role !== "assistant") continue; + const id = row.message.id; + + if (pendingRevealRef.current.has(id)) { + if (!row.message.streaming) { + pendingRevealRef.current.delete(id); + fresh.add(id); + } + continue; + } + + if (!knownMessageIdsRef.current.has(id)) { + knownMessageIdsRef.current.add(id); + if (row.message.streaming) { + pendingRevealRef.current.add(id); + } else { + fresh.add(id); + } } } return fresh; - }, [rows, threadId]); + }, [rows, threadId, isHydrating]); + + useLayoutEffect(() => { + if (!onRevealStart || newAssistantMessageIds.size === 0) return; + for (const messageId of newAssistantMessageIds) { + onRevealStart(messageId); + } + }, [newAssistantMessageIds, onRevealStart]); const showInlineDiffs = expandedWorkGroups; const onTimelineImageLoad = useCallback(() => {}, []); @@ -365,22 +453,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({
)}
- - - - - + + {(() => { const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); if (!turnSummary) return null; @@ -488,6 +572,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({
); + const showSkeleton = + isHydrating || (!hasMessages && !isWorking && pendingHydrationSeedRef.current); + + if (showSkeleton) { + return ; + } + if (!hasMessages && !isWorking) { return (
@@ -502,11 +593,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{rows.map((row) => (
(null); - - useEffect(() => { - if (!animating) return; - const el = containerRef.current; - if (!el) return; - - const handleEnd = () => setAnimating(false); - el.addEventListener("animationend", handleEnd, { once: true }); - return () => el.removeEventListener("animationend", handleEnd); - }, [animating]); +function AnimatedChatMarkdown({ text, cwd, isStreaming, animate }: AnimatedChatMarkdownProps) { + const { containerRef, isRevealing, finish } = useSmoothReveal(animate, text.length); - const durationMs = animating ? computeRevealDuration(textLength) : undefined; + const handlePointerDown = useCallback(() => { + if (isRevealing) finish(); + }, [isRevealing, finish]); return ( -
- {children} +
+
); } -export default memo(TextRevealContainer); +export default memo(AnimatedChatMarkdown); diff --git a/apps/web/src/hooks/useSmoothReveal.ts b/apps/web/src/hooks/useSmoothReveal.ts new file mode 100644 index 000000000000..04a1553549a2 --- /dev/null +++ b/apps/web/src/hooks/useSmoothReveal.ts @@ -0,0 +1,239 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; + +const MS_PER_WORD = 30; +const MIN_DURATION_MS = 600; +const MAX_DURATION_MS = 5000; +const MIN_TEXT_LENGTH = 10; +const MAX_WORD_COUNT = 2000; +const WORD_FADE_MS = 140; +const BURST_FRACTION = 0.05; + +const SENTENCE_ENDERS = new Set([".", "!", "?"]); +const CLAUSE_BREAKS = new Set([",", ";", ":", "—", "–"]); + +function prefersReducedMotion(): boolean { + if (typeof window === "undefined") return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function wrapTextNodes(root: HTMLElement): HTMLSpanElement[] { + const spans: HTMLSpanElement[] = []; + const textNodes: Text[] = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + + let cursor: Node | null = walker.nextNode(); + while (cursor) { + if ((cursor as Text).textContent?.length) textNodes.push(cursor as Text); + cursor = walker.nextNode(); + } + + for (const tn of textNodes) { + const raw = tn.textContent ?? ""; + const parts = raw.match(/\S+|\s+/g); + if (!parts || parts.length === 0) continue; + if (parts.length === 1 && /^\s+$/.test(parts[0]!)) continue; + + const frag = document.createDocumentFragment(); + for (const part of parts) { + if (/^\s+$/.test(part)) { + frag.appendChild(document.createTextNode(part)); + } else { + const s = document.createElement("span"); + s.textContent = part; + s.className = "tr-word"; + spans.push(s); + frag.appendChild(s); + } + } + tn.parentNode?.replaceChild(frag, tn); + } + + return spans; +} + +function hideDecoratedElements(root: HTMLElement): void { + for (const li of root.querySelectorAll("li")) { + li.classList.add("tr-li-hidden"); + } + for (const el of root.querySelectorAll( + ".chat-markdown-codeblock, :not(pre) > code, blockquote, table", + )) { + el.classList.add("tr-block-hidden"); + } +} + +function revealDecorationForSpan(span: HTMLSpanElement): void { + const li = span.closest("li.tr-li-hidden"); + if (li) li.classList.remove("tr-li-hidden"); + const block = span.closest(".tr-block-hidden"); + if (block) block.classList.remove("tr-block-hidden"); +} + +function unwrapSpans(root: HTMLElement): void { + for (const el of root.querySelectorAll(".tr-li-hidden")) { + el.classList.remove("tr-li-hidden"); + } + for (const el of root.querySelectorAll(".tr-block-hidden")) { + el.classList.remove("tr-block-hidden"); + } + const nodes = Array.from(root.querySelectorAll(".tr-word")); + for (const span of nodes) { + span.parentNode?.replaceChild(document.createTextNode(span.textContent ?? ""), span); + } + root.normalize(); +} + +function buildTimeline(spans: ReadonlyArray): Float64Array { + const n = spans.length; + const tl = new Float64Array(n); + if (n === 0) return tl; + + const total = Math.min(Math.max(n * MS_PER_WORD, MIN_DURATION_MS), MAX_DURATION_MS); + const base = total / n; + const burstEnd = Math.floor(n * BURST_FRACTION); + let cum = 0; + + for (let i = 0; i < n; i++) { + let dt = base; + if (i < burstEnd) dt *= 0.25; + + const txt = spans[i]!.textContent ?? ""; + const last = txt[txt.length - 1]; + if (last) { + if (SENTENCE_ENDERS.has(last)) dt += 100 + Math.random() * 80; + else if (CLAUSE_BREAKS.has(last)) dt += 35 + Math.random() * 30; + } + + dt *= 0.7 + Math.random() * 0.6; + dt = Math.max(dt, 4); + cum += dt; + tl[i] = cum; + } + + const scale = total / cum; + for (let i = 0; i < n; i++) tl[i]! *= scale; + return tl; +} + +function searchTimeline(tl: Float64Array, t: number): number { + let lo = 0; + let hi = tl.length - 1; + if (hi < 0 || t < tl[0]!) return 0; + if (t >= tl[hi]!) return tl.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (tl[mid]! <= t) lo = mid + 1; + else hi = mid; + } + return lo; +} + +export function useSmoothReveal( + enabled: boolean, + textLength: number, +): { + containerRef: React.RefObject; + isRevealing: boolean; + finish: () => void; +} { + const [active] = useState( + () => enabled && textLength >= MIN_TEXT_LENGTH && !prefersReducedMotion(), + ); + const [isRevealing, setIsRevealing] = useState(active); + const containerRef = useRef(null); + const rafRef = useRef(0); + const spansRef = useRef([]); + + const finish = useCallback(() => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = 0; + } + for (const s of spansRef.current) s.classList.add("tr-visible"); + const el = containerRef.current; + if (el) { + setTimeout(() => { + if (el.isConnected) unwrapSpans(el); + spansRef.current = []; + }, WORD_FADE_MS + 30); + } + setIsRevealing(false); + }, []); + + useLayoutEffect(() => { + if (!active) return; + const el = containerRef.current; + if (!el) { + setIsRevealing(false); + return; + } + + const spans = wrapTextNodes(el); + spansRef.current = spans; + + if (spans.length === 0 || spans.length > MAX_WORD_COUNT) { + unwrapSpans(el); + spansRef.current = []; + setIsRevealing(false); + return; + } + + hideDecoratedElements(el); + + const burstCount = Math.max(1, Math.floor(spans.length * BURST_FRACTION)); + for (let i = 0; i < burstCount && i < spans.length; i++) { + spans[i]!.classList.add("tr-visible"); + revealDecorationForSpan(spans[i]!); + } + + const tl = buildTimeline(spans); + const start = performance.now(); + let last = burstCount - 1; + + el.scrollIntoView({ block: "start", behavior: "smooth" }); + + const tick = (now: number) => { + const idx = searchTimeline(tl, now - start); + + if (!spans[last + 1]?.isConnected) { + rafRef.current = 0; + spansRef.current = []; + setIsRevealing(false); + return; + } + + while (last < idx - 1 && last < spans.length - 1) { + last++; + spans[last]!.classList.add("tr-visible"); + revealDecorationForSpan(spans[last]!); + } + + if (idx < spans.length) { + rafRef.current = requestAnimationFrame(tick); + } else { + for (let i = last + 1; i < spans.length; i++) spans[i]!.classList.add("tr-visible"); + setTimeout(() => { + if (el.isConnected) { + unwrapSpans(el); + spansRef.current = []; + } + }, WORD_FADE_MS + 30); + rafRef.current = 0; + setIsRevealing(false); + } + }; + + rafRef.current = requestAnimationFrame(tick); + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = 0; + } + if (el.isConnected) unwrapSpans(el); + spansRef.current = []; + }; + }, [active]); + + return { containerRef, isRevealing, finish }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8de694dbfad1..d87798a0afc2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -515,36 +515,50 @@ label:has(> select#reasoning-effort) select { animation: ultrathink-rainbow 10s linear infinite; } -/* Text reveal animation for new assistant messages */ -@property --text-reveal-progress { - syntax: ""; - initial-value: 0%; - inherits: false; -} - -@keyframes text-reveal-sweep { +/* Fade-in for hydrated timeline content */ +@keyframes timeline-fade-in { from { - --text-reveal-progress: 0%; + opacity: 0; } to { - --text-reveal-progress: 120%; + opacity: 1; } } -.text-reveal-animating { - --text-reveal-progress: 0%; - -webkit-mask-image: linear-gradient( - to bottom, - black 0%, - black calc(var(--text-reveal-progress) - 18%), - transparent var(--text-reveal-progress) - ); - mask-image: linear-gradient( - to bottom, - black 0%, - black calc(var(--text-reveal-progress) - 18%), - transparent var(--text-reveal-progress) - ); - animation: text-reveal-sweep var(--text-reveal-duration, 1000ms) cubic-bezier(0.16, 1, 0.3, 1) - forwards; +.timeline-fade-in { + animation: timeline-fade-in 300ms ease-out both; +} + +/* Word-by-word fade-in for new assistant messages */ +.tr-word { + opacity: 0; + transition: opacity 140ms ease-out; +} + +.tr-word.tr-visible { + opacity: 1; +} + +.tr-li-hidden::marker { + color: transparent; + transition: color 140ms ease-out; +} + +.tr-block-hidden { + background: transparent !important; + border-color: transparent !important; + box-shadow: none !important; + transition: + background 180ms ease-out, + border-color 180ms ease-out, + box-shadow 180ms ease-out; +} + +.tr-block-hidden .shiki { + background: transparent !important; +} + +.tr-block-hidden > .chat-markdown-copy-button { + opacity: 0 !important; + pointer-events: none; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 081901c1ca9a..4bece0afef98 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -213,6 +213,7 @@ function ServerStateBootstrap() { function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); const syncServerReadModel = useStore((store) => store.syncServerReadModel); + const syncListingSnapshot = useStore((store) => store.syncListingSnapshot); const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); const syncProjects = useUiStateStore((store) => store.syncProjects); const syncThreads = useUiStateStore((store) => store.syncThreads); @@ -558,13 +559,27 @@ function EventRouter() { } } } catch { - // Keep prior state and wait for welcome or a later replay attempt. recovery.failSnapshotRecovery(); } }; const bootstrapFromSnapshot = async (): Promise => { - await runSnapshotRecovery("bootstrap"); + const started = recovery.beginSnapshotRecovery("bootstrap"); + if (!started) { + return; + } + try { + const listing = await api.orchestration.getListingSnapshot(); + if (!disposed) { + syncListingSnapshot(listing); + reconcileSnapshotDerivedState(); + if (recovery.completeSnapshotRecovery(listing.snapshotSequence)) { + void runReplayRecovery("sequence-gap"); + } + } + } catch { + recovery.failSnapshotRecovery(); + } }; bootstrapFromSnapshotRef.current = bootstrapFromSnapshot; diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 38fd5fb3b99c..2b81f8a70004 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -1,6 +1,6 @@ import { ThreadId } from "@marcode/contracts"; import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react"; +import { Suspense, lazy, type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import ChatView from "../components/ChatView"; import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; @@ -17,7 +17,8 @@ import { stripDiffSearchParams, } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { useStore } from "../store"; +import { isThreadHydrated, useStore } from "../store"; +import { readNativeApi } from "../nativeApi"; import { Sheet, SheetPopup } from "../components/ui/sheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; @@ -172,6 +173,36 @@ function ChatThreadRouteView() { Object.hasOwn(store.draftThreadsByThreadId, threadId), ); const routeThreadExists = threadExists || draftThreadExists; + const needsHydration = useStore((store) => { + const thread = store.threads.find((t) => t.id === threadId); + return thread !== undefined && !isThreadHydrated(thread); + }); + const hydrateThread = useStore((store) => store.hydrateThread); + const hydrationInFlightRef = useRef(null); + + useEffect(() => { + if (!needsHydration || hydrationInFlightRef.current === threadId) return; + hydrationInFlightRef.current = threadId; + const api = readNativeApi(); + if (!api) return; + let cancelled = false; + api.orchestration + .getThread({ threadId }) + .then((fullThread) => { + if (!cancelled && fullThread) { + hydrateThread(fullThread); + } + }) + .catch(() => undefined) + .finally(() => { + if (!cancelled) { + hydrationInFlightRef.current = null; + } + }); + return () => { + cancelled = true; + }; + }, [threadId, needsHydration, hydrateThread]); const diffOpen = search.diff === "1"; const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); // TanStack Router keeps active route components mounted across param-only navigations diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index d925efa444f7..8eb6ceeb978f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,5 +1,6 @@ import { type OrchestrationEvent, + type OrchestrationListingSnapshot, type OrchestrationMessage, type OrchestrationProposedPlan, type ProjectId, @@ -593,6 +594,90 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea }; } +export function syncListingSnapshot( + state: AppState, + listing: OrchestrationListingSnapshot, +): AppState { + const projects = listing.projects.filter((project) => project.deletedAt === null).map(mapProject); + + const threads = listing.threads + .filter((t) => t.deletedAt === null) + .map( + (summary): Thread => ({ + id: summary.id, + codexThreadId: null, + projectId: summary.projectId, + title: summary.title, + modelSelection: normalizeModelSelection(summary.modelSelection), + runtimeMode: summary.runtimeMode, + interactionMode: summary.interactionMode, + session: summary.session ? mapSession(summary.session) : null, + messages: [], + proposedPlans: [], + error: sanitizeThreadErrorMessage(summary.session?.lastError), + createdAt: summary.createdAt, + archivedAt: summary.archivedAt, + updatedAt: summary.updatedAt, + latestTurn: summary.latestTurn, + pendingSourceProposedPlan: summary.latestTurn?.sourceProposedPlan, + branch: summary.branch, + worktreePath: summary.worktreePath, + additionalDirectories: [...(summary.additionalDirectories ?? [])], + turnDiffSummaries: [], + activities: [], + }), + ); + + const sidebarThreadsById: Record = {}; + for (const summary of listing.threads) { + if (summary.deletedAt !== null) continue; + const thread = threads.find((t) => t.id === summary.id); + if (!thread) continue; + sidebarThreadsById[summary.id] = { + id: summary.id, + projectId: summary.projectId, + title: summary.title, + interactionMode: summary.interactionMode, + session: thread.session, + createdAt: summary.createdAt, + archivedAt: summary.archivedAt, + updatedAt: summary.updatedAt, + latestTurn: summary.latestTurn, + branch: summary.branch, + worktreePath: summary.worktreePath, + latestUserMessageAt: summary.latestUserMessageAt, + hasPendingApprovals: summary.hasPendingApprovals, + hasPendingUserInput: summary.hasPendingUserInput, + hasActionableProposedPlan: summary.hasActionableProposedPlan, + }; + } + + const threadIdsByProjectId = buildThreadIdsByProjectId(threads); + return { + ...state, + projects, + threads, + sidebarThreadsById, + threadIdsByProjectId, + bootstrapComplete: true, + }; +} + +export function hydrateThread(state: AppState, fullThread: OrchestrationThread): AppState { + const mapped = mapThread(fullThread); + const threads = state.threads.map((t) => (t.id === mapped.id ? mapped : t)); + const summary = buildSidebarThreadSummary(mapped); + const previousSummary = state.sidebarThreadsById[mapped.id]; + const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, summary) + ? state.sidebarThreadsById + : { ...state.sidebarThreadsById, [mapped.id]: summary }; + return { ...state, threads, sidebarThreadsById }; +} + +export function isThreadHydrated(thread: Thread): boolean { + return thread.messages.length > 0 || thread.latestTurn === null; +} + export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { switch (event.type) { case "project.created": { @@ -1147,6 +1232,8 @@ export function setThreadBranch( interface AppStore extends AppState { syncServerReadModel: (readModel: OrchestrationReadModel) => void; + syncListingSnapshot: (listing: OrchestrationListingSnapshot) => void; + hydrateThread: (fullThread: OrchestrationThread) => void; applyOrchestrationEvent: (event: OrchestrationEvent) => void; applyOrchestrationEvents: (events: ReadonlyArray) => void; setError: (threadId: ThreadId, error: string | null) => void; @@ -1156,6 +1243,8 @@ interface AppStore extends AppState { export const useStore = create((set) => ({ ...initialState, syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), + syncListingSnapshot: (listing) => set((state) => syncListingSnapshot(state, listing)), + hydrateThread: (fullThread) => set((state) => hydrateThread(state, fullThread)), applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 19a3f9410f42..9ad29bf26188 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -102,6 +102,8 @@ export function createWsNativeApi(): NativeApi { }, orchestration: { getSnapshot: rpcClient.orchestration.getSnapshot, + getListingSnapshot: rpcClient.orchestration.getListingSnapshot, + getThread: rpcClient.orchestration.getThread, dispatchCommand: rpcClient.orchestration.dispatchCommand, getTurnDiff: rpcClient.orchestration.getTurnDiff, getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 8907380e1867..686c207bbaa5 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -104,6 +104,10 @@ export interface WsRpcClient { }; readonly orchestration: { readonly getSnapshot: RpcUnaryNoArgMethod; + readonly getListingSnapshot: RpcUnaryNoArgMethod< + typeof ORCHESTRATION_WS_METHODS.getListingSnapshot + >; + readonly getThread: RpcUnaryMethod; readonly dispatchCommand: RpcUnaryMethod; readonly getTurnDiff: RpcUnaryMethod; readonly getFullThreadDiff: RpcUnaryMethod; @@ -253,6 +257,10 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { orchestration: { getSnapshot: () => transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), + getListingSnapshot: () => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.getListingSnapshot]({})), + getThread: (input) => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.getThread](input)), dispatchCommand: (input) => transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), getTurnDiff: (input) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3bd6b4d72a0b..24441e1e4e51 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -48,10 +48,13 @@ import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, OrchestrationGetFullThreadDiffResult, + OrchestrationGetThreadInput, OrchestrationGetTurnDiffInput, OrchestrationGetTurnDiffResult, OrchestrationEvent, + OrchestrationListingSnapshot, OrchestrationReadModel, + OrchestrationThread, } from "./orchestration"; import type { JiraConnectionStatus, @@ -206,6 +209,8 @@ export interface NativeApi { }; orchestration: { getSnapshot: () => Promise; + getListingSnapshot: () => Promise; + getThread: (input: OrchestrationGetThreadInput) => Promise; dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise; getFullThreadDiff: ( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 665743e1a11f..2709c7b6aeef 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -18,6 +18,8 @@ import { export const ORCHESTRATION_WS_METHODS = { getSnapshot: "orchestration.getSnapshot", + getListingSnapshot: "orchestration.getListingSnapshot", + getThread: "orchestration.getThread", dispatchCommand: "orchestration.dispatchCommand", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", @@ -306,6 +308,41 @@ export const OrchestrationReadModel = Schema.Struct({ }); export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; +export const OrchestrationThreadSummary = Schema.Struct({ + id: ThreadId, + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + additionalDirectories: Schema.Array(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(() => []), + ), + latestTurn: Schema.NullOr(OrchestrationLatestTurn), + session: Schema.NullOr(OrchestrationSession), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), + deletedAt: Schema.NullOr(IsoDateTime), + latestUserMessageAt: Schema.NullOr(IsoDateTime), + hasPendingApprovals: Schema.Boolean, + hasPendingUserInput: Schema.Boolean, + hasActionableProposedPlan: Schema.Boolean, +}); +export type OrchestrationThreadSummary = typeof OrchestrationThreadSummary.Type; + +export const OrchestrationListingSnapshot = Schema.Struct({ + snapshotSequence: NonNegativeInt, + projects: Schema.Array(OrchestrationProject), + threads: Schema.Array(OrchestrationThreadSummary), + updatedAt: IsoDateTime, +}); +export type OrchestrationListingSnapshot = typeof OrchestrationListingSnapshot.Type; + export const ProjectCreateCommand = Schema.Struct({ type: Schema.Literal("project.create"), commandId: CommandId, @@ -1026,6 +1063,16 @@ export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput const OrchestrationGetSnapshotResult = OrchestrationReadModel; export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type; +export const OrchestrationGetListingSnapshotInput = Schema.Struct({}); +export type OrchestrationGetListingSnapshotInput = typeof OrchestrationGetListingSnapshotInput.Type; + +export const OrchestrationGetThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type OrchestrationGetThreadInput = typeof OrchestrationGetThreadInput.Type; +const OrchestrationGetThreadResult = Schema.NullOr(OrchestrationThread); +export type OrchestrationGetThreadResult = typeof OrchestrationGetThreadResult.Type; + export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields( Struct.assign({ threadId: ThreadId }), { unsafePreserveChecks: true }, @@ -1057,6 +1104,14 @@ export const OrchestrationRpcSchemas = { input: OrchestrationGetSnapshotInput, output: OrchestrationGetSnapshotResult, }, + getListingSnapshot: { + input: OrchestrationGetListingSnapshotInput, + output: OrchestrationListingSnapshot, + }, + getThread: { + input: OrchestrationGetThreadInput, + output: OrchestrationGetThreadResult, + }, dispatchCommand: { input: ClientOrchestrationCommand, output: DispatchResult, @@ -1083,6 +1138,22 @@ export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass()( + "OrchestrationGetListingSnapshotError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class OrchestrationGetThreadError extends Schema.TaggedErrorClass()( + "OrchestrationGetThreadError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass()( "OrchestrationDispatchCommandError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5b682681b7c6..b65ddad64b13 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -47,6 +47,10 @@ import { OrchestrationReplayEventsError, OrchestrationReplayEventsInput, OrchestrationRpcSchemas, + OrchestrationGetListingSnapshotError, + OrchestrationGetListingSnapshotInput, + OrchestrationGetThreadError, + OrchestrationGetThreadInput, } from "./orchestration"; import { ProjectBrowseDirectoriesError, @@ -326,6 +330,21 @@ export const WsOrchestrationGetSnapshotRpc = Rpc.make(ORCHESTRATION_WS_METHODS.g error: OrchestrationGetSnapshotError, }); +export const WsOrchestrationGetListingSnapshotRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.getListingSnapshot, + { + payload: OrchestrationGetListingSnapshotInput, + success: OrchestrationRpcSchemas.getListingSnapshot.output, + error: OrchestrationGetListingSnapshotError, + }, +); + +export const WsOrchestrationGetThreadRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getThread, { + payload: OrchestrationGetThreadInput, + success: OrchestrationRpcSchemas.getThread.output, + error: OrchestrationGetThreadError, +}); + export const WsOrchestrationDispatchCommandRpc = Rpc.make( ORCHESTRATION_WS_METHODS.dispatchCommand, { @@ -488,6 +507,8 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsOrchestrationGetSnapshotRpc, + WsOrchestrationGetListingSnapshotRpc, + WsOrchestrationGetThreadRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, From 63fd111fdf2dbecec8648d54b637a69f30321269 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 10 Apr 2026 17:12:25 +0300 Subject: [PATCH 2/2] perf: suppress completion notifications for user-initiated stops - Suppress notifications for 5s after user stops/interrupts to prevent spurious completion notifications - Improve scroll behavior during agent execution: respect user scroll intent, use multiple timeout attempts - Optimize paint performance by disabling content-visibility auto for messages near bottom - Enhance text reveal animation to support checkboxes and horizontal rules --- apps/web/src/components/BranchToolbar.tsx | 2 + apps/web/src/components/ChatView.tsx | 41 +++++++++++++------ .../src/components/chat/MessagesTimeline.tsx | 31 ++++++++------ apps/web/src/hooks/useSmoothReveal.ts | 14 ++++++- apps/web/src/hooks/useThreadActions.ts | 2 + apps/web/src/index.css | 15 ++++--- apps/web/src/turnNotification.ts | 36 ++++++++++++++++ 7 files changed, 110 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 56e81f7cf841..e252619b2b82 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -4,6 +4,7 @@ import { useCallback } from "react"; import { newCommandId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; +import { markThreadUserStopped } from "../turnNotification"; import { useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; import { @@ -61,6 +62,7 @@ export default function BranchToolbar({ // If the effective cwd is about to change, stop the running session so the // next message creates a new one with the correct cwd. if (serverThread?.session && worktreePath !== activeWorktreePath && api) { + markThreadUserStopped(activeThreadId); void api.orchestration .dispatchCommand({ type: "thread.session.stop", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6cb8e22c9270..43bb4e34dea1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -78,6 +78,7 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; +import { markThreadUserStopped } from "../turnNotification"; import { isThreadHydrated, useStore } from "../store"; import { useProjectById, useThreadById } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; @@ -843,6 +844,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } | null>(null); const pendingInteractionAnchorFrameRef = useRef(null); const lastContentHeightRef = useRef(0); + const lastSmoothScrollTimestampRef = useRef(0); const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); @@ -2321,6 +2323,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (pendingAutoScrollFrameRef.current !== null) return; pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { pendingAutoScrollFrameRef.current = null; + if (pendingUserScrollUpIntentRef.current || isPointerScrollActiveRef.current) return; scrollMessagesToBottom(); }); }, [scrollMessagesToBottom]); @@ -2408,8 +2411,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp && !isNearBottom) { shouldAutoScrollRef.current = false; + pendingUserScrollUpIntentRef.current = false; + } else if (!scrolledUp) { + pendingUserScrollUpIntentRef.current = false; } - pendingUserScrollUpIntentRef.current = false; } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp && !isNearBottom) { @@ -2466,17 +2471,18 @@ export default function ChatView({ threadId }: ChatViewProps) { useLayoutEffect(() => { if (!activeThread?.id) return; shouldAutoScrollRef.current = true; - scheduleStickToBottom(); - const timeout = window.setTimeout(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - if (isScrollContainerNearBottom(scrollContainer)) return; - scheduleStickToBottom(); - }, 96); + scrollMessagesToBottom(); + const delays = [50, 150, 300]; + const timeouts = delays.map((delay) => + window.setTimeout(() => { + if (!shouldAutoScrollRef.current) return; + scrollMessagesToBottom(); + }, delay), + ); return () => { - window.clearTimeout(timeout); + for (const timeout of timeouts) window.clearTimeout(timeout); }; - }, [activeThread?.id, scheduleStickToBottom]); + }, [activeThread?.id, scrollMessagesToBottom]); useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; @@ -2577,11 +2583,17 @@ export default function ChatView({ threadId }: ChatViewProps) { if (nextHeight <= previousHeight) return; if (!shouldAutoScrollRef.current) return; if (pendingInteractionAnchorRef.current) return; - if (!isScrollContainerNearBottom(messagesScrollElement)) return; + if (pendingUserScrollUpIntentRef.current || isPointerScrollActiveRef.current) return; cancelPendingStickToBottom(); pendingAutoScrollFrameRef.current = null; const heightDelta = nextHeight - previousHeight; - scrollMessagesToBottom(heightDelta > 100 ? "smooth" : "auto"); + const now = performance.now(); + const recentlySmoothed = now - lastSmoothScrollTimestampRef.current < 400; + const useSmoothScroll = heightDelta > 100 && !recentlySmoothed; + if (useSmoothScroll) { + lastSmoothScrollTimestampRef.current = now; + } + scrollMessagesToBottom(useSmoothScroll ? "smooth" : "auto"); }); observer.observe(contentElement); @@ -3206,6 +3218,10 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + if (activeThread.session?.orchestrationStatus === "running") { + markThreadUserStopped(threadIdForSend); + } + sendInFlightRef.current = true; try { beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); @@ -3604,6 +3620,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const onInterrupt = async () => { const api = readNativeApi(); if (!api || !activeThread) return; + markThreadUserStopped(activeThread.id); await api.orchestration.dispatchCommand({ type: "thread.turn.interrupt", commandId: newCommandId(), diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 37aabe96807b..f8acb0f056d1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -595,18 +595,25 @@ export const MessagesTimeline = memo(function MessagesTimeline({ data-timeline-root="true" className="timeline-fade-in mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden" > - {rows.map((row) => ( -
- {renderRowContent(row)} -
- ))} + {rows.map((row, index) => { + const nearBottom = index >= rows.length - 3; + return ( +
+ {renderRowContent(row)} +
+ ); + })}
); }); diff --git a/apps/web/src/hooks/useSmoothReveal.ts b/apps/web/src/hooks/useSmoothReveal.ts index 04a1553549a2..4cfeb042959d 100644 --- a/apps/web/src/hooks/useSmoothReveal.ts +++ b/apps/web/src/hooks/useSmoothReveal.ts @@ -56,15 +56,22 @@ function hideDecoratedElements(root: HTMLElement): void { li.classList.add("tr-li-hidden"); } for (const el of root.querySelectorAll( - ".chat-markdown-codeblock, :not(pre) > code, blockquote, table", + ".chat-markdown-codeblock, :not(pre) > code, blockquote, table, hr", )) { el.classList.add("tr-block-hidden"); } + for (const input of root.querySelectorAll('input[type="checkbox"]')) { + (input as HTMLElement).classList.add("tr-input-hidden"); + } } function revealDecorationForSpan(span: HTMLSpanElement): void { const li = span.closest("li.tr-li-hidden"); - if (li) li.classList.remove("tr-li-hidden"); + if (li) { + li.classList.remove("tr-li-hidden"); + const checkbox = li.querySelector(".tr-input-hidden"); + if (checkbox) checkbox.classList.remove("tr-input-hidden"); + } const block = span.closest(".tr-block-hidden"); if (block) block.classList.remove("tr-block-hidden"); } @@ -76,6 +83,9 @@ function unwrapSpans(root: HTMLElement): void { for (const el of root.querySelectorAll(".tr-block-hidden")) { el.classList.remove("tr-block-hidden"); } + for (const el of root.querySelectorAll(".tr-input-hidden")) { + el.classList.remove("tr-input-hidden"); + } const nodes = Array.from(root.querySelectorAll(".tr-word")); for (const span of nodes) { span.parentNode?.replaceChild(document.createTextNode(span.textContent ?? ""), span); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index ca29e8055c08..cee341d0ac90 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -9,6 +9,7 @@ import { useHandleNewThread } from "./useHandleNewThread"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { newCommandId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; +import { markThreadUserStopped } from "../turnNotification"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -94,6 +95,7 @@ export function useThreadActions() { )); if (thread.session && thread.session.status !== "closed") { + markThreadUserStopped(threadId); await api.orchestration .dispatchCommand({ type: "thread.session.stop", diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d87798a0afc2..01171cf7026d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -544,21 +544,26 @@ label:has(> select#reasoning-effort) select { transition: color 140ms ease-out; } -.tr-block-hidden { +.chat-markdown .tr-block-hidden, +.chat-markdown .tr-block-hidden *:not(.tr-word) { background: transparent !important; border-color: transparent !important; box-shadow: none !important; +} + +.tr-block-hidden { transition: background 180ms ease-out, border-color 180ms ease-out, box-shadow 180ms ease-out; } -.tr-block-hidden .shiki { - background: transparent !important; -} - .tr-block-hidden > .chat-markdown-copy-button { opacity: 0 !important; pointer-events: none; } + +.tr-input-hidden { + opacity: 0; + transition: opacity 140ms ease-out; +} diff --git a/apps/web/src/turnNotification.ts b/apps/web/src/turnNotification.ts index b760d1fd4b73..d53edd6332fb 100644 --- a/apps/web/src/turnNotification.ts +++ b/apps/web/src/turnNotification.ts @@ -45,6 +45,28 @@ const COMPLETION_STATUS_TO_REASON: Partial< error: "turn-errored", }; +const USER_INITIATED_STATUSES: ReadonlySet = new Set([ + "stopped", + "interrupted", +]); + +const SUPPRESSION_WINDOW_MS = 5_000; +const suppressedThreads = new Map(); + +export function markThreadUserStopped(threadId: ThreadId): void { + suppressedThreads.set(threadId, Date.now()); +} + +function isThreadSuppressed(threadId: ThreadId): boolean { + const suppressedAt = suppressedThreads.get(threadId); + if (suppressedAt === undefined) return false; + if (Date.now() - suppressedAt > SUPPRESSION_WINDOW_MS) { + suppressedThreads.delete(threadId); + return false; + } + return true; +} + export function deriveTurnNotificationTriggers( events: readonly OrchestrationEvent[], getThread: (threadId: ThreadId) => Thread | undefined, @@ -52,6 +74,16 @@ export function deriveTurnNotificationTriggers( ): TurnNotificationTrigger[] { const triggers: TurnNotificationTrigger[] = []; + const userInitiatedThreadIds = new Set(); + for (const event of events) { + if ( + event.type === "thread.session-set" && + USER_INITIATED_STATUSES.has(event.payload.session.status) + ) { + userInitiatedThreadIds.add(event.payload.threadId); + } + } + for (const event of events) { if (event.type === "thread.session-set") { const { threadId, session } = event.payload; @@ -60,6 +92,10 @@ export function deriveTurnNotificationTriggers( const reason = COMPLETION_STATUS_TO_REASON[newStatus]; if (!reason) continue; + if (userInitiatedThreadIds.has(threadId) && !USER_INITIATED_STATUSES.has(newStatus)) continue; + + if (isThreadSuppressed(threadId)) continue; + const thread = getThread(threadId); if (!thread) continue; if (thread.session?.orchestrationStatus !== "running") continue;