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
1 change: 1 addition & 0 deletions FEATURE_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ OMP authority: `packages/coding-agent/src/session/agent-session.ts`, `session-ma
| List/recent/search/filter | session store and metadata | Codex-parity left rail: By project or In one list; Priority, Last updated, or Manual order; real project/session dragging with keyboard fallbacks; title/project/host search; attention/running/unread/error filters; pinned shortcuts; five-row Show more; project aliases; reversible hidden projects; bulk read/archive; local-only Finder reveal; direct pin/archive controls | `Rail.tsx`, `session-tree.ts`, workspace store, management helpers, browser tests | Launch |
| New session | `/new` | Create in selected project/host; model/profile defaults visible before first prompt | draft routes and composer draft store | Launch |
| Fast switch and tabs | session IDs and snapshots | One-click/keyboard switch; preserve draft, scroll anchor, panel widths/tabs, terminal focus; no white flash | T3 routes, `composerDraftStore`, `rightPanelStore`, terminal store | Launch |
| Tail-first transcript history | Bounded `transcript.page` range reads plus the existing live attach cursor | Paint a small newest page on cold open; prepend older pages without moving the reading anchor or live cursor | T4-owned host and web client implemented; Flutter local cache and thin OMP bridge planned | Launch |
| Resume | `/resume` | Open existing session by stable ID/path; recover moved/missing files with explicit error | thread routing and reconnect supervisor | Launch |
| Rename | `/rename` | Inline rename with optimistic state and rollback | sidebar row actions | Launch |
| Move working directory/session | `/move` | Native/remote path picker, validation, explicit impact message | environment picker patterns | Parity |
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/features/session-runtime/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ import {
/** How current this session's connection is; mirrors shell freshness. */
export type SessionLink = "live" | "cached" | "offline";

export interface TranscriptHistoryPageState {
readonly phase: "loading" | "ready" | "error" | "unsupported";
readonly hasMore: boolean;
readonly error: string | null;
}

export interface SessionRuntimeSnapshot {
readonly projection: TranscriptProjection;
readonly link: SessionLink;
Expand Down Expand Up @@ -69,6 +75,8 @@ export interface SessionRuntimeSnapshot {
readonly sessionControl: SessionControlState | null;
/** Redacted provider-owned transport evidence for this session. */
readonly providerTransport: ProviderTransportState | null;
/** Read-only backward history; independent from the live stream cursor. */
readonly transcriptHistory?: TranscriptHistoryPageState;
/**
* Time base for elapsed labels. The fixture runtime reports the fixed
* scripted "now" so renders are reproducible; a real bridge runtime
Expand All @@ -94,6 +102,8 @@ export interface SessionRuntime {
* draft only on "accepted" and keeps it otherwise.
*/
submitPrompt(intent: SessionIntent): Promise<PromptOutcome>;
/** Request the next older bounded page without changing live/reconnect state. */
loadEarlierTranscript?(): Promise<void>;
/** Stop timers; the runtime keeps its state for A→B→A switch-back. */
pause(): void;
/** Resume draining scripted live steps. */
Expand Down
151 changes: 149 additions & 2 deletions apps/web/src/features/session-runtime/live-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import type {
DesktopRuntimeSnapshot,
SessionProjection,
} from "@t4-code/client";
import { readTranscriptPage, TranscriptPageClientError } from "@t4-code/client";
import {
hostId as brandHostId,
PROTOCOL_VERSION,
revision as brandRevision,
sessionId as brandSessionId,
type CatalogItem,
type DurableEntry,
type Revision,
type SessionEvent,
type SessionRef,
Expand All @@ -39,6 +41,7 @@ import type {
SessionLink,
SessionRuntime,
SessionRuntimeSnapshot,
TranscriptHistoryPageState,
} from "./controller.ts";
import { IMAGE_PROMPTS_UNSUPPORTED_REASON, type SessionIntent } from "./intents.ts";
import { runImagePromptUpload } from "./image-upload.ts";
Expand Down Expand Up @@ -87,6 +90,39 @@ const CONTROL_REJECTED: Record<PendingControl, string> = {
const CONTROL_UNKNOWN =
"The connection dropped before the host answered. The control shows the host's last confirmed value.";
const MAX_RETIRED_PENDING_PROMPTS = 128;
const INITIAL_TRANSCRIPT_PAGE_ENTRIES = 64;
const INITIAL_TRANSCRIPT_PAGE_BYTES = 256 * 1024;
const OLDER_TRANSCRIPT_PAGE_ENTRIES = 128;
const OLDER_TRANSCRIPT_PAGE_BYTES = 512 * 1024;
const MAX_PAGED_TRANSCRIPT_ENTRIES = 4_096;

function prependTranscriptPage(
current: readonly DurableEntry[],
older: readonly DurableEntry[],
): readonly DurableEntry[] {
const existing = new Set(current.map((entry) => entry.id));
const added: DurableEntry[] = [];
for (const entry of older) {
if (existing.has(entry.id)) continue;
existing.add(entry.id);
added.push(entry);
}
return [...added, ...current];
}

function presentPagedTranscript(
projection: TranscriptProjection,
pagedEntries: readonly DurableEntry[],
): TranscriptProjection {
if (pagedEntries.length === 0) return projection;
if (projection.entries.length === 0) return { ...projection, entries: pagedEntries };
const liveIds = new Set(projection.entries.map((entry) => entry.id));
const firstOverlap = pagedEntries.findIndex((entry) => liveIds.has(entry.id));
const prefix = firstOverlap < 0 ? pagedEntries : pagedEntries.slice(0, firstOverlap);
return prefix.length === 0
? projection
: { ...projection, entries: [...prefix, ...projection.entries] };
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
Expand Down Expand Up @@ -233,6 +269,11 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
const decidedChallenges = new Set<string>();
const listeners = new Set<() => void>();
let transcriptImagesAttached = false;
let pagedEntries: readonly DurableEntry[] = [];
let transcriptHistory: TranscriptHistoryPageState | undefined;
let transcriptPageGeneration: string | undefined;
let transcriptPageCursor: string | undefined;
let transcriptPageRequest: Promise<void> | null = null;

const transcriptImages = createTranscriptArtifactSource({
hostId: options.hostId,
Expand Down Expand Up @@ -339,6 +380,97 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
for (const listener of listeners) listener();
};

const transcriptPageSupported = (runtime: DesktopRuntimeSnapshot): boolean => {
const host = runtime.hosts.get(options.hostId);
return (
runtime.connections.get(targetId) === "connected" &&
runtime.targetHosts.get(targetId) === options.hostId &&
host?.grantedCapabilities.includes("sessions.read") === true &&
host.grantedFeatures.includes("transcript.page")
);
};

const loadTranscriptPage = (before?: string): Promise<void> => {
if (transcriptPageRequest !== null) return transcriptPageRequest;
const loadingOlder = before !== undefined;
const remainingEntries = MAX_PAGED_TRANSCRIPT_ENTRIES - pagedEntries.length;
if (loadingOlder && remainingEntries <= 0) return Promise.resolve();
transcriptHistory = {
phase: "loading",
hasMore: transcriptPageCursor !== undefined,
error: null,
};
notify();
const request = readTranscriptPage(
controller,
{ targetId, hostId: options.hostId, sessionId: options.sessionId },
{
...(before === undefined ? {} : { before }),
limit: loadingOlder
? Math.min(OLDER_TRANSCRIPT_PAGE_ENTRIES, remainingEntries)
: INITIAL_TRANSCRIPT_PAGE_ENTRIES,
maxBytes: loadingOlder ? OLDER_TRANSCRIPT_PAGE_BYTES : INITIAL_TRANSCRIPT_PAGE_BYTES,
},
)
.then((page) => {
if (disposed) return;
if (
loadingOlder &&
transcriptPageGeneration !== undefined &&
page.generation !== transcriptPageGeneration
) {
throw new TranscriptPageClientError(
"stale",
"The transcript changed while older history was loading.",
"transcript_generation_changed",
);
}
pagedEntries = loadingOlder
? prependTranscriptPage(pagedEntries, page.entries)
: [...page.entries];
transcriptPageGeneration = page.generation;
transcriptPageCursor = page.nextCursor;
transcriptHistory = {
phase: "ready",
hasMore: page.hasMore && pagedEntries.length < MAX_PAGED_TRANSCRIPT_ENTRIES,
error:
page.hasMore && pagedEntries.length >= MAX_PAGED_TRANSCRIPT_ENTRIES
? "This view reached its in-memory history limit."
: null,
};
notify();
})
.catch((error: unknown) => {
if (disposed) return;
const unsupported =
error instanceof TranscriptPageClientError && error.code === "unsupported";
transcriptHistory = {
phase: unsupported ? "unsupported" : "error",
hasMore: transcriptPageCursor !== undefined,
error: unsupported
? null
: error instanceof TranscriptPageClientError
? error.message
: "Older transcript history could not be loaded.",
};
notify();
})
.finally(() => {
if (transcriptPageRequest === request) transcriptPageRequest = null;
});
transcriptPageRequest = request;
return request;
};

const primeTranscriptTail = (): Promise<void> => {
if (transcriptHistory !== undefined) return transcriptPageRequest ?? Promise.resolve();
if (!transcriptPageSupported(controller.getSnapshot())) {
transcriptHistory = { phase: "unsupported", hasMore: false, error: null };
return Promise.resolve();
}
return loadTranscriptPage();
};

// Seed from the controller's warm projection. Durable entries install at the
// authoritative warm cursor; the bounded event suffix is then folded in its
// original order to restore requests and other event-derived state. Event
Expand Down Expand Up @@ -675,8 +807,16 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
retryAfterAttach = false;
const generation = connectionGeneration;
attached = true;
void controller
.attachSession(targetId, options.hostId, options.sessionId, transcript.cursor ?? undefined)
const tailPrime = primeTranscriptTail();
const startAttach = () =>
controller.attachSession(
targetId,
options.hostId,
options.sessionId,
transcript.cursor ?? undefined,
);
const attachRequest = transcript.cursor === null ? tailPrime.then(startAttach) : startAttach();
void attachRequest
.then((result) => {
const current = controller.getSnapshot();
transcriptImagesAttached = result.accepted === true && generation === connectionGeneration;
Expand Down Expand Up @@ -1046,6 +1186,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
pendingControl,
controlError,
});
projection = presentPagedTranscript(projection, pagedEntries);

snapshot = {
projection,
Expand Down Expand Up @@ -1076,6 +1217,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
: gateComposerControls(derivedControls, controlGate.controlReason),
sessionControl,
providerTransport: ref?.liveState?.providerTransport ?? null,
...(transcriptHistory === undefined ? {} : { transcriptHistory }),
nowMs: Date.now(),
};
}
Expand All @@ -1089,6 +1231,11 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
void submitPrompt(intent);
},
submitPrompt,
async loadEarlierTranscript() {
if (transcriptHistory?.phase === "loading") return;
if (transcriptPageCursor === undefined && transcriptHistory?.phase !== "error") return;
await loadTranscriptPage(transcriptPageCursor);
},
pause() {
// Live frames keep applying in the background so switch-back is warm.
// Image bytes do not: every inactive runtime releases its object URLs
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/transcript/SessionMain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -580,9 +580,11 @@ export function SessionMain({ onOpenHostHealth, session, exportRowsRef }: Sessio
) : (
<TranscriptTimeline
bottomInset={archived ? 16 : dockHeight + 16}
history={snapshot.transcriptHistory}
imageSource={runtime.transcriptImages}
key={session.id}
nowMs={snapshot.nowMs}
onLoadEarlier={runtime.loadEarlierTranscript}
rows={rows}
sessionId={session.id}
streaming={snapshot.sessionActive}
Expand Down
43 changes: 37 additions & 6 deletions apps/web/src/features/transcript/TranscriptTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat

import { workspaceStore } from "../../state/store-instance.ts";
import { selectSessionView } from "../../state/workspace-store.ts";
import type { TranscriptHistoryPageState } from "../session-runtime/controller.ts";
import type { TranscriptImageSource } from "../session-runtime/transcript-images.ts";
import type { ToolRenderHost } from "./tool-render/types.ts";
import { createAnchoredToggle, DisclosureAnchorContext } from "./disclosure-anchor.tsx";
Expand Down Expand Up @@ -51,6 +52,8 @@ export interface TranscriptTimelineProps {
readonly nowMs: number;
readonly imageSource: TranscriptImageSource;
readonly toolHost?: ToolRenderHost | undefined;
readonly history?: TranscriptHistoryPageState | undefined;
readonly onLoadEarlier?: (() => Promise<void>) | undefined;
}

export const TranscriptTimeline = memo(function TranscriptTimeline({
Expand All @@ -61,6 +64,8 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({
nowMs,
imageSource,
toolHost,
history,
onLoadEarlier,
}: TranscriptTimelineProps) {
const listRef = useRef<LegendListRef | null>(null);
// null anchor = the user was following the tail when they left. Read the
Expand Down Expand Up @@ -168,13 +173,15 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({
);

// New output while scrolled away raises the pill.
const lastRowCountRef = useRef(rows.length);
const lastTailIdRef = useRef(rows.at(-1)?.id);
useEffect(() => {
if (rows.length !== lastRowCountRef.current) {
lastRowCountRef.current = rows.length;
if (!following) setNewOutputPending(true);
const nextTailId = rows.at(-1)?.id;
if (nextTailId !== lastTailIdRef.current) {
const hadTail = lastTailIdRef.current !== undefined;
lastTailIdRef.current = nextTailId;
if (hadTail && nextTailId !== undefined && !following) setNewOutputPending(true);
}
}, [rows.length, following]);
}, [rows, following]);

// Follow pins at the TRUE max on every painted frame, not eventually:
// 1. React-commit growth (streamed rows, composer/footer resize) pins in
Expand Down Expand Up @@ -231,6 +238,30 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({
void listRef.current?.scrollToEnd({ animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches });
}, []);

const listHeader = useMemo(() => {
if (history === undefined || history.phase === "unsupported") return LIST_HEADER;
if (!history.hasMore && history.phase !== "loading" && history.phase !== "error") return LIST_HEADER;
return (
<div className="flex min-h-12 items-center justify-center px-4 py-2">
<Button
disabled={history.phase === "loading"}
onClick={() => void onLoadEarlier?.()}
size="xs"
variant="outline"
>
{history.phase === "loading"
? "Loading earlier messages…"
: history.phase === "error"
? "Retry earlier messages"
: "Load earlier messages"}
</Button>
{history.error !== null && (
<span className="ml-2 max-w-sm text-muted-foreground text-xs">{history.error}</span>
)}
</div>
);
}, [history, onLoadEarlier]);

// Cold-mount mask: commit the exact warm tail before mounting LegendList.
// Rendering the hidden virtual list and its duplicate warm rows in the
// same first commit made a 10k session click wait on work the user could
Expand Down Expand Up @@ -320,7 +351,7 @@ export const TranscriptTimeline = memo(function TranscriptTimeline({
// max, and the last row rests a full gap above the composer at every
// viewport size.
ListFooterComponent={<div style={{ height: bottomInset }} />}
ListHeaderComponent={LIST_HEADER}
ListHeaderComponent={listHeader}
maintainScrollAtEnd={maintainScrollAtEnd}
maintainVisibleContentPosition={{ data: true, size: false }}
onScroll={handleScroll}
Expand Down
Loading
Loading