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
30 changes: 20 additions & 10 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ import {
} from "../../state/assets";
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
import { usePreparedConnection } from "../../state/session";
import { useLiveThreadLinkLabels } from "../../state/entities";
import { useThreadSelection } from "../../state/use-thread-selection";
import { composerDocumentAttachmentRecord } from "../../lib/composerContext";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -906,16 +907,15 @@ interface MarkdownLinkHandlers {

const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
readonly markdown: string;
readonly environmentId: EnvironmentId;
readonly markdownStyles: MarkdownStyleSet;
readonly linkHandlers: MarkdownLinkHandlers;
readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
readonly renderImage: MarkdownImageRenderer;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
}) {
const segments = useMemo(
() => splitCodexArtifactTemplateMarkdown(props.markdown),
[props.markdown],
);
const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]);

return segments.map((segment) => {
if (segment.kind === "artifact-template") {
Expand Down Expand Up @@ -1905,6 +1905,7 @@ function renderFeedEntry(
<MarkdownImageAvailableWidthContext value={props.markdownContentWidth}>
<AssistantMarkdownContent
markdown={renderedText}
environmentId={props.environmentId}
markdownStyles={styles}
linkHandlers={props.markdownLinkHandlers}
onUseArtifactTemplate={props.onUseArtifactTemplate}
Expand Down Expand Up @@ -2002,7 +2003,8 @@ function UserMessageContent(props: UserMessageContentProps) {
const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null);
const navigation = useNavigation();
const { selectedThread } = useThreadSelection();
const text = replaceComposerContextReferences(props.text, (ref) => {
const liveText = useLiveThreadLinkLabels(props.text, props.environmentId);
const text = replaceComposerContextReferences(liveText, (ref) => {
const available = props.context?.records.some((record) => record.contextId === ref.contextId);
return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`;
});
Expand Down Expand Up @@ -2292,11 +2294,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const userBubbleColor = theme["--color-user-bubble"];
const onMarkdownLinkPress = useCallback(
(href: string) => {
const threadLink = parseThreadLinkHref(href);
if (threadLink) {
// A thread link names a thread in this feed's environment.
const linkedThreadId = parseThreadLinkHref(href);
if (linkedThreadId) {
navigation.navigate("Thread", {
environmentId: String(threadLink.environmentId),
threadId: String(threadLink.threadId),
environmentId: String(props.environmentId),
threadId: String(linkedThreadId),
});
return;
}
Expand Down Expand Up @@ -2504,13 +2507,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
(text: string) => (
<AssistantMarkdownContent
markdown={text}
environmentId={props.environmentId}
markdownStyles={markdownStyles.assistant}
linkHandlers={markdownLinkHandlers}
renderImage={renderMarkdownImage}
skills={props.skills}
/>
),
[markdownStyles.assistant, markdownLinkHandlers, renderMarkdownImage, props.skills],
[
markdownStyles.assistant,
markdownLinkHandlers,
renderMarkdownImage,
props.skills,
props.environmentId,
],
);
const reviewCommentColors = useReviewCommentColors();
const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null;
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/lib/nativeMarkdownText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,12 @@ describe("nativeMarkdownTextRuns", () => {
children: [
{
type: "link",
href: "t3-thread://v1/env/thread-1",
href: "t3-thread://v1/thread-1",
children: [{ type: "text", content: "Fix the build" }],
},
],
}),
).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]);
).toEqual([{ text: "Fix the build", href: "t3-thread://v1/thread-1" }]);
});

it("preserves the destination of a link with a code-formatted label", () => {
Expand Down
36 changes: 36 additions & 0 deletions apps/mobile/src/state/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import type {
ScopedProjectRef,
ScopedThreadRef,
ServerConfig,
ThreadId,
} from "@t3tools/contracts";
import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks";
import { Atom } from "effect/reactivity";
import { useMemo } from "react";

import { environmentProjects } from "./projects";
import { environmentServerConfigsAtom, serverEnvironment } from "./server";
Expand All @@ -28,6 +31,25 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p
const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
Atom.withLabel("mobile-server-config:empty"),
);
const EMPTY_THREAD_TITLES: ReadonlyMap<ThreadId, string> = new Map();
const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe(
Atom.withLabel("mobile-thread-titles:empty"),
);

/** Thread titles in one environment. Emits when a title changes, not on every shell update. */
const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => {
let previous = EMPTY_THREAD_TITLES;
return Atom.make((get) => {
const index = get(environmentThreadShells.environmentThreadIndexAtom(environmentId));
const unchanged =
index.size === previous.size &&
Array.from(index).every(([threadId, shell]) => previous.get(threadId) === shell.title);
if (!unchanged) {
previous = new Map(Array.from(index, ([threadId, shell]) => [threadId, shell.title]));
}
return previous;
}).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`));
});

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

/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */
export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
const titles = useAtomValue(
hasThreadLinks(markdown) ? threadTitlesAtom(environmentId) : EMPTY_THREAD_TITLES_ATOM,
);
return useMemo(
() =>
titles.size === 0
? markdown
: relabelThreadLinks(markdown, (threadId) => titles.get(threadId)),
[markdown, titles],
);
}

export function useEnvironmentServerConfig(
environmentId: EnvironmentId | null,
): ServerConfig | null {
Expand Down
59 changes: 59 additions & 0 deletions apps/server/scripts/migrate-dev-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,65 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
}),
);

it.effect("never copies a settled thread's rows, and new events append after the source's", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" });
const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-dest-" });
const source = yield* createFixtureSource(sourceDir);
const [sourceSequence] = yield* withDatabase(
source,
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
return yield* sql<{ seq: number }>`
SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`;
}),
);

// Keep every family, so only the copy can leave the settled one out.
const result = yield* runMigrateDevDb(
{ baseDir: destDir, source, projects: 5, threadsPerProject: 100 },
{ sharedHome: sourceDir },
);

const copied = yield* withDatabase(
result.databasePath,
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const [settledRows] = yield* sql<{ count: number }>`
SELECT
(SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'settled-thread')
+ (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'settled-thread')
AS count`;
const [sequence] = yield* sql<{ seq: number }>`
SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`;
return { settledRows: settledRows?.count, sequence: sequence?.seq };
}),
);
assert.equal(copied.settledRows, 0);
assert.equal(copied.sequence, sourceSequence?.seq);
}),
);

it.effect("upgrades a source from before the V2 thread tables", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-" });
const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-dest-" });
const stateDir = path.join(sourceDir, "userdata");
const source = path.join(stateDir, "statev2.sqlite");
yield* fs.makeDirectory(stateDir, { recursive: true });
yield* withDatabase(source, runMigrations({ toMigrationInclusive: 54 }));

const result = yield* runMigrateDevDb(
{ baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
{ sharedHome: sourceDir },
);
assert.include(result.executedMigrations, "55_OrchestrationV2");
}),
);

it.effect("fails loudly on a migration slot collision", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
Loading
Loading