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
43 changes: 42 additions & 1 deletion apps/web/src/components/ThreadRouteView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { useEffect, useState } from "react";

import ChatView from "./ChatView";
import { BackgroundThreadContext } from "./chat/backgroundThread";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
import { SidebarInset } from "./ui/sidebar";
import { WorkspacePageHeader } from "./WorkspacePageHeader";
import { isElectron } from "../env";
import { useComposerDraftStore } from "../composerDraftStore";
import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
import {
Expand All @@ -18,12 +21,13 @@ import {
useQuietThreadShell,
} from "../state/entities";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { useEnvironmentShellStatus } from "../state/shell";
import { useEnvironmentShellHasSnapshot, useEnvironmentShellStatus } from "../state/shell";
import { environmentThreadShells, threadFamilies, useEnvironmentThread } from "../state/threads";
import {
buildThreadRouteParams,
isThreadRouteSnapshotAuthoritative,
resolveThreadRouteRenderState,
type ThreadRouteRenderState,
type ThreadRouteTarget,
} from "../threadRoutes";

Expand Down Expand Up @@ -71,6 +75,7 @@ export function ThreadRouteView({
const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null);
const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null);
const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shellStatus);
const shellHasSnapshot = useEnvironmentShellHasSnapshot(serverThreadRef?.environmentId ?? null);
const draftThread = useComposerDraftStore((store) =>
serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null,
);
Expand Down Expand Up @@ -133,6 +138,7 @@ export function ThreadRouteView({
);
const renderState = resolveThreadRouteRenderState({
bootstrapComplete,
shellHasSnapshot,
serverThreadExists: serverThreadShell !== null,
serverThreadDeleted: serverThreadShell?.deletedAt != null,
unlistedThreadDetail,
Expand Down Expand Up @@ -231,6 +237,41 @@ export function ThreadRouteView({
>
<BackgroundThreadContext value={background}>{view}</BackgroundThreadContext>
{background ? null : redirect}
{target.kind === "server" && view === null && redirect === null ? (
<ThreadRouteNotice
renderState={renderState}
waitingForEnvironment={!bootstrapComplete}
error={Option.getOrNull(unlistedThreadDetail.error)}
/>
) : null}
</SidebarInset>
);
}

/** Stands in for a server thread the route cannot show yet, so the pane is never blank. */
function ThreadRouteNotice(props: {
readonly renderState: ThreadRouteRenderState;
readonly waitingForEnvironment: boolean;
readonly error: string | null;
}) {
const [title, description] =
props.renderState === "missing"
? ["Thread not found", "It may have been deleted or archived."]
: props.error !== null
? ["Couldn’t open this thread", props.error]
: [
"Opening thread…",
props.waitingForEnvironment ? "Waiting for its environment to connect." : null,
];
return (
<>
{isElectron ? <WorkspacePageHeader electron /> : null}
<Empty className="flex-1">
<EmptyHeader className="max-w-md">
<EmptyTitle>{title}</EmptyTitle>
{description === null ? null : <EmptyDescription>{description}</EmptyDescription>}
</EmptyHeader>
</Empty>
</>
);
}
41 changes: 41 additions & 0 deletions apps/web/src/threadRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ describe("threadRoutes", () => {
expect(isThreadRoutePathname("/env-2/thread-1", ref)).toBe(false);
expect(isThreadRoutePathname("/draft/thread-1", ref)).toBe(false);
expect(isThreadRoutePathname("/env-1/thread-1/extra", ref)).toBe(false);

// The router keeps the escapes it wrote for a delegated task's id.
const child = scopeThreadRef(
"env-1" as never,
ThreadId.make("thread:delegated-task:command%3Amcp%3Ab629%3Adelegate-task%3Aq-1"),
);
const childPath =
"/env-1/thread%3Adelegated-task%3Acommand%253Amcp%253Ab629%253Adelegate-task%253Aq-1";
expect(isThreadRoutePathname(childPath, child)).toBe(true);
expect(isThreadRoutePathname(childPath.replace("q-1", "q-2"), child)).toBe(false);
});

it("resolves the backing thread while a draft route is being promoted", () => {
Expand Down Expand Up @@ -63,6 +73,7 @@ describe("threadRoutes", () => {
expect(
resolveThreadRouteRenderState({
bootstrapComplete: isThreadRouteSnapshotAuthoritative(status),
shellHasSnapshot: true,
serverThreadExists: source === "server thread",
serverThreadDeleted: false,
unlistedThreadDetail: EMPTY_ENVIRONMENT_THREAD_STATE,
Expand All @@ -76,6 +87,7 @@ describe("threadRoutes", () => {
expect(
resolveThreadRouteRenderState({
bootstrapComplete: false,
shellHasSnapshot: false,
serverThreadExists: false,
serverThreadDeleted: false,
unlistedThreadDetail: { ...EMPTY_ENVIRONMENT_THREAD_STATE, status: "deleted" },
Expand All @@ -85,6 +97,7 @@ describe("threadRoutes", () => {
expect(
resolveThreadRouteRenderState({
bootstrapComplete: true,
shellHasSnapshot: true,
serverThreadExists: false,
serverThreadDeleted: false,
unlistedThreadDetail: { ...EMPTY_ENVIRONMENT_THREAD_STATE, status: "deleted" },
Expand All @@ -96,6 +109,7 @@ describe("threadRoutes", () => {
it("opens an unlisted server thread once its detail arrives, without waiting for the shell", () => {
const route = {
bootstrapComplete: isThreadRouteSnapshotAuthoritative("live"),
shellHasSnapshot: true,
serverThreadExists: false,
serverThreadDeleted: false,
draftThreadExists: false,
Expand Down Expand Up @@ -136,13 +150,39 @@ describe("threadRoutes", () => {
},
}),
).toBe("ready");
// Until the shell first loads, it may yet list or hide the thread.
expect(
resolveThreadRouteRenderState({
...route,
bootstrapComplete: isThreadRouteSnapshotAuthoritative("synchronizing"),
shellHasSnapshot: false,
unlistedThreadDetail: {
...EMPTY_ENVIRONMENT_THREAD_STATE,
status: "live",
data: Option.some(makeThreadProjectionFixture()),
},
}),
).toBe("loading");
// A sandbox's subagent, left out of a shell that is still reconnecting.
expect(
resolveThreadRouteRenderState({
...route,
bootstrapComplete: isThreadRouteSnapshotAuthoritative("synchronizing"),
unlistedThreadDetail: {
...EMPTY_ENVIRONMENT_THREAD_STATE,
status: "cached",
data: Option.some(makeThreadProjectionFixture()),
},
}),
).toBe("ready");
});

it.each(["missing", "deleted", "archived"] as const)(
"redirects an unlisted thread only after detail confirms it is %s",
(outcome) => {
const route = {
bootstrapComplete: isThreadRouteSnapshotAuthoritative("live"),
shellHasSnapshot: true,
serverThreadExists: false,
serverThreadDeleted: false,
draftThreadExists: false,
Expand Down Expand Up @@ -186,6 +226,7 @@ describe("threadRoutes", () => {
expect(
resolveThreadRouteRenderState({
bootstrapComplete: isThreadRouteSnapshotAuthoritative("cached"),
shellHasSnapshot: true,
serverThreadExists: true,
serverThreadDeleted: true,
unlistedThreadDetail: EMPTY_ENVIRONMENT_THREAD_STATE,
Expand Down
42 changes: 32 additions & 10 deletions apps/web/src/threadRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function isThreadRouteSnapshotAuthoritative(

export function resolveThreadRouteRenderState(input: {
bootstrapComplete: boolean;
shellHasSnapshot: boolean;
serverThreadExists: boolean;
serverThreadDeleted: boolean;
unlistedThreadDetail: EnvironmentThreadState;
Expand All @@ -39,20 +40,29 @@ export function resolveThreadRouteRenderState(input: {
if (input.draftThreadExists || (input.serverThreadExists && !input.serverThreadDeleted)) {
return "ready";
}
// Shell membership can lag a new thread, and never holds an idle subagent; once the shell has
// loaded, its detail decides without waiting for a reconnecting shell.
const projection = input.serverThreadExists
? null
: Option.getOrNull(input.unlistedThreadDetail.data);
const removed =
projection !== null &&
(projection.thread.deletedAt !== null || projection.thread.archivedAt !== null);
if (
input.shellHasSnapshot &&
projection !== null &&
!removed &&
input.unlistedThreadDetail.status !== "deleted"
) {
return "ready";
}
if (!input.bootstrapComplete) {
return "loading";
}
if (input.serverThreadExists || input.unlistedThreadDetail.status === "deleted") {
return "missing";
}
// Shell membership can lag a new thread; its detail decides.
const projection = Option.getOrNull(input.unlistedThreadDetail.data);
if (projection === null) {
return "loading";
}
return projection.thread.deletedAt !== null || projection.thread.archivedAt !== null
? "missing"
: "ready";
return removed ? "missing" : "loading";
}

export function buildThreadRouteParams(ref: ScopedThreadRef): {
Expand All @@ -65,10 +75,22 @@ export function buildThreadRouteParams(ref: ScopedThreadRef): {
};
}

/** Whether a (decoded) location pathname is `ref`'s own thread route, `/$environmentId/$threadId`. */
/**
* Whether a location pathname is `ref`'s own thread route, `/$environmentId/$threadId`. The router
* leaves reserved escapes and `%25` in it, so each segment is decoded as the router decodes params.
*/
export function isThreadRoutePathname(pathname: string, ref: ScopedThreadRef): boolean {
const { environmentId, threadId } = buildThreadRouteParams(ref);
return pathname.replace(/\/$/, "") === `/${environmentId}/${threadId}`;
const segments = pathname.replace(/\/$/, "").split("/");
if (segments.length !== 3 || segments[0] !== "") return false;
try {
return (
decodeURIComponent(segments[1]!) === environmentId &&
decodeURIComponent(segments[2]!) === threadId
);
} catch {
return false;
}
}

export function buildDraftThreadRouteParams(draftId: DraftId): {
Expand Down