Skip to content

Commit a619829

Browse files
committed
Merge main into fix/mobile-stale-working-disconnected
2 parents 8c8ed84 + 50c1c5d commit a619829

109 files changed

Lines changed: 4921 additions & 1125 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/mobile-eas-production.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ jobs:
215215
git config user.name "${APP_SLUG}[bot]"
216216
git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com"
217217
git add apps/mobile/app.config.ts
218-
git commit \
218+
git commit --no-verify \
219219
-m "chore(mobile): bump app version to $RELEASE_VERSION" \
220220
-m "Co-authored-by: codex <[email protected]>"
221221
git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}"

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1388,7 +1388,7 @@ jobs:
13881388
git config user.email "${{ steps.app_bot.outputs.email }}"
13891389
13901390
git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml
1391-
git commit -m "chore(release): prepare $RELEASE_TAG"
1391+
git commit --no-verify -m "chore(release): prepare $RELEASE_TAG"
13921392
git push origin HEAD:main
13931393
13941394
announce_discord:

‎apps/mobile/src/features/keyboard/CommandPalette.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
type EnvironmentThreadSearchMatch,
99
type ShownOrder,
1010
} from "@t3tools/client-runtime/state/thread-search";
11-
import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
11+
import { isListedThreadLineage, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
1212
import {
1313
foldSearchQuery,
1414
PALETTE_MESSAGE_LIMIT,
@@ -422,10 +422,7 @@ export function CommandPalette(props: {
422422
}),
423423
}));
424424
const threadItems: CommandPaletteItem[] = threads
425-
.filter(
426-
(thread) =>
427-
thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent",
428-
)
425+
.filter((thread) => thread.archivedAt === null && isListedThreadLineage(thread.lineage))
429426
.map((thread) => {
430427
const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId));
431428
const environment =

‎apps/mobile/src/features/threads/ThreadDetailScreen.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
195195
import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
196196
import { useThreadQueuedCount } from "./ThreadQueueControl";
197197
import { ThreadGuestShareBar } from "./ThreadGuestShareBar";
198+
import { ThreadSideChatsRow } from "./ThreadSideChatsRow";
198199
import type { ThreadContentPresentation } from "./threadContentPresentation";
199200
import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
200201
import { useOpenLinkedThread } from "./useOpenLinkedThread";
@@ -1531,6 +1532,12 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
15311532
threadRef={props.selectedThreadDetailRef}
15321533
canOperateThread={props.canOperateThread}
15331534
/>
1535+
{props.draftMode ? null : (
1536+
<ThreadSideChatsRow
1537+
environmentId={props.environmentId}
1538+
threadId={props.selectedThread.id}
1539+
/>
1540+
)}
15341541
{props.feedbackSubmissions.map((submission) => (
15351542
<ComposerFeedback
15361543
key={submission.id}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2+
import { Pressable, ScrollView, View } from "react-native";
3+
4+
import { AppText as Text } from "../../components/ui/AppText";
5+
import { SymbolView } from "../../components/ui/AppSymbol";
6+
import { cn } from "../../lib/cn";
7+
import { useSideChats } from "../../state/entities";
8+
import { useOpenLinkedThread } from "./useOpenLinkedThread";
9+
10+
/**
11+
* The thread's side chats, started on web or desktop. Each opens as a normal thread, so its
12+
* approvals and questions can be answered from the phone.
13+
*/
14+
export function ThreadSideChatsRow(props: {
15+
readonly environmentId: EnvironmentId;
16+
readonly threadId: ThreadId;
17+
}) {
18+
const sideChats = useSideChats({ environmentId: props.environmentId, threadId: props.threadId });
19+
const openLinkedThread = useOpenLinkedThread();
20+
if (sideChats.length === 0) return null;
21+
return (
22+
<View className="flex-row items-center gap-2 pb-2 pl-4">
23+
<SymbolView
24+
name="bubble.left.and.bubble.right"
25+
size={12}
26+
tintColorClassName="accent-foreground-muted"
27+
/>
28+
<Text className="text-xs text-foreground-muted">Side chats</Text>
29+
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="flex-1">
30+
<View className="flex-row gap-1 pr-4">
31+
{sideChats.map((sideChat) => {
32+
const needs =
33+
sideChat.hasPendingApprovals || sideChat.pendingSubagentRequest === "approval"
34+
? "approval"
35+
: sideChat.hasPendingUserInput || sideChat.pendingSubagentRequest
36+
? "input"
37+
: null;
38+
return (
39+
<Pressable
40+
key={sideChat.id}
41+
accessibilityRole="button"
42+
accessibilityLabel={`Open side chat ${sideChat.title}${needs === null ? "" : `, needs ${needs}`}`}
43+
className="max-w-48 flex-row items-center gap-1.5 rounded-full bg-subtle px-2.5 py-1"
44+
onPress={() =>
45+
openLinkedThread({ environmentId: props.environmentId, threadId: sideChat.id })
46+
}
47+
>
48+
{needs === null ? null : (
49+
<View
50+
className={cn(
51+
"h-1.5 w-1.5 shrink-0 rounded-full",
52+
needs === "approval" ? "bg-warning-foreground" : "bg-adaptive-indigo-600-300",
53+
)}
54+
/>
55+
)}
56+
<Text className="shrink text-xs text-foreground" numberOfLines={1}>
57+
{sideChat.title}
58+
</Text>
59+
</Pressable>
60+
);
61+
})}
62+
</View>
63+
</ScrollView>
64+
</View>
65+
);
66+
}

‎apps/mobile/src/features/threads/threadListV2.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
sortSettledThreads,
3030
} from "@t3tools/client-runtime/state/thread-sort";
3131
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
32-
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
32+
import { isListedThreadLineage, type EnvironmentId, type ProjectId } from "@t3tools/contracts";
3333
import type { SnoozePresetSetting } from "@t3tools/contracts/settings";
3434

3535
import type { ThreadListProvider } from "../../state/thread-list-environments";
@@ -276,8 +276,7 @@ export function getThreadListV2OrderedSection(input: {
276276
}): EnvironmentThreadShell[] {
277277
const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined;
278278
const threads = input.threads.filter((thread) => {
279-
if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent")
280-
return false;
279+
if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) return false;
281280
if (
282281
(input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
283282
thread.settledOverride === "settled" &&
@@ -661,7 +660,7 @@ export function buildThreadListV2Items(input: {
661660
const listedThreads = input.threads.filter(
662661
(thread) =>
663662
thread.archivedAt === null &&
664-
thread.lineage.relationshipToParent !== "subagent" &&
663+
isListedThreadLineage(thread.lineage) &&
665664
(input.environmentId === null || thread.environmentId === input.environmentId) &&
666665
(projectKeys === null || projectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
667666
);

‎apps/mobile/src/state/entities.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ export function useChildThreadInputs(ref: ScopedThreadRef) {
198198
return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref));
199199
}
200200

201+
export function useSideChats(ref: ScopedThreadRef) {
202+
return useAtomValue(environmentThreadShells.sideChatsAtom(ref));
203+
}
204+
201205
export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
202206
const titles = useAtomValue(
203207
hasThreadLinks(markdown)

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ export const make = Effect.gen(function* () {
224224
fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
225225
pullRequests: true,
226226
pullRequestChecks: true,
227+
sideChats: true,
227228
inlineMessageContext: true,
228229
requiredWorktreeBootstrap: true,
229230
threadSettlement: true,

‎apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts‎

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,4 +719,190 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => {
719719
assert.deepEqual([...projected.readyCheckpointOrdinals].toSorted(), [0, 1]);
720720
}),
721721
);
722+
723+
it.effect("completes a side chat run with a missing checkpoint and never touches git", () =>
724+
Effect.gen(function* () {
725+
const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
726+
const now = yield* DateTime.now;
727+
const sideChatThreadId = ThreadId.make("thread:checkpoint-capture-side-chat");
728+
const sideChatRunId = RunId.make("run:checkpoint-capture-side-chat");
729+
const sideChatScopeId = CheckpointScopeId.make("scope:checkpoint-capture-side-chat");
730+
const sideChatRootNodeId = NodeId.make("node:checkpoint-capture-side-chat-root");
731+
const sideChatProviderThreadId = ProviderThreadId.make(
732+
"provider-thread:checkpoint-capture-side-chat",
733+
);
734+
const event = { threadId: sideChatThreadId, occurredAt: now };
735+
const events: ReadonlyArray<OrchestrationV2DomainEvent> = [
736+
{
737+
...event,
738+
id: EventId.make("event:checkpoint-capture-side-chat:thread"),
739+
type: "thread.created",
740+
payload: {
741+
createdBy: "user",
742+
creationSource: "web",
743+
id: sideChatThreadId,
744+
projectId,
745+
title: "Side chat",
746+
providerInstanceId,
747+
modelSelection,
748+
runtimeMode: "approval-required",
749+
interactionMode: "default",
750+
branch: null,
751+
worktreePath: "/repo",
752+
activeProviderThreadId: null,
753+
lineage: {
754+
parentThreadId: threadId,
755+
relationshipToParent: "fork",
756+
rootThreadId: threadId,
757+
sideChat: true,
758+
},
759+
forkedFrom: null,
760+
createdAt: now,
761+
updatedAt: now,
762+
archivedAt: null,
763+
settledOverride: null,
764+
settledAt: null,
765+
lastVisitedAt: null,
766+
deletedAt: null,
767+
},
768+
},
769+
{
770+
...event,
771+
id: EventId.make("event:checkpoint-capture-side-chat:provider-thread"),
772+
type: "provider-thread.updated",
773+
driver,
774+
providerInstanceId,
775+
payload: {
776+
id: sideChatProviderThreadId,
777+
driver,
778+
providerInstanceId,
779+
providerSessionId: null,
780+
appThreadId: sideChatThreadId,
781+
ownerNodeId: null,
782+
nativeThreadRef: null,
783+
nativeConversationHeadRef: null,
784+
status: "idle",
785+
firstRunOrdinal: 1,
786+
lastRunOrdinal: 1,
787+
handoffIds: [],
788+
forkedFrom: null,
789+
createdAt: now,
790+
updatedAt: now,
791+
},
792+
},
793+
{
794+
...event,
795+
id: EventId.make("event:checkpoint-capture-side-chat:run"),
796+
type: "run.updated",
797+
runId: sideChatRunId,
798+
payload: {
799+
id: sideChatRunId,
800+
threadId: sideChatThreadId,
801+
ordinal: 1,
802+
providerInstanceId,
803+
modelSelection,
804+
providerThreadId: sideChatProviderThreadId,
805+
userMessageId: MessageId.make("message:checkpoint-capture-side-chat"),
806+
rootNodeId: sideChatRootNodeId,
807+
activeAttemptId: null,
808+
status: "waiting",
809+
requestedAt: now,
810+
startedAt: now,
811+
completedAt: null,
812+
checkpointId: null,
813+
contextHandoffId: null,
814+
},
815+
},
816+
{
817+
...event,
818+
id: EventId.make("event:checkpoint-capture-side-chat:node"),
819+
type: "node.updated",
820+
runId: sideChatRunId,
821+
nodeId: sideChatRootNodeId,
822+
payload: {
823+
id: sideChatRootNodeId,
824+
threadId: sideChatThreadId,
825+
runId: sideChatRunId,
826+
parentNodeId: null,
827+
rootNodeId: sideChatRootNodeId,
828+
kind: "root_turn",
829+
status: "waiting",
830+
countsForRun: true,
831+
providerThreadId: sideChatProviderThreadId,
832+
providerTurnId: null,
833+
nativeItemRef: null,
834+
runtimeRequestId: null,
835+
checkpointScopeId: sideChatScopeId,
836+
startedAt: now,
837+
completedAt: null,
838+
},
839+
},
840+
{
841+
...event,
842+
id: EventId.make("event:checkpoint-capture-side-chat:scope"),
843+
type: "checkpoint-scope.created",
844+
runId: sideChatRunId,
845+
nodeId: sideChatRootNodeId,
846+
payload: {
847+
id: sideChatScopeId,
848+
threadId: sideChatThreadId,
849+
runId: sideChatRunId,
850+
nodeId: sideChatRootNodeId,
851+
parentScopeId: null,
852+
providerThreadId: sideChatProviderThreadId,
853+
kind: "root_run",
854+
ordinalWithinParent: 0,
855+
advancesAppRunCount: true,
856+
cwd: "/repo",
857+
createdAt: now,
858+
},
859+
},
860+
];
861+
yield* Effect.forEach(events, (candidate) => projectionStore.apply(candidate));
862+
const gitUnavailable = () => Effect.die("side chats must not snapshot the shared worktree");
863+
const layerCapture = CheckpointCaptureService.layer.pipe(
864+
Layer.provide(
865+
Layer.mergeAll(
866+
IdAllocator.layer,
867+
CheckpointService.layer.pipe(
868+
Layer.provide(
869+
Layer.mergeAll(
870+
IdAllocator.layer,
871+
NodeCrypto.layer,
872+
Layer.mock(CheckpointStore.CheckpointStore)({
873+
isGitRepository: gitUnavailable,
874+
captureCheckpoint: gitUnavailable,
875+
hasCheckpointRef: gitUnavailable,
876+
}),
877+
),
878+
),
879+
),
880+
Layer.mock(EventSink.EventSinkV2)({
881+
commitCommand: (input) =>
882+
Effect.forEach(input.events, (committed) => projectionStore.apply(committed)).pipe(
883+
Effect.as({ committed: true } as never),
884+
Effect.orDie,
885+
),
886+
}),
887+
),
888+
),
889+
);
890+
891+
yield* CheckpointCaptureService.CheckpointCaptureServiceV2.use((service) =>
892+
service.execute({
893+
threadId: sideChatThreadId,
894+
runId: sideChatRunId,
895+
scopeId: sideChatScopeId,
896+
}),
897+
).pipe(Effect.provide(layerCapture));
898+
899+
const { checkpoints, runs } = yield* projectionStore.getThreadRecords(sideChatThreadId, [
900+
"runs",
901+
"checkpoints",
902+
]);
903+
assert.equal(runs[0]?.status, "completed");
904+
const checkpoint = checkpoints.find((candidate) => candidate.id === runs[0]?.checkpointId);
905+
assert.equal(checkpoint?.status, "missing");
906+
}),
907+
);
722908
});

‎apps/server/src/orchestration-v2/CheckpointCaptureService.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export const layer: Layer.Layer<
6565
readonly runId: RunId;
6666
readonly scopeId: CheckpointScopeId;
6767
}) {
68-
const { run, rootNode, scope, providerThread, readyCheckpointOrdinals } =
68+
const { run, rootNode, scope, providerThread, readyCheckpointOrdinals, sideChat } =
6969
yield* projections.getCheckpointCaptureContext(input.threadId, input);
7070
// Stopped runs remain terminal; their checkpoint is the next message's rollback point.
7171
const stopped = run?.status === "interrupted" || run?.status === "cancelled";
@@ -109,12 +109,14 @@ export const layer: Layer.Layer<
109109
: yield* checkpoints.materializeBaselineCheckpoint({
110110
scope,
111111
ordinalWithinScope: 0,
112+
forceMissing: sideChat,
112113
});
113114
const baselineCheckpoint = hasReadyCheckpoint(baselineOrdinalWithinScope)
114115
? null
115116
: yield* checkpoints.materializeBaselineCheckpoint({
116117
scope,
117118
ordinalWithinScope: baselineOrdinalWithinScope,
119+
forceMissing: sideChat,
118120
});
119121
const checkpoint = yield* checkpoints.capture({
120122
scope,
@@ -123,6 +125,7 @@ export const layer: Layer.Layer<
123125
ordinalWithinScope: run.ordinal,
124126
appRunOrdinal: run.ordinal,
125127
capturedAt,
128+
forceMissing: sideChat,
126129
});
127130
// Omit delegatedCompletion to preserve cohort writes made after capture loaded the run.
128131
const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = run;

0 commit comments

Comments
 (0)