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
145 changes: 145 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
import { type LegendListRef } from "@legendapp/list/react-native";
import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
import {
actionResultPresentation,
Expand Down Expand Up @@ -114,8 +115,10 @@ import {
} from "./thread-work-log";
import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
import { useAssetUrl, useAssetUrlState } from "../../state/assets";
import { useThreadShell } from "../../state/entities";
import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
import { resolveThreadStatus } from "./threadPresentation";

const WIDE_MARKDOWN_BLOCK_OPTIONS = {
includeOrderedLists: Platform.OS === "android",
Expand Down Expand Up @@ -980,6 +983,7 @@ function renderFeedEntry(
readonly onToggleActionFollowUp: (rowId: string) => void;
readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
readonly onMarkdownLinkPress: (href: string) => void;
readonly onOpenSourceThread: (sourceThreadId: ThreadId) => void;
readonly renderMarkdownImage: MarkdownImageRenderer;
readonly iconSubtleColor: string | import("react-native").ColorValue;
readonly userBubbleColor: string | import("react-native").ColorValue;
Expand Down Expand Up @@ -1074,6 +1078,25 @@ function renderFeedEntry(
!assistantTurnStillInProgress &&
!message.streaming;

if (isUser && message.sourceThreadId !== undefined) {
return (
<AgentMessageTimelineRow
entry={entry}
environmentId={props.environmentId}
iconSubtleColor={iconSubtleColor}
markdownStyles={markdownStyles.assistant}
maxWidth={props.userBubbleMaxWidth}
onLinkPress={props.onMarkdownLinkPress}
onOpenSourceThread={props.onOpenSourceThread}
onPressImage={props.onPressImage}
renderImage={props.renderMarkdownImage}
reviewCommentColors={props.reviewCommentColors}
skills={props.skills}
sourceThreadId={message.sourceThreadId}
/>
);
}

if (isUser) {
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
Expand Down Expand Up @@ -1206,6 +1229,117 @@ function renderFeedEntry(
);
}

function AgentMessageTimelineRow(props: {
readonly entry: Extract<ThreadFeedEntry, { type: "message" }>;
readonly environmentId: EnvironmentId;
readonly iconSubtleColor: ColorValue;
readonly markdownStyles: MarkdownStyleSet;
readonly maxWidth: number;
readonly onLinkPress: (href: string) => void;
readonly onOpenSourceThread: (sourceThreadId: ThreadId) => void;
readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
readonly renderImage: MarkdownImageRenderer;
readonly reviewCommentColors: ReviewCommentColors;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
readonly sourceThreadId: ThreadId;
}) {
const source = useThreadShell(scopeThreadRef(props.environmentId, props.sourceThreadId));
const resolvedStatus = source ? resolveThreadStatus(source) : null;
const status = source
? (resolvedStatus ?? {
label: "Ready",
iconColor: props.iconSubtleColor,
})
: null;
const sourceTitle = source?.title ?? "Source thread unavailable";
const message = props.entry.message;
const attachments = (message.attachments ?? []).filter(
(attachment) => attachment.type === "image",
);

return (
<Animated.View
className="mb-5 items-start"
{...(isFreshTimestamp(message.createdAt) ? { entering: FadeInUp.duration(220) } : {})}
>
<View
className="min-w-0 gap-2 rounded-[20px] border border-primary bg-secondary px-3.5 py-3"
style={{ maxWidth: props.maxWidth }}
>
<View className="min-w-0 flex-row flex-wrap items-center gap-1.5">
<Text className="font-t3-bold text-2xs tracking-wider text-foreground-muted">
AGENT MESSAGE
</Text>
{status ? (
<View className="flex-row items-center gap-1">
<View
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: status.iconColor }}
/>
<Text className="font-t3-medium text-2xs text-foreground-muted">{status.label}</Text>
</View>
) : null}
{source ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Open source thread: ${source.title}`}
className="min-w-0 flex-row items-center gap-0.5 active:opacity-60"
onPress={() => props.onOpenSourceThread(props.sourceThreadId)}
>
<Text className="shrink font-t3-medium text-2xs text-primary" numberOfLines={1}>
{sourceTitle.toLocaleLowerCase()}
</Text>
<SymbolView
name="arrow.up.right"
size={11}
tintColorClassName="accent-icon-subtle"
type="monochrome"
/>
</Pressable>
) : (
<Text className="shrink font-t3-medium text-2xs text-foreground-muted">
{sourceTitle.toLocaleLowerCase()}
</Text>
)}
<Text className="ml-auto font-t3-medium text-2xs tabular-nums text-foreground-muted">
{formatMessageTime(message.createdAt)}
</Text>
</View>
{message.text.trim().length > 0 ? (
<UserMessageContent
text={message.text}
markdownStyles={props.markdownStyles}
reviewCommentColors={props.reviewCommentColors}
skills={props.skills}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
/>
) : null}
{attachments.map((attachment) => (
<MessageAttachmentImage
key={attachment.id}
environmentId={props.environmentId}
attachmentId={attachment.id}
className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
onPressImage={props.onPressImage}
/>
))}
</View>
{message.text.trim().length > 0 ? (
<View className="mt-1 pl-0.5">
<CopyTextButton
accessibilityLabel="Copy agent message"
text={message.text}
tintColor={props.iconSubtleColor}
buttonSize={28}
iconSize={13}
/>
</View>
) : null}
</Animated.View>
);
}

const ActionFollowUpCard = memo(function ActionFollowUpCard(props: {
readonly actionName: string;
readonly outcome: ActionResultPresentationOutcome;
Expand Down Expand Up @@ -1731,6 +1865,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
},
[props.environmentId, props.threadId, props.workspaceRoot, navigation],
);
const onOpenSourceThread = useCallback(
(sourceThreadId: ThreadId) => {
navigation.navigate("Thread", {
environmentId: String(props.environmentId),
threadId: String(sourceThreadId),
});
},
[navigation, props.environmentId],
);
const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
(image) => {
const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);
Expand Down Expand Up @@ -2167,6 +2310,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onToggleActionFollowUp,
onPressImage,
onMarkdownLinkPress,
onOpenSourceThread,
renderMarkdownImage,
iconSubtleColor,
userBubbleColor,
Expand All @@ -2190,6 +2334,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
userBubbleMaxWidth,
onCopyWorkRow,
onMarkdownLinkPress,
onOpenSourceThread,
onPressImage,
onToggleTurnFold,
onToggleActionFollowUp,
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/cli/thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,7 @@ it.effect("prepares and dispatches an exact accepted send using the target threa
commandId: CommandId.make("command-send"),
messageId: MessageId.make("message-send"),
createdAt: "2026-08-22T00:00:00.000Z",
sourceThreadId: ThreadId.make("thread-source"),
});

assert.deepStrictEqual(result, {
Expand All @@ -643,12 +644,37 @@ it.effect("prepares and dispatches an exact accepted send using the target threa
},
runtimeMode: "approval-required",
interactionMode: "plan",
sourceThreadId: "thread-source",
createdAt: "2026-08-22T00:00:00.000Z",
},
]);
}),
);

it.effect("does not mark a send to the current thread as cross-thread", () =>
Effect.gen(function* () {
const { source } = runnerSource();
const dispatched: unknown[] = [];
yield* sendThreadOutput(
{
descriptor: source.descriptor,
shell: source.shell,
dispatch: (command) => Effect.sync(() => dispatched.push(command)),
},
{
identifier: "thread-runner",
message: "status",
commandId: CommandId.make("command-self-send"),
messageId: MessageId.make("message-self-send"),
createdAt: "2026-08-22T00:00:00.000Z",
sourceThreadId: ThreadId.make("thread-runner"),
},
);

assert.notProperty(dispatched[0] as object, "sourceThreadId");
}),
);

it.effect("marks only explicitly tracked sends for wait correlation", () =>
Effect.gen(function* () {
const { source } = runnerSource();
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/cli/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* (
readonly commandId: CommandId;
readonly messageId: MessageId;
readonly createdAt: string;
readonly sourceThreadId?: ThreadId;
readonly trackRequestCorrelation?: true;
readonly rejectWaitForThreadId?: ThreadId;
},
Expand Down Expand Up @@ -558,6 +559,9 @@ export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* (
},
runtimeMode: resolution.thread.runtimeMode,
interactionMode: resolution.thread.interactionMode,
...(input.sourceThreadId !== undefined && input.sourceThreadId !== resolution.thread.id
? { sourceThreadId: input.sourceThreadId }
: {}),
...(input.trackRequestCorrelation === true ? { trackRequestCorrelation: true } : {}),
createdAt: input.createdAt,
});
Expand Down Expand Up @@ -946,6 +950,9 @@ const runThreadSend = Effect.fn("runThreadSend")(function* (
commandId,
messageId,
createdAt: DateTime.formatIso(yield* DateTime.now),
...(process.env.T3CODE_THREAD_ID?.trim()
? { sourceThreadId: ThreadId.make(process.env.T3CODE_THREAD_ID.trim()) }
: {}),
...(waitForCompletion ? { trackRequestCorrelation: true as const } : {}),
...(waitForCompletion && process.env.T3CODE_THREAD_ID?.trim()
? { rejectWaitForThreadId: ThreadId.make(process.env.T3CODE_THREAD_ID.trim()) }
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
role: event.payload.role,
text: nextText,
...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
...(event.payload.sourceThreadId !== undefined ||
previousMessage?.sourceThreadId !== undefined
? { sourceThreadId: event.payload.sourceThreadId ?? previousMessage?.sourceThreadId }
: {}),
isStreaming: event.payload.streaming,
createdAt: previousMessage?.createdAt ?? event.payload.createdAt,
updatedAt: event.payload.updatedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
turn_id,
role,
text,
source_thread_id,
is_streaming,
created_at,
updated_at
Expand All @@ -140,6 +141,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
'turn-1',
'assistant',
'hello from projection',
'thread-source',
0,
'2026-02-24T00:00:04.000Z',
'2026-02-24T00:00:05.000Z'
Expand Down Expand Up @@ -354,6 +356,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
id: asMessageId("message-1"),
role: "assistant",
text: "hello from projection",
sourceThreadId: ThreadId.make("thread-source"),
turnId: asTurnId("turn-1"),
streaming: false,
createdAt: "2026-02-24T00:00:04.000Z",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
Struct.assign({
isStreaming: Schema.Number,
attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
sourceThreadId: Schema.NullOr(ThreadId),
}),
);
const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
Expand Down Expand Up @@ -560,6 +561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
role,
text,
attachments_json AS "attachments",
source_thread_id AS "sourceThreadId",
is_streaming AS "isStreaming",
created_at AS "createdAt",
updated_at AS "updatedAt"
Expand Down Expand Up @@ -1008,6 +1010,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
role,
text,
attachments_json AS "attachments",
source_thread_id AS "sourceThreadId",
is_streaming AS "isStreaming",
created_at AS "createdAt",
updated_at AS "updatedAt"
Expand Down Expand Up @@ -1255,6 +1258,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
role,
text,
attachments_json AS "attachments",
source_thread_id AS "sourceThreadId",
is_streaming AS "isStreaming",
created_at AS "createdAt",
updated_at AS "updatedAt"
Expand Down Expand Up @@ -1597,6 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
role: row.role,
text: row.text,
...(row.attachments !== null ? { attachments: row.attachments } : {}),
...(row.sourceThreadId !== null ? { sourceThreadId: row.sourceThreadId } : {}),
turnId: row.turnId,
streaming: row.isStreaming === 1,
createdAt: row.createdAt,
Expand Down Expand Up @@ -2699,6 +2704,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
id: row.messageId,
role: row.role,
text: row.text,
...(row.sourceThreadId !== null ? { sourceThreadId: row.sourceThreadId } : {}),
turnId: row.turnId,
streaming: row.isStreaming === 1,
createdAt: row.createdAt,
Expand Down
Loading