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
8 changes: 1 addition & 7 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt
import { useThreadComposerState } from "../../state/use-thread-composer-state";
import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows";
import { threadEnvironment, useThreadFamily } from "../../state/threads";
import { threadEnvironment } from "../../state/threads";
import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads";
import { useProjects } from "../../state/entities";
import { projectThreadContentPresentation } from "./threadContentPresentation";
Expand Down Expand Up @@ -243,12 +243,6 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) {
const connectionsReady = useConnectionsReady();
const { connectionState } = useRemoteConnectionStatus();
const { selectedThread } = useThreadSelection();
// Subagent rows, the open child's own row and its ancestors come from the family when the
// shell leaves idle children out.
useThreadFamily(
selectedThread?.environmentId ?? null,
selectedThread?.lineage.rootThreadId ?? null,
);
const params = props.route.params ?? {};
const environmentIdRaw = firstRouteParam(params.environmentId);
const threadIdRaw = firstRouteParam(params.threadId);
Expand Down
13 changes: 0 additions & 13 deletions apps/mobile/src/state/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
} from "@t3tools/client-runtime/state/threads";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { AsyncResult, Atom } from "effect/reactivity";
import { useEffect } from "react";

import { environmentCatalog } from "../connection/catalog";
import { connectionAtomRuntime } from "../connection/runtime";
Expand Down Expand Up @@ -45,18 +44,6 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({
snapshotAtom: threadEnvironment.snapshotAtom,
});

/** Keeps the subagent tree under `rootThreadId` in the thread list while the caller shows it. */
export function useThreadFamily(
environmentId: EnvironmentId | null,
rootThreadId: ThreadId | null,
): void {
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts the subagent tree under the root thread into the shared thread registry while the caller shows it
useEffect(() => {
if (environmentId === null || rootThreadId === null) return;
return threadFamilies.mount(appAtomRegistry, environmentId, rootThreadId);
}, [environmentId, rootThreadId]);
}

// Long enough for the opened screen to subscribe; its subscription keeps the thread after this.
const OPENING_THREAD_HOLD_MS = 3_000;

Expand Down
10 changes: 1 addition & 9 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ import {
primaryServerKeybindingsAtom,
serverEnvironment,
} from "../state/server";
import { threadEnvironment, useThreadFamily } from "../state/threads";
import { threadEnvironment } from "../state/threads";
import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
import { vcsEnvironment } from "../state/vcs";
import {
Expand Down Expand Up @@ -482,14 +482,6 @@ export default function ChatView(props: ChatViewProps) {
});
const serverThreadProjection = useQuietThreadProjection(routeThreadDetailRef);
const serverProjection = serverThreadProjection?.projection ?? null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// Subagent rows, an open child's own row and its ancestors come from the family when the shell
// leaves idle children out. Every source agrees on the root, so the key never flips.
useThreadFamily(
routeKind === "server" ? environmentId : null,
serverThreadShell?.lineage.rootThreadId ??
serverProjection?.thread.lineage.rootThreadId ??
null,
);
// Only the shell-less fallback needs these; with a shell, the shell carries them.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const fallbackDetailRef = serverThreadShell === null ? routeThreadDetailRef : null;
const fallbackUpdatedAt = useThreadUpdatedAt(fallbackDetailRef);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand Down
8 changes: 1 addition & 7 deletions apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,16 @@ function WorkflowMemberRow({
providers,
driver,
onOpen,
isThreadAvailable,
}: {
member: RuntimeSubagent;
providerInstanceId: ProviderInstanceId;
provider: ServerProvider | undefined;
providers: ReadonlyArray<ServerProvider> | undefined;
driver: ProviderDriverKind | undefined;
onOpen: (threadId: string) => void;
isThreadAvailable: (threadId: string) => boolean;
}) {
const threadId = member.childThreadId;
const canOpen = threadId !== null && isThreadAvailable(threadId);
const canOpen = threadId !== null;
return (
<li>
<Tooltip>
Expand Down Expand Up @@ -117,7 +115,6 @@ export function ThreadLineageWorkflowRow({
providers,
driver,
onOpenThread,
isThreadAvailable,
onStop,
stopping,
stopDisabled,
Expand All @@ -129,7 +126,6 @@ export function ThreadLineageWorkflowRow({
readonly providers: ReadonlyArray<ServerProvider> | undefined;
readonly driver: ProviderDriverKind | undefined;
readonly onOpenThread: (threadId: string) => void;
readonly isThreadAvailable: (threadId: string) => boolean;
readonly onStop?: (() => void) | undefined;
readonly stopping: boolean;
readonly stopDisabled: boolean;
Expand Down Expand Up @@ -227,7 +223,6 @@ export function ThreadLineageWorkflowRow({
providers={providers}
driver={driver}
onOpen={onOpenThread}
isThreadAvailable={isThreadAvailable}
/>
))}
</ul>
Expand All @@ -244,7 +239,6 @@ export function ThreadLineageWorkflowRow({
providers={providers}
driver={driver}
onOpen={onOpenThread}
isThreadAvailable={isThreadAvailable}
/>
))}
{phases.length === 0 && group.unphasedMembers.length === 0 ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { EnvironmentId, NodeId, ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";
Expand Down Expand Up @@ -34,6 +34,7 @@ vi.mock(import("../../state/use-atom-command"), () => ({
}));

import { render } from "../../../test/render";
import { makeThreadProjectionFixture } from "../../test-fixtures";
import { ToastProvider } from "../ui/toast";
import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl";

Expand Down Expand Up @@ -134,3 +135,51 @@ it.each([
await expect.element(screen.getByRole("button", { name: "More thread actions" })).toBeEnabled();
}
});

it("opens an idle child from Lineage when its shell has not been loaded", async () => {
setup();
const projection = makeThreadProjectionFixture();
const childId = ThreadId.make("omitted-child");
const environmentId = EnvironmentId.make("local");
state.projection = {
...projection,
subagents: [
{
id: NodeId.make("agent"),
threadId: projection.thread.id,
runId: null,
parentNodeId: NodeId.make("parent"),
origin: "app_owned",
createdBy: "agent",
driver: "codex",
providerInstanceId: projection.thread.providerInstanceId,
providerThreadId: null,
childThreadId: childId,
nativeTaskRef: null,
prompt: "Inspect the change",
title: "Idle child",
model: null,
status: "completed",
result: null,
startedAt: null,
completedAt: projection.thread.updatedAt,
updatedAt: projection.thread.updatedAt,
},
],
};
await using screen = await render(
<ThreadRelationshipsPanel
environmentId={environmentId}
threadId={projection.thread.id}
showLineage
showSideChats
/>,
);
await screen.getByRole("button", { name: /Previous agents/ }).click();
const child = screen.getByRole("button", { name: /Idle child/ });
await expect.element(child).toBeEnabled();
await child.click();
await vi.waitFor(() =>
expect(screen.router?.state.location.pathname).toBe("/local/omitted-child"),
);
});
10 changes: 2 additions & 8 deletions apps/web/src/components/chat/ThreadRelationshipsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ export function ThreadRelationshipsPanel(props: {
return null;
}

// Idle children can be omitted from the shell; opening one loads its own detail.
const openThread = (threadId: ThreadId) => {
if (openSideChatTab(ref, graph.nodes.get(threadId)?.thread ?? null)) return;
void navigate({
Expand Down Expand Up @@ -643,9 +644,7 @@ export function ThreadRelationshipsPanel(props: {
? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0"
: "";
const project = projects.find((project) => project.id === node?.thread?.projectId);
const relationshipHint = node?.missing
? "This related thread is unavailable"
: `Open ${relationship.toLowerCase()} in this chat`;
const relationshipHint = `Open ${relationship.toLowerCase()} in this chat`;
const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup;
const relationshipTooltip = agent ? (
<SubagentTooltipContent
Expand Down Expand Up @@ -713,7 +712,6 @@ export function ThreadRelationshipsPanel(props: {
size="sm"
variant="ghost"
part={workflowGroup ? "primary" : "row"}
disabled={node?.missing === true}
onClick={() => openThread(threadId)}
/>
}
Expand All @@ -733,9 +731,6 @@ export function ThreadRelationshipsPanel(props: {
providers={providers}
driver={providerDriver}
onOpenThread={(memberThreadId) => openThread(memberThreadId as ThreadId)}
isThreadAvailable={(memberThreadId) =>
graph.nodes.get(memberThreadId as ThreadId)?.missing === false
}
onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined}
stopping={stoppingId === agent.id}
stopDisabled={stoppingId !== null}
Expand All @@ -756,7 +751,6 @@ export function ThreadRelationshipsPanel(props: {
variant="ghost"
part="primary"
aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`}
disabled={node?.missing === true}
onClick={() => openThread(threadId)}
/>
}
Expand Down
14 changes: 0 additions & 14 deletions apps/web/src/state/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,9 @@ import {
import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/reactivity";
import { useEffect } from "react";

import { environmentCatalog } from "../connection/catalog";
import { connectionAtomRuntime } from "../connection/runtime";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentShell } from "./shell";

export const threadFamilies = createThreadFamilyAtoms(
Expand All @@ -42,18 +40,6 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({
snapshotAtom: threadEnvironment.snapshotAtom,
});

/** Keeps the subagent tree under `rootThreadId` in the thread list while the caller shows it. */
export function useThreadFamily(
environmentId: EnvironmentId | null,
rootThreadId: ThreadId | null,
): void {
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts the subagent tree into the thread family registry while the caller shows it
useEffect(() => {
if (environmentId === null || rootThreadId === null) return;
return threadFamilies.mount(appAtomRegistry, environmentId, rootThreadId);
}, [environmentId, rootThreadId]);
}

const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe(
Atom.withLabel("web-environment-thread:empty"),
);
Expand Down
34 changes: 34 additions & 0 deletions packages/client-runtime/src/state/threadRelationships.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,40 @@ describe("thread relationships", () => {
).toBeNull();
});

it.each(["fork", "subagent"] as const)(
"keeps the parent edge of an opened %s omitted from the shell",
(relationshipToParent) => {
const parent = ThreadId.make("parent");
const child = ThreadId.make("omitted-child");
const projection = {
thread: {
id: child,
forkedFrom:
relationshipToParent === "fork"
? { type: "run", threadId: parent, runId: "source-run" }
: null,
lineage: { rootThreadId: parent, parentThreadId: parent, relationshipToParent },
},
subagents: [],
contextTransfers: [],
} as never;
const graph = deriveThreadRelationshipGraph({ threads: [], projection });
expect(immediateThreadRelationships(graph, child)).toEqual([
expect.objectContaining({
threadId: parent,
edge: expect.objectContaining({
sourceThreadId: parent,
targetThreadId: child,
kind: relationshipToParent,
}),
}),
]);
expect(resolveMergeBackTargetThreadId(projection)).toBe(
relationshipToParent === "fork" ? parent : null,
);
},
);

it("keeps an older activity run visible over a newer cancelled run", () => {
const parent = ThreadId.make("thread-parent");
const child = ThreadId.make("thread-child");
Expand Down
7 changes: 6 additions & 1 deletion packages/client-runtime/src/state/threadRelationships.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,12 @@ export function deriveThreadRelationshipGraph(input: {
);
};

for (const thread of threads) {
const selectedThread = input.projection?.thread;
const lineageThreads =
selectedThread !== undefined && !threadsById.has(selectedThread.id)
? [...threads, { ...selectedThread, activityRunStatus: null, status: null }]
: threads;
for (const thread of lineageThreads) {
const parentThreadId =
thread.forkedFrom?.type === "run"
? thread.forkedFrom.threadId
Expand Down
Loading