Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
19c0127
load thread messages progressively
tarik02 Jul 28, 2026
ac58678
load thread history in bounded segments
tarik02 Jul 28, 2026
552c519
stabilize progressive thread history
tarik02 Jul 28, 2026
e73ce8f
stabilize progressive history with viewport anchors
tarik02 Jul 28, 2026
079e49e
Merge remote-tracking branch 'origin/main' into feat/progressive-thre…
tarik02 Jul 28, 2026
4f08341
stabilize progressive history scrolling
tarik02 Jul 28, 2026
84aab49
simplify progressive history scroll ownership
tarik02 Jul 28, 2026
a9abd9c
Merge remote-tracking branch 'origin/main' into feat/progressive-thre…
tarik02 Jul 28, 2026
8207ed9
fill visible progressive history boundaries
tarik02 Jul 29, 2026
797aac2
fix scroll to end for progressive history
tarik02 Jul 29, 2026
f6f614b
fix delayed progressive history loading
tarik02 Jul 29, 2026
b9d3d6b
make progressive history scrolling deterministic
tarik02 Jul 29, 2026
9bf8235
Merge remote-tracking branch 'origin/main' into feat/progressive-thre…
tarik02 Jul 29, 2026
47e28f5
fix(web): remove history boundary gaps
tarik02 Jul 29, 2026
fa46a3c
fix(web): stabilize progressive history navigation
tarik02 Jul 29, 2026
013ebd3
fix(web): keep progressive history paging at scroll edges
tarik02 Jul 29, 2026
d273562
fix(web): stabilize progressive history scrolling
tarik02 Jul 29, 2026
fe72bd5
fix(web): release stale history page anchors
tarik02 Jul 29, 2026
72e1d5b
refactor(web): use legend list for progressive history
tarik02 Jul 29, 2026
2a402c5
fix: stabilize progressive thread history
tarik02 Jul 29, 2026
c211086
fix(web): stabilize timeline history anchors
tarik02 Jul 29, 2026
aa388e1
feat: stabilize progressive thread history across clients
tarik02 Jul 29, 2026
213bd07
fix(web): clear stale minimap highlights
tarik02 Jul 29, 2026
57f2392
Merge remote-tracking branch 'origin/main' into feat/progressive-thre…
tarik02 Jul 30, 2026
5612f24
refactor: stabilize progressive thread history
tarik02 Jul 30, 2026
74268f2
Merge remote-tracking branch 'origin/main' into feat/progressive-thre…
tarik02 Jul 30, 2026
2f15f1e
feat: stabilize progressive thread history
tarik02 Jul 30, 2026
c79b646
Merge remote-tracking branch 'origin/main' into feat/progressive-thre…
tarik02 Jul 30, 2026
9b58754
fix(web): stabilize progressive history loading
tarik02 Jul 30, 2026
1533c42
fix: stabilize progressive thread history
tarik02 Jul 30, 2026
fedee4a
feat(web): add thread history cache reset
tarik02 Jul 30, 2026
e0c01a0
Merge remote-tracking branch 'origin/main' into feat/progressive-thre…
tarik02 Jul 30, 2026
4cc81fe
fix(web): stabilize progressive thread history
tarik02 Jul 30, 2026
791b997
fix(web): stabilize progressive history scrolling
tarik02 Jul 31, 2026
9ed8422
fix: harden progressive history lifecycle
tarik02 Jul 31, 2026
6797004
fix: preserve thread history state across pagination
tarik02 Jul 31, 2026
db034dd
fix(mobile): skip unused thread history outline
tarik02 Jul 31, 2026
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
32 changes: 32 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork
- Exception: upstream actualization PRs may preserve upstream commit structure when that makes future syncs easier to audit.
- Staged formatting tolerates chunks containing only ignored files so large upstream actualization commits can pass the pre-commit hook.

### Compatibility

- Fork backend changes must remain compatible with non-fork clients. Upstream clients must be able to use a fork server without fork-specific assumptions or protocol failures.
- Fork database changes and migrations must leave the database usable by regular upstream builds. The upstream backend must still open and use the database, and the upstream frontend must still work through that backend. Prefer sidecar databases for fork-only data.

### Protocol Compatibility

- Fork protocol extensions use separate extension RPC methods and additive optional capabilities.
Expand Down Expand Up @@ -63,6 +68,16 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork
### Fork Persistence

- Fork-only goal persistence is stored in a sidecar database named `state-tarik02.sqlite`.
- Bounded live-thread snapshots use fork-namespaced cache keys, so the fork never decodes legacy
unbounded snapshot JSON during startup and upstream clients ignore the fork cache entries.
- The web client keeps fetched thread-history rows in an unbounded, normalized sidecar IndexedDB
cache. Messages, activities, and plans are stored once and reused for covered history ranges.
Cached records are schema-decoded independently so large activity batches cannot stall on a
cooperative runtime yield. An authoritative bounded snapshot clears cached segments because it
may replace an event replay that contained a thread revert.
- The mobile client keeps recently fetched thread-history pages in a bounded, session-only
in-memory LRU. It avoids repeat requests while browsing nearby segments without adding mobile
database state or migrations, and it does not request the web-only minimap outline.

### Goals UI

Expand All @@ -88,6 +103,23 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork

### UX Changes

- Progressive thread history is a client-local beta setting and defaults to off. Disabled clients
use the full-history subscription and do not request bounded message pages.
- Thread detail snapshots keep a bounded live tail. Historical browsing uses bounded,
bidirectional keyset windows cut only at user-turn boundaries, while the web minimap indexes every
user message across the full thread and loads the selected segment on demand. One LegendList owns
both live and historical scrolling, and its visible-content anchoring stabilizes page changes and
late row measurements. The local scrollbar only represents the current bounded segment; the
minimap is the global conversation-indexed scrubber. Historical windows retain every raw message
and work-telemetry row belonging to their turns, and only display activity for turns represented
by the active message window. Reaching a segment edge loads the adjacent window
without moving visible rows. Scroll-to-end returns directly to the bounded live tail.
- Paginated history responses use the same client-facing activity payload projection as full thread
snapshots, and command output omitted by that projection is removed in SQLite before schema decode.
The persisted activity remains unchanged.
- Failed segment requests release their pending navigation target. Keyboard, pointer, touch, or wheel
input immediately cancels target alignment. Minimap dragging follows the pointer immediately while
throttling segment requests, and only the latest explicit jump may replace the active segment.
- Desktop context-menu style is configurable from Appearance settings.
- The sidebar follows the active thread when it appears or when navigation originates elsewhere.
- Sidebar environments can be hidden or shown dynamically from the project toolbar.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const clientSettings: ClientSettings = {
environmentIdentificationMode: "artwork",
favorites: [],
glassOpacity: 80,
progressiveThreadHistoryEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarProjectGroupingMode: "repository_path",
Expand Down
17 changes: 14 additions & 3 deletions apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2;
const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
const VCS_REFS_CACHE_SCHEMA_VERSION = 1;
const THREAD_CACHE_KEY_NAMESPACE = "tarik02:bounded-v1";

const StoredShellSnapshot = Schema.Struct({
schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
Expand Down Expand Up @@ -61,6 +62,10 @@ const encodeStoredVcsRefs = Schema.encodeEffect(Schema.fromJsonString(StoredVcsR

type CacheOperation = ConnectionPersistenceError["operation"];

function threadCacheKey(threadId: string) {
return `${THREAD_CACHE_KEY_NAMESPACE}:${threadId}`;
}

function persistenceError(operation: CacheOperation, cause: unknown) {
return new ConnectionPersistenceError({
operation,
Expand Down Expand Up @@ -140,7 +145,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
database,
environmentId,
kind: "thread",
cacheKey: threadId,
cacheKey: threadCacheKey(threadId),
operation: "load-thread",
decode: decodeStoredThreadSnapshot,
select: (stored) =>
Expand All @@ -158,12 +163,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
snapshot,
}).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));
yield* database
.saveCache(environmentId, "thread", threadId, THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, payload)
.saveCache(
environmentId,
"thread",
threadCacheKey(threadId),
THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,
payload,
)
.pipe(Effect.mapError(mapDatabaseError("save-thread")));
}),
removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) =>
database
.removeCache(environmentId, "thread", threadId)
.removeCache(environmentId, "thread", threadCacheKey(threadId))
.pipe(Effect.mapError(mapDatabaseError("remove-thread"))),
),
loadServerConfig: Effect.fn("MobileEnvironmentCache.loadServerConfig")((environmentId) =>
Expand Down
44 changes: 26 additions & 18 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PrimaryEnvironmentAuth,
RelayDeviceIdentity,
SshEnvironmentGateway,
ThreadHistoryCacheStore,
} from "@t3tools/client-runtime/platform";
import {
ConnectionBlockedError,
Expand Down Expand Up @@ -208,26 +209,33 @@ const providedCapabilitiesLayer = capabilitiesLayer.pipe(
Layer.provide(Runtime.runtimeContextLayer),
);

const environmentOwnedDataCleanupLayer = Layer.succeed(
const environmentOwnedDataCleanupLayer = Layer.effect(
EnvironmentOwnedDataCleanup,
EnvironmentOwnedDataCleanup.of({
clear: (environmentId) =>
Effect.all(
[
Effect.promise(() => clearThreadOutboxEnvironment(environmentId)),
Effect.promise(() => clearComposerDraftsEnvironment(environmentId)),
],
{ concurrency: "unbounded", discard: true },
).pipe(
Effect.catch((cause) =>
Effect.logWarning("Could not clear mobile environment-owned data.", {
environmentId,
cause,
}),
Effect.gen(function* () {
const historyCache = yield* ThreadHistoryCacheStore;
return EnvironmentOwnedDataCleanup.of({
clear: (environmentId) =>
Effect.all(
[
Effect.promise(() => clearThreadOutboxEnvironment(environmentId)),
Effect.promise(() => clearComposerDraftsEnvironment(environmentId)),
historyCache.clear(environmentId),
],
{ concurrency: "unbounded", discard: true },
).pipe(
Effect.catch((cause) =>
Effect.logWarning("Could not clear mobile environment-owned data.", {
environmentId,
cause,
}),
),
),
),
});
}),
);
const providedEnvironmentOwnedDataCleanupLayer = environmentOwnedDataCleanupLayer.pipe(
Layer.provide(Runtime.runtimeContextLayer),
);

type ConnectionPlatformLayerSource =
| typeof providedConnectionStorageLayer
Expand All @@ -236,7 +244,7 @@ type ConnectionPlatformLayerSource =
| typeof wakeupsLayer
| typeof providedCapabilitiesLayer
| typeof platformConnectionSourceLayer
| typeof environmentOwnedDataCleanupLayer;
| typeof providedEnvironmentOwnedDataCleanupLayer;

export const connectionPlatformLayer: Layer.Layer<
Layer.Success<ConnectionPlatformLayerSource>,
Expand All @@ -249,5 +257,5 @@ export const connectionPlatformLayer: Layer.Layer<
wakeupsLayer,
providedCapabilitiesLayer,
platformConnectionSourceLayer,
environmentOwnedDataCleanupLayer,
providedEnvironmentOwnedDataCleanupLayer,
);
14 changes: 12 additions & 2 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,11 @@ function GeneralSettingsSection() {
*/
function BetaSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const progressiveThreadHistoryEnabled =
AsyncResult.isSuccess(preferencesResult) &&
preferencesResult.value.progressiveThreadHistoryEnabled === true;

return (
<View className="gap-3">
Expand All @@ -569,10 +573,16 @@ function BetaSettingsSection() {
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
/>
<SettingsSwitchRow
icon="doc.text"
label="Progressive Thread History"
value={progressiveThreadHistoryEnabled}
onValueChange={(value) => savePreferences({ progressiveThreadHistoryEnabled: value })}
/>
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
Thread List v2 simplifies navigation. Progressive Thread History loads large conversations
in pages instead of downloading the full thread.
</Text>
</View>
);
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export interface ThreadDetailScreenProps {
readonly connectionStateLabel: EnvironmentConnectionPhase;
/** Message sync status for the selected thread (drives the composer status pill). */
readonly threadSyncStatus?: EnvironmentThreadStatus;
readonly hasPreviousMessages?: boolean;
readonly hasNextMessages?: boolean;
readonly isLoadingPreviousMessages?: boolean;
readonly isLoadingNextMessages?: boolean;
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
Expand All @@ -78,6 +82,8 @@ export interface ThreadDetailScreenProps {
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onReconnectEnvironment: () => void;
readonly onLoadPreviousMessages?: () => void;
readonly onLoadNextMessages?: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void;
Expand Down Expand Up @@ -370,6 +376,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
layoutVariant={layoutVariant}
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
hasPreviousMessages={props.hasPreviousMessages}
hasNextMessages={props.hasNextMessages}
isLoadingPreviousMessages={props.isLoadingPreviousMessages}
isLoadingNextMessages={props.isLoadingNextMessages}
onLoadPreviousMessages={props.onLoadPreviousMessages}
onLoadNextMessages={props.onLoadNextMessages}
skills={selectedProviderSkills}
/>
</View>
Expand Down
36 changes: 34 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ export interface ThreadFeedProps {
readonly layoutVariant?: LayoutVariant;
readonly usesAutomaticContentInsets?: boolean;
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
readonly hasPreviousMessages?: boolean;
readonly hasNextMessages?: boolean;
readonly isLoadingPreviousMessages?: boolean;
readonly isLoadingNextMessages?: boolean;
readonly onLoadPreviousMessages?: () => void;
readonly onLoadNextMessages?: () => void;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
}

Expand Down Expand Up @@ -1842,7 +1848,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// anchor scrolls also lets it correct a scroll that landed on a
// stale end target once the anchor row finishes measuring.
maintainScrollAtEnd={
disclosureToggleSettling
disclosureToggleSettling || props.hasNextMessages
? false
: {
animated: true,
Expand Down Expand Up @@ -1890,10 +1896,36 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// overflow the viewport (the padding clamps to zero).
alignItemsAtEnd
initialScrollAtEnd
onStartReached={props.hasPreviousMessages ? props.onLoadPreviousMessages : undefined}
onStartReachedThreshold={0.8}
onEndReached={props.hasNextMessages ? props.onLoadNextMessages : undefined}
onEndReachedThreshold={0.8}
onScroll={handleScroll}
scrollEventThrottle={16}
ListHeaderComponent={
usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />
props.isLoadingPreviousMessages ? (
<View
className="items-center justify-center"
style={usesNativeAutomaticInsets ? undefined : { paddingTop: topContentInset }}
>
<View className="my-2 flex-row items-center gap-2">
<ActivityIndicator size="small" />
<Text className="text-muted-foreground text-xs">
Loading earlier messages...
</Text>
</View>
</View>
) : usesNativeAutomaticInsets ? null : (
<View style={{ height: topContentInset }} />
)
}
ListFooterComponent={
props.isLoadingNextMessages ? (
<View className="my-2 flex-row items-center justify-center gap-2">
<ActivityIndicator size="small" />
<Text className="text-muted-foreground text-xs">Loading newer messages...</Text>
</View>
) : null
}
contentContainerStyle={{
paddingTop: 12,
Expand Down
38 changes: 37 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s
import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useThreadComposerState } from "../../state/use-thread-composer-state";
import { threadEnvironment } from "../../state/threads";
import { environmentThreads, threadEnvironment } from "../../state/threads";
import { projectThreadContentPresentation } from "./threadContentPresentation";
import {
useAdaptiveWorkspaceLayout,
Expand Down Expand Up @@ -196,6 +196,12 @@ function ThreadRouteContent(
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const loadPreviousMessages = useAtomCommand(environmentThreads.loadPreviousMessages, {
reportFailure: false,
});
const loadNextMessages = useAtomCommand(environmentThreads.loadNextMessages, {
reportFailure: false,
});
const navigation = useNavigation();
const params = props.route.params;
const environmentIdRaw = firstRouteParam(params.environmentId);
Expand Down Expand Up @@ -314,6 +320,24 @@ function ThreadRouteContent(
}
onReconnectEnvironment(environmentId);
}, [environmentId, onReconnectEnvironment]);
const handleLoadPreviousMessages = useCallback(() => {
if (selectedThread === null) {
return;
}
void loadPreviousMessages({
environmentId: selectedThread.environmentId,
input: { threadId: selectedThread.id },
});
}, [loadPreviousMessages, selectedThread]);
const handleLoadNextMessages = useCallback(() => {
if (selectedThread === null) {
return;
}
void loadNextMessages({
environmentId: selectedThread.environmentId,
input: { threadId: selectedThread.id },
});
}, [loadNextMessages, selectedThread]);

/* ─── Git action progress (for overlay banner) ──────────────────── */
const gitActionProgressTarget = useMemo(
Expand Down Expand Up @@ -761,6 +785,16 @@ function ThreadRouteContent(
draftAttachments={composer.draftAttachments}
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
hasPreviousMessages={selectedThreadDetail?.messageHistory?.hasMoreBefore === true}
hasNextMessages={selectedThreadDetail?.messageHistory?.hasMoreAfter === true}
isLoadingPreviousMessages={
selectedThreadDetailState.history.kind === "ready" &&
selectedThreadDetailState.history.loading === "before"
}
isLoadingNextMessages={
selectedThreadDetailState.history.kind === "ready" &&
selectedThreadDetailState.history.loading === "after"
}
activeThreadBusy={composer.activeThreadBusy}
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
Expand All @@ -777,6 +811,8 @@ function ThreadRouteContent(
onStopThread={handleStopThread}
onSendMessage={composer.onSendMessage}
onReconnectEnvironment={handleReconnectEnvironment}
onLoadPreviousMessages={handleLoadPreviousMessages}
onLoadNextMessages={handleLoadNextMessages}
onUpdateThreadModelSelection={composer.onUpdateModelSelection}
onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode}
onUpdateThreadInteractionMode={composer.onUpdateInteractionMode}
Expand Down
Loading
Loading