Skip to content

Commit 3e7009d

Browse files
fix(mobile): unresolved thread links stay neutral until their family read settles
A link to a thread the shell lacks showed its raw id while the family read was pending, and a failed read called the thread gone. Only a settled read now proves absence; until then a link shows its written label or "Thread". Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 57d31c6 commit 3e7009d

3 files changed

Lines changed: 38 additions & 10 deletions

File tree

‎apps/mobile/src/state/entities.test.tsx‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,19 @@ const server = await vi.hoisted(async () => {
3535
}
3636
return rows;
3737
});
38-
const families = new Map<string, OrchestrationV2ThreadFamily>();
38+
const families = new Map<string, OrchestrationV2ThreadFamily | "pending" | "failed">();
3939
return { key, shellRows, mounted, merged, families, AsyncResult, Atom };
4040
});
4141

4242
vi.mock("./threads", () => {
4343
const { Atom, AsyncResult, key, merged, mounted, families } = server;
4444
type Registry = import("effect/reactivity").AtomRegistry.AtomRegistry;
45-
const familyFor = (environmentId: string, threadId: string) =>
45+
const readOf = (environmentId: string, threadId: string) =>
4646
families.get(key(environmentId, threadId)) ?? null;
47+
const familyFor = (environmentId: string, threadId: string) => {
48+
const read = readOf(environmentId, threadId);
49+
return typeof read === "string" ? null : read;
50+
};
4751
const addRows = (
4852
registry: Registry,
4953
mountKey: string,
@@ -72,11 +76,13 @@ vi.mock("./threads", () => {
7276
environmentThreadDetails: {},
7377
threadFamilies: {
7478
familyAtom: (environmentId: string, threadId: string) => {
75-
const family = familyFor(environmentId, threadId);
79+
const read = readOf(environmentId, threadId);
7680
return Atom.make(
77-
family === null
81+
read === null || read === "failed"
7882
? AsyncResult.fail(new Error("not a known thread"))
79-
: AsyncResult.success(family),
83+
: read === "pending"
84+
? AsyncResult.initial(true)
85+
: AsyncResult.success(read),
8086
);
8187
},
8288
mount: (registry: Registry, environmentId: string, threadId: string) =>
@@ -158,3 +164,25 @@ it("opens a link to an idle subagent the shell omitted", () => {
158164
childLink,
159165
);
160166
});
167+
168+
it("keeps a link's title when the shell drops a subagent it already rendered", () => {
169+
using harness = setup();
170+
act(() => appAtomRegistry.set(server.shellRows, [PARENT, CHILD]));
171+
harness.render(parentReply);
172+
act(() => appAtomRegistry.set(server.shellRows, [PARENT]));
173+
174+
expect(harness.rendered()).toBe(`Child: ${formatThreadLink(childLink, CHILD.title)}`);
175+
});
176+
177+
it.each(["pending", "failed"] as const)(
178+
"labels a link neutrally while its family read is %s, not as gone",
179+
(read) => {
180+
using harness = setup();
181+
server.families.set(server.key(ENV, CHILD.id), read);
182+
harness.render(`${parentReply} and ${formatThreadLink(childLink, "Changelog")}`);
183+
184+
expect(harness.rendered()).toBe(
185+
`Child: ${formatThreadLink(childLink, "Thread")} and ${formatThreadLink(childLink, "Changelog")}`,
186+
);
187+
},
188+
);

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ const threadTitleAtom = Atom.family((key: string) => {
6767

6868
const decodeThreadKeys = Schema.decodeUnknownSync(Schema.Array(Schema.String));
6969

70-
// Null while the family read of a thread this client lacks is pending, so it is not called gone yet.
70+
// Null until the family read of a thread this client lacks settles: only a settled read proves it gone.
7171
const threadLinkTitlesAtom = Atom.family((key: string) => {
7272
const refKeys = decodeThreadKeys(JSON.parse(key));
7373
return Atom.make((get) => {
@@ -76,8 +76,8 @@ const threadLinkTitlesAtom = Atom.family((key: string) => {
7676
const title = get(threadTitleAtom(refKey));
7777
if (title !== null) titles.set(refKey, title);
7878
else if (
79-
threadFamilyRead(get(threadFamilies.familyAtom(...decodeThreadKey(JSON.parse(refKey))))) ===
80-
"pending"
79+
threadFamilyRead(get(threadFamilies.familyAtom(...decodeThreadKey(JSON.parse(refKey))))) !==
80+
"settled"
8181
) {
8282
titles.set(refKey, null);
8383
}
@@ -251,7 +251,7 @@ export function useLiveThreadLinkLabels(markdown: string, environmentId: Environ
251251
const titles = useAtomValue(
252252
refKeys === null ? EMPTY_THREAD_LINK_TITLES_ATOM : threadLinkTitlesAtom(refKeys),
253253
);
254-
// An empty title keeps the written label; another machine's thread keeps its own label.
254+
// An unsettled thread shows its written label or a neutral one; another machine's keeps its own.
255255
return relabelThreadLinks(markdown, environmentId, (ref) => {
256256
const title = titles.get(threadKey(ref));
257257
return title === null ? (ref.environmentId === environmentId ? "" : undefined) : title;

‎packages/shared/src/threadLinks.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export function relabelThreadLinks(
150150
}
151151
return formatThreadLink(
152152
{ environmentId: written.environmentId, threadId },
153-
current.trim() || groups.label || threadId,
153+
current.trim() || groups.label || "Thread",
154154
);
155155
});
156156
}

0 commit comments

Comments
 (0)