Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
} from "@t3tools/client-runtime/state/threads";
// T3-CUSTOM(expbkt3): durable execution presentation drives the recovery banner.
import { deriveThreadExecutionPresentation } from "@t3tools/client-runtime/state/thread-execution-presentation";
// T3-CUSTOM(expbkt3): worktree and setup progress stand in for "Thinking".
import { deriveWorkspacePreparation } from "@t3tools/client-runtime/state/workspace-preparation";
import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
import type { LegendListRef } from "@legendapp/list/react-native";
Expand Down Expand Up @@ -352,6 +354,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
intent: props.selectedThread.execution?.intent ?? null,
providerActivity: props.selectedThread.execution?.activity ?? "idle",
});
// T3-CUSTOM(expbkt3): BEGIN — no "Thinking" until the agent has the prompt.
const workspacePreparation = deriveWorkspacePreparation(
props.selectedThread.execution?.intent ?? null,
);
const feedActiveWorkStartedAt =
workspacePreparation !== null && !workspacePreparation.agentStarted
? null
: props.activeWorkStartedAt;
// T3-CUSTOM(expbkt3): END
// T3-CUSTOM(expbkt3): use the supervisor's terminal turn state, not the
// lagging latest-turn projection, for an explicit stopped/failed outcome.
const executionOutcome = getThreadExecutionOutcome(props.selectedThread.execution);
Expand Down Expand Up @@ -406,6 +417,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
if (props.creationState?.kind === "failed") {
return null;
}
// T3-CUSTOM(expbkt3): name the workspace step instead of timing a turn.
if (workspacePreparation !== null && !workspacePreparation.agentStarted) {
return { kind: "preparing", label: workspacePreparation.label };
}
if (threadSyncLabel !== null) {
return { kind: "syncing", label: threadSyncLabel };
}
Expand Down Expand Up @@ -920,7 +935,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
latestTurn={feedLatestTurn}
execution={props.selectedThread.execution ?? null}
turnSummaries={props.selectedThreadTurnSummaries}
activeWorkStartedAt={props.activeWorkStartedAt}
// T3-CUSTOM(expbkt3): no "Thinking" row while the workspace is prepared.
activeWorkStartedAt={feedActiveWorkStartedAt}
listRef={listRef}
freeze={freeze}
anchorMessageId={anchorMessageId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ import {
DurableExecutionIntentRepository,
DurableExecutionIntentRepositoryLive,
} from "../../execution/DurableExecutionIntentRepository.ts";
// T3-CUSTOM(expbkt3): durable bootstrap setup without a real terminal.
import { ProjectSetupScriptRunner } from "../../project/ProjectSetupScriptRunner.ts";
import { SourceControlProfileService } from "../../sourceControl/SourceControlProfileService.ts";
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
Expand Down Expand Up @@ -275,6 +277,8 @@ describe("ProviderCommandReactor", () => {
readonly durableNativeCommands?: boolean;
readonly drainDeletionThrough?: (sequence: number) => Effect.Effect<void>;
readonly executionSupervisor?: ThreadExecutionSupervisorShape;
// T3-CUSTOM(expbkt3): durable bootstrap setup without a real terminal.
readonly setupScriptRunner?: ProjectSetupScriptRunner["Service"];
readonly activeTurnInput?: "steer" | "queue";
readonly baseDir?: string;
readonly threadModelSelection?: ModelSelection;
Expand Down Expand Up @@ -711,6 +715,12 @@ describe("ProviderCommandReactor", () => {
? Layer.succeed(ThreadExecutionSupervisor, input.executionSupervisor)
: Layer.empty,
),
// T3-CUSTOM(expbkt3): durable bootstrap setup without a real terminal.
Layer.provide(
input?.setupScriptRunner
? Layer.succeed(ProjectSetupScriptRunner, input.setupScriptRunner)
: Layer.empty,
),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
Expand Down Expand Up @@ -841,6 +851,16 @@ describe("ProviderCommandReactor", () => {
return rows[0];
}),
),
// T3-CUSTOM(expbkt3): durable bootstrap setup phase as clients would read it.
readBootstrapSetupPhase: () =>
runtime!.runPromise(
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const rows = yield* sql<{ readonly setupPhase: string }>`
SELECT setup_phase AS "setupPhase" FROM thread_execution_bootstrap_operations`;
return rows[0]?.setupPhase;
}),
),
seedNativeConversation: () =>
runtime!.runPromise(
Effect.gen(function* () {
Expand Down Expand Up @@ -1091,6 +1111,62 @@ describe("ProviderCommandReactor", () => {
}),
);

// T3-CUSTOM(expbkt3): BEGIN — clients learn setup started before the agent runs.
effectIt.effect("publishes the running setup step before the setup script finishes", () =>
Effect.gen(function* () {
const supervisor = yield* ThreadExecutionSupervisor;
// The setup phase each client-visible refresh carried, in order.
const refreshedSetupPhases: Array<string | undefined> = [];
const setupStarted = yield* Deferred.make<ReadonlyArray<string | undefined>>();
let harness: Awaited<ReturnType<typeof createHarness>> | undefined;
const readSetupPhase = () => harness!.readBootstrapSetupPhase();
harness = yield* Effect.promise(() =>
createHarness({
durableNativeCommands: true,
executionSupervisor: {
...supervisor,
refreshIntent: (threadId) =>
Effect.promise(readSetupPhase).pipe(
Effect.tap((phase) => Effect.sync(() => refreshedSetupPhases.push(phase))),
Effect.andThen(supervisor.refreshIntent(threadId)),
),
},
setupScriptRunner: {
runForThread: () =>
Deferred.succeed(setupStarted, [...refreshedSetupPhases]).pipe(
Effect.as({ status: "no-script" } as const),
),
},
tryHandlePromptCommandEffect: () => Effect.succeed(true),
}),
);
yield* harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("setup-progress-worktree"),
threadId: ThreadId.make("thread-1"),
worktreePath: "/tmp/provider-project-worktree",
});
yield* harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("setup-progress"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: MessageId.make("setup-progress-message"),
role: "user",
text: "/logout",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
bootstrap: { runSetupScript: true },
createdAt: "2026-01-01T00:00:01.000Z",
});
expect(yield* Deferred.await(setupStarted)).toContain("running");
expect(yield* Deferred.await(harness.nativeCompletion)).toBe("setup-progress");
}),
);
// T3-CUSTOM(expbkt3): END

effectIt.effect.each(["new", "ready", "stopped"] as const)(
"handles sign-out for a %s thread before worktree repair, text helpers, or startup",
(sessionStatus) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3006,6 +3006,13 @@ const make = Effect.gen(function* () {
false,
);
}
// T3-CUSTOM(expbkt3): publish "worktree done, setup running" to clients; step
// changes inside one preparation pass are not intent transitions of their own.
if (setupStep.value === "pending") {
yield* executionSupervisor
.refreshIntent(input.intent.threadId)
.pipe(Effect.ignoreCause({ log: true }));
}
if (setupStep.value === "acknowledged" || setupStep.value === "not-required") return;
const terminalId = operation.value.setupTerminalId;
if (setupStep.value === "running") {
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ import {
} from "@t3tools/shared/terminalLabels";
// T3-CUSTOM(expbkt3): durable execution drives the working label.
import { describeThreadExecution } from "@t3tools/shared/threadExecution";
// T3-CUSTOM(expbkt3): workspace setup checklist in the working row.
import { deriveWorkspacePreparation } from "@t3tools/client-runtime/state/workspace-preparation";
import { Debouncer } from "@tanstack/react-pacer";
import { useAtomValue } from "@effect/atom-react";
import {
Expand Down Expand Up @@ -3392,6 +3394,13 @@ export default function ChatView(props: ChatViewProps) {
shellExecution: routeServerThreadShell?.execution,
});
// T3-CUSTOM(expbkt3): END
// T3-CUSTOM(expbkt3): worktree and setup progress stand in for "Thinking".
const workspacePreparationIntent =
activeThread?.execution?.intent ?? routeServerThreadShell?.execution?.intent ?? null;
const workspacePreparation = useMemo(
() => deriveWorkspacePreparation(workspacePreparationIntent),
[workspacePreparationIntent],
);
const optimisticCompactionMessage = optimisticUserMessages.at(-1);
const pendingCompactionMessage =
isSendBusy &&
Expand Down Expand Up @@ -9726,7 +9735,10 @@ export default function ChatView(props: ChatViewProps) {
) : null}
{/* T3-CUSTOM(expbkt3): show accepted durable workspace progress when
the resolved-request path never materialized a legacy bootstrap. */}
{!activeThread.bootstrap && activeThread.execution?.intent?.bootstrap ? (
{/* T3-CUSTOM(expbkt3): the working-row checklist replaces this line while it shows. */}
{!activeThread.bootstrap &&
activeThread.execution?.intent?.bootstrap &&
!(isWorking && workspacePreparation !== null) ? (
<DurableBootstrapStatus bootstrap={activeThread.execution.intent.bootstrap} />
) : null}
{/* Messages — LegendList handles virtualization and scrolling internally */}
Expand All @@ -9745,6 +9757,9 @@ export default function ChatView(props: ChatViewProps) {
// T3-CUSTOM(expbkt3): durable execution labels and running clock.
workingStatusLabel={workingStatusLabel}
isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
// T3-CUSTOM(expbkt3): workspace setup checklist in the working row.
workspacePreparation={paintOnlyDisplayedTimeline ? null : workspacePreparation}
onShowWorkspaceOutput={showBootstrapOutput}
isCompacting={!paintOnlyDisplayedTimeline && isCompacting}
activeTurnStartedAt={
paintOnlyDisplayedTimeline || phase !== "running" ? null : activeWorkStartedAt
Expand Down
74 changes: 70 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ import {
} from "lucide-react";
// T3-CUSTOM(expbkt3): agent-rendered UI surfaces in chat.
import { AgentUiSurfaceRow, resolveAgentUiSurface } from "../../fork/agentUiSurface";
// T3-CUSTOM(expbkt3): workspace setup checklist replaces "Thinking" until the agent starts.
import type { WorkspacePreparationView } from "@t3tools/client-runtime/state/workspace-preparation";
import { WorkspacePreparationRow } from "../thread-bootstrap/WorkspacePreparationRow";
import type {
ComposerContextId,
ComposerContextRecord,
Expand Down Expand Up @@ -306,6 +309,9 @@ interface TimelineRowSharedState {
interface TimelineRowActivityState {
isWorking: boolean;
isPreparingWorktree: boolean;
// T3-CUSTOM(expbkt3): durable workspace preparation for the working row.
workspacePreparation: WorkspacePreparationView | null;
onShowWorkspaceOutput: ((terminalId: string) => void) | undefined;
isCompacting: boolean;
isRevertingCheckpoint: boolean;
latestTurnId: TurnId | null;
Expand Down Expand Up @@ -407,6 +413,9 @@ interface MessagesTimelineProps {
onOpenAgents?: () => void;
isWorking: boolean;
isPreparingWorktree?: boolean;
// T3-CUSTOM(expbkt3): durable workspace preparation for the working row.
workspacePreparation?: WorkspacePreparationView | null;
onShowWorkspaceOutput?: (terminalId: string) => void;
isCompacting?: boolean;
activeTurnStartedAt: string | null;
workingStatusLabel?: string;
Expand Down Expand Up @@ -477,6 +486,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onCiteAssistantText,
isWorking,
isPreparingWorktree = false,
// T3-CUSTOM(expbkt3): durable workspace preparation for the working row.
workspacePreparation = null,
onShowWorkspaceOutput,
isCompacting = false,
activeTurnStartedAt,
workingStatusLabel = "Working",
Expand Down Expand Up @@ -1031,11 +1043,22 @@ export const MessagesTimeline = memo(function MessagesTimeline({
() => ({
isWorking,
isPreparingWorktree,
// T3-CUSTOM(expbkt3): durable workspace preparation for the working row.
workspacePreparation,
onShowWorkspaceOutput,
isCompacting,
isRevertingCheckpoint,
latestTurnId: latestTurn?.turnId ?? null,
}),
[isCompacting, isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId],
[
isCompacting,
isRevertingCheckpoint,
isWorking,
isPreparingWorktree,
workspacePreparation,
onShowWorkspaceOutput,
latestTurn?.turnId,
],
);

// Stable renderItem — no closure deps. Row components read shared state
Expand Down Expand Up @@ -2136,7 +2159,31 @@ function ProposedPlanTimelineRow({
}

function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
const { isCompacting, isPreparingWorktree, workspacePreparation, onShowWorkspaceOutput } =
use(TimelineRowActivityCtx);
// T3-CUSTOM(expbkt3): BEGIN — the checklist replaces the timer while the workspace is
// prepared, and stays as a collapsed checkmark above it once the agent takes over.
if (workspacePreparation && !isPreparingWorktree && !isCompacting) {
return (
<div className="border-b border-border/60 pb-2 pt-1">
<WorkspacePreparationRow
view={workspacePreparation}
onShowOutput={onShowWorkspaceOutput}
renderActiveLabel={(label) => (
<span
ref={observeVisibleAnimation}
className="relative shrink-0 overflow-hidden whitespace-nowrap"
>
{label}
<ActivityShimmerOverlay>{label}</ActivityShimmerOverlay>
</span>
)}
/>
{workspacePreparation.state === "ready" ? <WorkingTimerLine row={row} /> : null}
</div>
);
}
// T3-CUSTOM(expbkt3): END
return (
<div className="border-b border-border/60 pb-2 pt-1">
<div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
Expand Down Expand Up @@ -2170,12 +2217,31 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
);
}

// T3-CUSTOM(expbkt3): BEGIN — the plain working line under a finished checklist.
function WorkingTimerLine({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
return (
<div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
{row.createdAt ? (
<>
{row.label} for <WorkingTimer createdAt={row.createdAt} />
</>
) : (
`${row.label}...`
)}
</div>
);
}
// T3-CUSTOM(expbkt3): END

function ThinkingTimelineRow() {
const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
const { isCompacting, isPreparingWorktree, workspacePreparation } = use(TimelineRowActivityCtx);
// Reserve the activity row during setup so the handoff keeps the same height.
return (
<div className="min-h-7">
{isPreparingWorktree || isCompacting ? null : (
{/* T3-CUSTOM(expbkt3): no "Thinking" until the agent has the prompt. */}
{isPreparingWorktree ||
isCompacting ||
(workspacePreparation !== null && !workspacePreparation.agentStarted) ? null : (
<LiveActivityRow label="Thinking" iconName="brain" active shimmer />
)}
</div>
Expand Down
Loading
Loading