Skip to content

Commit 0f09627

Browse files
fix(server,web,mobile): thread links reference the thread id, not a baked title (#17017)
1 parent 04ad174 commit 0f09627

18 files changed

Lines changed: 384 additions & 180 deletions

File tree

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

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ import {
198198
} from "../../state/assets";
199199
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
200200
import { usePreparedConnection } from "../../state/session";
201+
import { useLiveThreadLinkLabels } from "../../state/entities";
201202
import { useThreadSelection } from "../../state/use-thread-selection";
202203
import { composerDocumentAttachmentRecord } from "../../lib/composerContext";
203204
import * as Option from "effect/Option";
@@ -906,16 +907,15 @@ interface MarkdownLinkHandlers {
906907

907908
const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
908909
readonly markdown: string;
910+
readonly environmentId: EnvironmentId;
909911
readonly markdownStyles: MarkdownStyleSet;
910912
readonly linkHandlers: MarkdownLinkHandlers;
911913
readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
912914
readonly renderImage: MarkdownImageRenderer;
913915
readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
914916
}) {
915-
const segments = useMemo(
916-
() => splitCodexArtifactTemplateMarkdown(props.markdown),
917-
[props.markdown],
918-
);
917+
const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
918+
const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]);
919919

920920
return segments.map((segment) => {
921921
if (segment.kind === "artifact-template") {
@@ -1905,6 +1905,7 @@ function renderFeedEntry(
19051905
<MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>
19061906
<AssistantMarkdownContent
19071907
markdown={renderedText}
1908+
environmentId={props.environmentId}
19081909
markdownStyles={styles}
19091910
linkHandlers={props.markdownLinkHandlers}
19101911
onUseArtifactTemplate={props.onUseArtifactTemplate}
@@ -2002,7 +2003,8 @@ function UserMessageContent(props: UserMessageContentProps) {
20022003
const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null);
20032004
const navigation = useNavigation();
20042005
const { selectedThread } = useThreadSelection();
2005-
const text = replaceComposerContextReferences(props.text, (ref) => {
2006+
const liveText = useLiveThreadLinkLabels(props.text, props.environmentId);
2007+
const text = replaceComposerContextReferences(liveText, (ref) => {
20062008
const available = props.context?.records.some((record) => record.contextId === ref.contextId);
20072009
return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`;
20082010
});
@@ -2292,11 +2294,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22922294
const userBubbleColor = theme["--color-user-bubble"];
22932295
const onMarkdownLinkPress = useCallback(
22942296
(href: string) => {
2295-
const threadLink = parseThreadLinkHref(href);
2296-
if (threadLink) {
2297+
// A thread link names a thread in this feed's environment.
2298+
const linkedThreadId = parseThreadLinkHref(href);
2299+
if (linkedThreadId) {
22972300
navigation.navigate("Thread", {
2298-
environmentId: String(threadLink.environmentId),
2299-
threadId: String(threadLink.threadId),
2301+
environmentId: String(props.environmentId),
2302+
threadId: String(linkedThreadId),
23002303
});
23012304
return;
23022305
}
@@ -2504,13 +2507,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
25042507
(text: string) => (
25052508
<AssistantMarkdownContent
25062509
markdown={text}
2510+
environmentId={props.environmentId}
25072511
markdownStyles={markdownStyles.assistant}
25082512
linkHandlers={markdownLinkHandlers}
25092513
renderImage={renderMarkdownImage}
25102514
skills={props.skills}
25112515
/>
25122516
),
2513-
[markdownStyles.assistant, markdownLinkHandlers, renderMarkdownImage, props.skills],
2517+
[
2518+
markdownStyles.assistant,
2519+
markdownLinkHandlers,
2520+
renderMarkdownImage,
2521+
props.skills,
2522+
props.environmentId,
2523+
],
25142524
);
25152525
const reviewCommentColors = useReviewCommentColors();
25162526
const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null;

‎apps/mobile/src/lib/nativeMarkdownText.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,12 @@ describe("nativeMarkdownTextRuns", () => {
9191
children: [
9292
{
9393
type: "link",
94-
href: "t3-thread://v1/env/thread-1",
94+
href: "t3-thread://v1/thread-1",
9595
children: [{ type: "text", content: "Fix the build" }],
9696
},
9797
],
9898
}),
99-
).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]);
99+
).toEqual([{ text: "Fix the build", href: "t3-thread://v1/thread-1" }]);
100100
});
101101

102102
it("preserves the destination of a link with a code-formatted label", () => {

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import type {
1212
ScopedProjectRef,
1313
ScopedThreadRef,
1414
ServerConfig,
15+
ThreadId,
1516
} from "@t3tools/contracts";
17+
import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks";
1618
import { Atom } from "effect/reactivity";
19+
import { useMemo } from "react";
1720

1821
import { environmentProjects } from "./projects";
1922
import { environmentServerConfigsAtom, serverEnvironment } from "./server";
@@ -28,6 +31,25 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p
2831
const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
2932
Atom.withLabel("mobile-server-config:empty"),
3033
);
34+
const EMPTY_THREAD_TITLES: ReadonlyMap<ThreadId, string> = new Map();
35+
const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe(
36+
Atom.withLabel("mobile-thread-titles:empty"),
37+
);
38+
39+
/** Thread titles in one environment. Emits when a title changes, not on every shell update. */
40+
const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => {
41+
let previous = EMPTY_THREAD_TITLES;
42+
return Atom.make((get) => {
43+
const index = get(environmentThreadShells.environmentThreadIndexAtom(environmentId));
44+
const unchanged =
45+
index.size === previous.size &&
46+
Array.from(index).every(([threadId, shell]) => previous.get(threadId) === shell.title);
47+
if (!unchanged) {
48+
previous = new Map(Array.from(index, ([threadId, shell]) => [threadId, shell.title]));
49+
}
50+
return previous;
51+
}).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`));
52+
});
3153

3254
/** Resolves when the project event reaches the live client store. */
3355
export function waitForProject(
@@ -76,6 +98,20 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh
7698
);
7799
}
78100

101+
/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */
102+
export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
103+
const titles = useAtomValue(
104+
hasThreadLinks(markdown) ? threadTitlesAtom(environmentId) : EMPTY_THREAD_TITLES_ATOM,
105+
);
106+
return useMemo(
107+
() =>
108+
titles.size === 0
109+
? markdown
110+
: relabelThreadLinks(markdown, (threadId) => titles.get(threadId)),
111+
[markdown, titles],
112+
);
113+
}
114+
79115
export function useEnvironmentServerConfig(
80116
environmentId: EnvironmentId | null,
81117
): ServerConfig | null {

‎apps/server/scripts/migrate-dev-db.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,65 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
142142
}),
143143
);
144144

145+
it.effect("never copies a settled thread's rows, and new events append after the source's", () =>
146+
Effect.gen(function* () {
147+
const fs = yield* FileSystem.FileSystem;
148+
const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" });
149+
const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-dest-" });
150+
const source = yield* createFixtureSource(sourceDir);
151+
const [sourceSequence] = yield* withDatabase(
152+
source,
153+
Effect.gen(function* () {
154+
const sql = yield* SqlClient.SqlClient;
155+
return yield* sql<{ seq: number }>`
156+
SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`;
157+
}),
158+
);
159+
160+
// Keep every family, so only the copy can leave the settled one out.
161+
const result = yield* runMigrateDevDb(
162+
{ baseDir: destDir, source, projects: 5, threadsPerProject: 100 },
163+
{ sharedHome: sourceDir },
164+
);
165+
166+
const copied = yield* withDatabase(
167+
result.databasePath,
168+
Effect.gen(function* () {
169+
const sql = yield* SqlClient.SqlClient;
170+
const [settledRows] = yield* sql<{ count: number }>`
171+
SELECT
172+
(SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'settled-thread')
173+
+ (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'settled-thread')
174+
AS count`;
175+
const [sequence] = yield* sql<{ seq: number }>`
176+
SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`;
177+
return { settledRows: settledRows?.count, sequence: sequence?.seq };
178+
}),
179+
);
180+
assert.equal(copied.settledRows, 0);
181+
assert.equal(copied.sequence, sourceSequence?.seq);
182+
}),
183+
);
184+
185+
it.effect("upgrades a source from before the V2 thread tables", () =>
186+
Effect.gen(function* () {
187+
const fs = yield* FileSystem.FileSystem;
188+
const path = yield* Path.Path;
189+
const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-" });
190+
const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-dest-" });
191+
const stateDir = path.join(sourceDir, "userdata");
192+
const source = path.join(stateDir, "statev2.sqlite");
193+
yield* fs.makeDirectory(stateDir, { recursive: true });
194+
yield* withDatabase(source, runMigrations({ toMigrationInclusive: 54 }));
195+
196+
const result = yield* runMigrateDevDb(
197+
{ baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
198+
{ sharedHome: sourceDir },
199+
);
200+
assert.include(result.executedMigrations, "55_OrchestrationV2");
201+
}),
202+
);
203+
145204
it.effect("fails loudly on a migration slot collision", () =>
146205
Effect.gen(function* () {
147206
const fs = yield* FileSystem.FileSystem;

0 commit comments

Comments
 (0)