Skip to content
Open
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
44 changes: 30 additions & 14 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
import {
canForkProjectedAssistantItem,
forkCutTurnItemId,
} from "@t3tools/client-runtime/state/thread-workflows";
import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime";
import {
type OrchestrationMessageContext,
Expand Down Expand Up @@ -174,6 +177,7 @@ import {
} from "../../lib/markdownLinks";
import {
failedFeedRunIds,
terminalFeedAssistantMessageIds,
deriveThreadFeedPresentation,
threadFeedRunIsUnsettled,
isContextCompactionActivityGroup,
Expand Down Expand Up @@ -217,6 +221,8 @@ import { useThreadSelection } from "../../state/use-thread-selection";
import { composerDocumentAttachmentRecord } from "../../lib/composerContext";
import * as Option from "effect/Option";
import { appAtomRegistry } from "../../state/atom-registry";
import { useAtomValue } from "@effect/atom-react";
import { serverEnvironment } from "../../state/server";
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
import { ThreadWorkflowOpening, useWorkflowOpening } from "./thread-workflow-opening";
import { useAtomCommand } from "../../state/use-atom-command";
Expand Down Expand Up @@ -361,8 +367,10 @@ function UserMessageCopyButton(props: {

function AssistantForkButton(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly iconColor: ColorValue;
readonly projectedItem: OrchestrationV2ProjectedTurnItem;
readonly midRun: boolean;
readonly sourceTitle: string;
}) {
const support = useV2ItemSupport({
Expand All @@ -373,10 +381,23 @@ function AssistantForkButton(props: {
const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response");
const navigation = useNavigation();
const [busy, setBusy] = useState(false);
const canFork = canForkProjectedAssistantItem({
const serverConfig = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
const turnItemId = forkCutTurnItemId({
projectedItem: props.projectedItem,
capabilities: support.providerSession?.capabilities,
midRun: props.midRun,
threadId: props.threadId,
readForkedFrom: (threadId) =>
appAtomRegistry.get(
environmentThreadShells.threadShellAtom(scopeThreadRef(props.environmentId, threadId)),
)?.forkedFrom,
});
const supportsItemFork = serverConfig?.environment.capabilities.threadForkFromItem === true;
const canFork =
(turnItemId === undefined || supportsItemFork) &&
canForkProjectedAssistantItem({
projectedItem: props.projectedItem,
capabilities: support.providerSession?.capabilities,
});
const runId = props.projectedItem.item.runId;

if (!canFork || runId === null) return null;
Expand All @@ -396,6 +417,7 @@ function AssistantForkButton(props: {
sourceThreadId: props.projectedItem.sourceThreadId,
targetThreadId,
runId,
...(turnItemId === undefined ? {} : { turnItemId }),
title: `${props.sourceTitle} fork`,
creationSource: "mobile",
},
Expand Down Expand Up @@ -983,7 +1005,7 @@ function renderFeedEntry(
readonly expandedWorkRows: Record<string, boolean>;
readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
readonly workGroupScrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
readonly terminalAssistantMessageIds: ReadonlySet<string>;
readonly terminalAssistantMessageIds: ReturnType<typeof terminalFeedAssistantMessageIds>;
readonly unsettledTurnId: RunId | null;
readonly failedRunIds: ReadonlySet<RunId>;
readonly onCopyWorkRow: (rowId: string, value: string) => void;
Expand Down Expand Up @@ -1152,7 +1174,7 @@ function renderFeedEntry(
message.runId === props.unsettledTurnId;
const showAssistantMeta =
message.role === "assistant" &&
props.terminalAssistantMessageIds.has(message.id) &&
props.terminalAssistantMessageIds.terminalIds.has(message.id) &&
!assistantTurnStillInProgress &&
!message.streaming;

Expand Down Expand Up @@ -1424,8 +1446,10 @@ function renderFeedEntry(
{message.projectedItem ? (
<AssistantForkButton
environmentId={props.environmentId}
threadId={props.threadId}
iconColor={iconSubtleColor}
projectedItem={message.projectedItem}
midRun={props.terminalAssistantMessageIds.midRunIds.has(message.id)}
sourceTitle={props.threadTitle}
/>
) : null}
Expand Down Expand Up @@ -2123,15 +2147,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
{ anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
);
const failedRunIds = failedFeedRunIds(props.feed, props.latestRun);
const terminalAssistantMessageIds = (() => {
const terminalIdsByTurn = new Map<RunId, string>();
for (const entry of props.feed) {
if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) {
terminalIdsByTurn.set(entry.message.runId, entry.message.id);
}
}
return new Set(terminalIdsByTurn.values());
})();
const terminalAssistantMessageIds = terminalFeedAssistantMessageIds(props.feed);
useEffect(() => {
const previous = previousLatestTurnRef.current;
previousLatestTurnRef.current = props.latestRun;
Expand Down
31 changes: 31 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-mes
import { summarizeToolGroup } from "@t3tools/client-runtime/work-log/presentation";
import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";
import * as threadActivity from "./threadActivity";

import {
workEntryRowLabel,
Expand Down Expand Up @@ -2908,3 +2909,33 @@ describe("MCP apps", () => {
expect(feed("running").some((candidate) => candidate.type === "mcp-app")).toBe(false);
});
});

it.each([false, true])(
"offers the last response before a steer as an item cut (later reply: %s)",
(hasLaterReply) => {
const response = (id: string, ordinal: number) => ({
...assistantMessage(),
id: TurnItemId.make(id),
messageId: MessageId.make(id),
ordinal,
});
const feed = buildThreadFeed(
[
userMessage(),
response("commentary", 1),
response("cut", 2),
{
...userMessage("2026-06-20T00:00:04.000Z"),
id: TurnItemId.make("steer"),
messageId: MessageId.make("steer"),
inputIntent: "steer" as const,
ordinal: 3,
},
...(hasLaterReply ? [response("reply", 4)] : []),
].map((item, position) => projected(item, position)),
);
const { terminalIds, midRunIds } = threadActivity.terminalFeedAssistantMessageIds(feed);
expect([...terminalIds]).toEqual(hasLaterReply ? ["cut", "reply"] : ["cut"]);
expect([...midRunIds]).toEqual(["cut"]);
},
);
27 changes: 27 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,33 @@ export function failedFeedRunIds(
return failed;
}

// A steer ends a response even when no assistant reply follows it.
export function terminalFeedAssistantMessageIds(feed: ReadonlyArray<ThreadFeedEntry>) {
const lastBySegment = new Map<
string,
{ readonly runId: RunId; readonly segment: number; readonly id: string }
>();
const segmentByRun = new Map<RunId, number>();
for (const entry of feed) {
if (entry.type !== "message" || !entry.message.runId) continue;
const runId = entry.message.runId;
const segment = segmentByRun.get(runId) ?? 0;
if (entry.message.role === "user") {
segmentByRun.set(runId, segment + 1);
continue;
}
lastBySegment.set(`${runId}:${segment}`, { runId, segment, id: entry.message.id });
}
const terminals = [...lastBySegment.values()];
const terminalIds = new Set(terminals.map((terminal) => terminal.id));
const midRunIds = new Set(
terminals
.filter((terminal) => terminal.segment < (segmentByRun.get(terminal.runId) ?? 0))
.map((terminal) => terminal.id),
);
return { terminalIds, midRunIds };
}

function deriveThreadFeedRunFolds(
feed: ReadonlyArray<ThreadFeedEntry>,
latestRun: ThreadFeedLatestRun | null,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
type ServerProvider,
type ScopedThreadRef,
type ThreadId,
type TurnItemId,
type RunId,
type RuntimeRequestId,
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
Expand Down Expand Up @@ -5843,6 +5844,7 @@ export default function ChatView(props: ChatViewProps) {
const onForkFromRun = async (input: {
readonly sourceThreadId: ThreadId;
readonly runId: RunId;
readonly turnItemId?: TurnItemId;
}) => {
if (!activeThread || activeEnvironmentUnavailable) return;
const targetThreadId = newThreadId();
Expand All @@ -5853,6 +5855,7 @@ export default function ChatView(props: ChatViewProps) {
sourceThreadId: input.sourceThreadId,
targetThreadId,
runId: input.runId,
...(input.turnItemId === undefined ? {} : { turnItemId: input.turnItemId }),
title: `${activeThread.title} fork`,
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,7 @@ describe("MessagesTimeline messages", () => {
}),
} as Entry,
],
{ onForkFromRun },
{ onForkFromRun, routeThreadKey: "environment-local:thread-1" },
);
await click(button("Fork from this response"));
expect(onForkFromRun).toHaveBeenCalledWith({ sourceThreadId: "thread-1", runId: "run-1" });
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5699,3 +5699,62 @@ describe("timeline end and minimap geometry", () => {
}
});
});

it.each([false, true])(
"keeps the last response before a steer forkable (later reply: %s)",
(hasLaterReply) => {
const runId = RunId.make("steered-response-run");
const timelineEntries: TimelineEntry[] = (
[
{ id: "prompt", role: "user", inputIntent: "turn_start" },
{ id: "commentary", role: "assistant" },
{ id: "cut", role: "assistant" },
{ id: "steer", role: "user", inputIntent: "steer" },
...(hasLaterReply ? [{ id: "reply", role: "assistant" } as const] : []),
] as const
).map((entry, index) => {
const { id, role } = entry;
const inputIntent = "inputIntent" in entry ? entry.inputIntent : undefined;
const createdAt = `2026-10-01T00:00:0${index}Z`;
return {
id,
kind: "message",
createdAt,
message: {
id: MessageId.make(id),
runId,
role,
text: id,
...(inputIntent === undefined ? {} : { inputIntent }),
createdAt,
updatedAt: createdAt,
streaming: false,
},
};
});
const rows = deriveMessagesTimelineRows({
timelineEntries,
expandedRunIds: new Set([runId]),
isWorking: false,
turnDiffSummaries: [],
supportsConversationRollback: false,
});
const responses = rows.filter(
(row) => row.kind === "message" && row.message.role === "assistant",
);
expect(responses).toMatchObject([
{ id: "commentary", showAssistantMeta: false, showAssistantCopyButton: false },
{ id: "cut", showAssistantMeta: true, showAssistantCopyButton: true, assistantMidRun: true },
...(hasLaterReply
? [
{
id: "reply",
showAssistantMeta: true,
showAssistantCopyButton: true,
assistantMidRun: false,
},
]
: []),
]);
},
);
Loading
Loading