Skip to content

Commit ee5e6d0

Browse files
committed
Release v0.2.14
1 parent bd6b1ea commit ee5e6d0

32 files changed

Lines changed: 555 additions & 711 deletions

‎RELEASE.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
2-
"version": "0.2.13",
2+
"version": "0.2.14",
33
"license": "FSL-1.1-ALv2",
44
"baselineSha256": "7847f65a9b20b366a60d52d19ca3cd6bdd1bb09b8cb506659a270f27a21d8877",
55
"e2b": {
66
"recipeSha256": "761809ee2efa2b6c750451ace29283cbb8e55b6ce3b5b14c2bb4509df71df801",
77
"profileSha256": "414dbecee2d47de36016ea05c736e2edff3a0deb298babf923887ea184a01c10"
88
},
99
"dxd": {
10-
"url": "https://github.com/dxcode-dev/dxcode/releases/download/v0.2.13/dxd-linux-x64",
10+
"url": "https://github.com/dxcode-dev/dxcode/releases/download/v0.2.14/dxd-linux-x64",
1111
"sha256": "b793f9d2c7032e5fe3e233e40fc906b12648c3dd53969b5b7ef44d2d075d219b"
1212
},
1313
"toolchain": {

‎apps/core/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@dx/core",
33
"private": true,
4-
"version": "0.2.13",
4+
"version": "0.2.14",
55
"license": "FSL-1.1-ALv2",
66
"type": "module",
77
"scripts": {

‎apps/core/src/threads/routes.ts‎

Lines changed: 82 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ import {
6868
WorkspaceRepository,
6969
} from "@dx/domain";
7070
import { D1Client } from "@effect/sql-d1";
71-
import { dispatch } from "@flue/runtime";
71+
import { dispatch, getAgentInstance } from "@flue/runtime";
7272
import { Effect, Layer, Match, Option, Result, Schema } from "effect";
7373
import { Hono } from "hono";
7474
import { DxAgent } from "../agents/dx-agent.js";
@@ -341,6 +341,15 @@ type InitialThreadDispatcher = (
341341
const dispatchInitialThreadMessage: InitialThreadDispatcher = (request) =>
342342
dispatch(DxAgent, request);
343343

344+
/**
345+
* Starts the Thread's agent Durable Object without changing it, so its cold
346+
* start overlaps model resolution and persistence instead of following them.
347+
*/
348+
type InitialThreadAgentWarmer = (threadId: ThreadId) => Promise<unknown>;
349+
350+
const warmInitialThreadAgent: InitialThreadAgentWarmer = (threadId) =>
351+
getAgentInstance(DxAgent, threadId);
352+
344353
const resolveCreationResponseDetail = <
345354
Detail,
346355
DetailError,
@@ -377,6 +386,7 @@ const resolveCreationResponseDetail = <
377386
export const createThreadRoutes = (
378387
dispatchInitialMessage: InitialThreadDispatcher = dispatchInitialThreadMessage,
379388
titleAgent: DxTitleAgentRunner = runDxTitleAgent,
389+
warmInitialAgent: InitialThreadAgentWarmer = warmInitialThreadAgent,
380390
) => {
381391
const threadRoutes = new Hono<AppEnv>();
382392

@@ -386,6 +396,18 @@ export const createThreadRoutes = (
386396
let requestAdmittedAt = startedAt;
387397
let sourceAuthorizedAt = startedAt;
388398
let threadPersistedAt = startedAt;
399+
let agentWarmedAt: number | undefined;
400+
let detailResolvedAt: number | undefined;
401+
let initialMessageAdmittedAt: number | undefined;
402+
// Creation outlives the request: once it reaches Core, a closed tab or a
403+
// dropped connection cannot stop the Thread or its first message.
404+
const execution = (() => {
405+
try {
406+
return context.executionCtx;
407+
} catch {
408+
return undefined;
409+
}
410+
})();
389411
let settleTitlePersisted: ((persisted: boolean) => void) | undefined;
390412
let recoveredConcurrentCreation = false;
391413
const operation = Effect.gen(function* () {
@@ -403,13 +425,7 @@ export const createThreadRoutes = (
403425
const principal = context.get("principal");
404426
const requestedThreadId = input.threadId;
405427
// DxTitleAgent needs an execution context to outlive the response.
406-
const titleExecution = (() => {
407-
try {
408-
return context.executionCtx;
409-
} catch {
410-
return undefined;
411-
}
412-
})();
428+
const titleExecution = execution;
413429
const existingThread =
414430
requestedThreadId === undefined
415431
? undefined
@@ -647,6 +663,23 @@ export const createThreadRoutes = (
647663
threadPersistedAt = Date.now();
648664
settleTitlePersisted?.(!recoveredConcurrentCreation);
649665
const initialMessage = input.initialMessage;
666+
// The Thread now exists, so its agent is real: start it while the
667+
// detail and author are read, ahead of the dispatch below.
668+
if (initialMessage !== undefined)
669+
void warmInitialAgent(thread.id).then(
670+
() => {
671+
agentWarmedAt = Date.now();
672+
},
673+
() => undefined,
674+
);
675+
// The author identity is read beside the detail, not after it.
676+
const ownerIdentity =
677+
initialMessage === undefined
678+
? undefined
679+
: userIdentity(db, thread.ownerUserId).then(
680+
(identity) => ({ identity }),
681+
() => ({ identity: undefined }),
682+
);
650683
const detail = yield* resolveCreationResponseDetail(
651684
threadDetailData(context.env.DB, db, thread),
652685
initialMessage === undefined
@@ -657,13 +690,17 @@ export const createThreadRoutes = (
657690
),
658691
),
659692
);
693+
detailResolvedAt = Date.now();
660694
let initialSubmission: { readonly submissionId: string } | undefined;
661695
const messageSourceThreadId = context.get("messageSourceThreadId");
662-
if (initialMessage !== undefined) {
663-
const owner = yield* Effect.tryPromise({
664-
try: () => userIdentity(db, thread.ownerUserId),
665-
catch: () => new ThreadInitialAdmissionUnavailable(),
666-
});
696+
if (initialMessage !== undefined && ownerIdentity !== undefined) {
697+
const owner = yield* Effect.flatMap(
698+
Effect.promise(() => ownerIdentity),
699+
({ identity }) =>
700+
identity === undefined
701+
? Effect.fail(new ThreadInitialAdmissionUnavailable())
702+
: Effect.succeed(identity),
703+
);
667704
const receipt = yield* Effect.tryPromise({
668705
try: () =>
669706
dispatchInitialMessage({
@@ -693,6 +730,7 @@ export const createThreadRoutes = (
693730
catch: () => new ThreadInitialAdmissionUnavailable(),
694731
});
695732
initialSubmission = { submissionId: receipt.submissionId };
733+
initialMessageAdmittedAt = Date.now();
696734
const observations = submissionStartupObservations(
697735
requestId,
698736
thread.id,
@@ -717,16 +755,23 @@ export const createThreadRoutes = (
717755
return { body, threadId: thread.id };
718756
});
719757

720-
const result = await Effect.runPromise(Effect.result(operation));
721-
// A creation that failed before persisting leaves no row to title.
722-
settleTitlePersisted?.(false);
758+
const completion = Effect.runPromise(Effect.result(operation)).then(
759+
async (result) => {
760+
// A creation that failed before persisting leaves no row to title.
761+
settleTitlePersisted?.(false);
762+
if (Result.isSuccess(result))
763+
await scheduleRealtimeInvalidation(
764+
() => context.executionCtx,
765+
context.env,
766+
result.success.threadId,
767+
"thread.invalidated",
768+
);
769+
return result;
770+
},
771+
);
772+
execution?.waitUntil(completion);
773+
const result = await completion;
723774
if (Result.isSuccess(result)) {
724-
await scheduleRealtimeInvalidation(
725-
() => context.executionCtx,
726-
context.env,
727-
result.success.threadId,
728-
"thread.invalidated",
729-
);
730775
const responseReadyAt = Date.now();
731776
const decodedCorrelation = Schema.decodeUnknownResult(
732777
StartupTraceCorrelation,
@@ -764,7 +809,21 @@ export const createThreadRoutes = (
764809
responseReadyAt,
765810
),
766811
];
767-
context.header("Server-Timing", startupServerTiming(observations));
812+
// Header-only marks between persistence and the response.
813+
const responseMarks = [
814+
["agent_warmed", agentWarmedAt],
815+
["detail_resolved", detailResolvedAt],
816+
["initial_message_admitted", initialMessageAdmittedAt],
817+
] as const;
818+
context.header(
819+
"Server-Timing",
820+
[
821+
startupServerTiming(observations),
822+
...responseMarks.flatMap(([phase, at]) =>
823+
at === undefined ? [] : [`dx_${phase};dur=${at - startedAt}`],
824+
),
825+
].join(", "),
826+
);
768827
await scheduleStartupPersistence(
769828
() => context.executionCtx,
770829
recordStartupPhases(context.env.DB, observations),

‎apps/docs/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@dx/docs",
33
"private": true,
4-
"version": "0.2.13",
4+
"version": "0.2.14",
55
"license": "FSL-1.1-ALv2",
66
"type": "module",
77
"scripts": {

‎apps/web/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@dx/web",
33
"private": true,
4-
"version": "0.2.13",
4+
"version": "0.2.14",
55
"license": "FSL-1.1-ALv2",
66
"type": "module",
77
"scripts": {

‎apps/web/src/features/navigation/app-sidebar.tsx‎

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,21 @@ import { useDebouncer } from "@tanstack/react-pacer";
99
import { Link, useLocation } from "@tanstack/react-router";
1010
import { Search, SquarePen } from "lucide-react";
1111
import * as React from "react";
12-
import { useNewThreadSurface } from "../../shared/new-thread-surface.js";
12+
import {
13+
type PendingThreadCreation,
14+
useNewThreadSurface,
15+
} from "../../shared/new-thread-surface.js";
1316
import { useThreadArchive } from "../../shared/thread-archive.js";
1417
import { Button } from "../../shared/ui/button.js";
1518
import { SidebarAccountMenu } from "./sidebar-account-menu.js";
1619
import { SidebarArchive } from "./sidebar-archive.js";
1720
import { ProjectsIcon } from "./sidebar-icons.js";
1821
import { SidebarPanelIcon } from "./sidebar-panel-icon.js";
1922
import { SidebarThreadNavigation } from "./sidebar-thread-navigation.js";
20-
import { buildThreadSections } from "./sidebar-thread-utils.js";
23+
import {
24+
buildThreadSections,
25+
withPendingThreads,
26+
} from "./sidebar-thread-utils.js";
2127
import {
2228
ThreadHoverPreview,
2329
type ThreadPreviewAnchor,
@@ -137,27 +143,26 @@ const useThreadPreviewIntent = () => {
137143
};
138144
};
139145

140-
const usePendingProjectlessCreation = (
146+
const noPendingCreations: ReadonlyArray<PendingThreadCreation> = [];
147+
const readNoPendingCreations = () => noPendingCreations;
148+
const subscribeNothing = () => () => undefined;
149+
150+
const usePendingCreations = (
141151
surface: ReturnType<typeof useNewThreadSurface>,
142-
threadId: string | undefined,
143152
) => {
144-
const subscribe = React.useCallback(
145-
(listener: () => void) =>
146-
surface.subscribePendingProjectlessCreation?.(listener) ??
147-
(() => undefined),
148-
[surface],
153+
const read = surface.pendingCreations ?? readNoPendingCreations;
154+
return React.useSyncExternalStore(
155+
surface.subscribePendingCreations ?? subscribeNothing,
156+
read,
157+
read,
149158
);
150-
const snapshot = React.useCallback(() => {
151-
if (threadId === undefined) return undefined;
152-
return surface.pendingProjectlessCreation?.(threadId);
153-
}, [surface, threadId]);
154-
return React.useSyncExternalStore(subscribe, snapshot, snapshot);
155159
};
156160

157161
export function AppSidebar({
158162
userId,
159163
archiveAvailable = userId !== undefined,
160164
projects,
165+
projectsComplete = false,
161166
threads,
162167
sharedThreads,
163168
personalAccount,
@@ -179,6 +184,8 @@ export function AppSidebar({
179184
readonly userId?: UserId;
180185
readonly archiveAvailable?: boolean;
181186
readonly projects: ReadonlyArray<ProjectData>;
187+
/** Every Project the user can see is loaded, so unnamed ones are hidden. */
188+
readonly projectsComplete?: boolean;
182189
readonly threads: ReadonlyArray<ThreadData>;
183190
/** Shared Threads of other workspace members that this user follows. */
184191
readonly sharedThreads?: ReadonlyArray<ThreadData>;
@@ -205,10 +212,7 @@ export function AppSidebar({
205212
const activeThreadId = pathname.startsWith("/threads/")
206213
? pathname.slice("/threads/".length)
207214
: undefined;
208-
const pendingCreation = usePendingProjectlessCreation(
209-
newThreadSurface,
210-
activeThreadId,
211-
);
215+
const pendingCreations = usePendingCreations(newThreadSurface);
212216
const [collapsedGroups, setCollapsedGroups] = React.useState<
213217
ReadonlySet<string>
214218
>(() => new Set(["inactive", "archived"]));
@@ -240,9 +244,19 @@ export function AppSidebar({
240244
},
241245
[dismissPreviewForNavigation, onNavigate],
242246
);
247+
const listedThreads = React.useMemo(
248+
() =>
249+
withPendingThreads(
250+
threads,
251+
pendingCreations,
252+
projectNames,
253+
projectsComplete,
254+
),
255+
[threads, pendingCreations, projectNames, projectsComplete],
256+
);
243257
const sections = buildThreadSections(
244258
projects,
245-
threads,
259+
listedThreads,
246260
Date.now(),
247261
archiveAvailable,
248262
sharedThreads,
@@ -329,15 +343,14 @@ export function AppSidebar({
329343
)
330344
}
331345
activeThreadId={activeThreadId}
332-
pendingCreation={pendingCreation}
333346
collapsedGroups={collapsedGroups}
334347
hasMore={hasMore}
335348
loadingMore={loadingMore}
336349
loadMoreError={loadMoreError}
337350
projectNames={projectNames}
338351
query=""
339352
sections={sections}
340-
visibleThreadCount={threads.length + (sharedThreads?.length ?? 0)}
353+
visibleThreadCount={listedThreads.length + (sharedThreads?.length ?? 0)}
341354
onLoadMore={onLoadMore}
342355
onNavigate={onNavigate}
343356
onThreadNavigate={onThreadNavigate}

‎apps/web/src/features/navigation/product-shell.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export function ProductShell() {
122122
userId={identity.id}
123123
archiveAvailable={archiveAvailable}
124124
projects={projects}
125+
projectsComplete={projectsQuery.isSuccess && !projectsQuery.hasNextPage}
125126
threads={threads}
126127
sharedThreads={sharedThreadsQuery.data}
127128
onUnfollow={unfollowThread}

0 commit comments

Comments
 (0)