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
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
IconPlus,
IconQrcode,
IconRefresh,
IconRobot,
IconSearch,
IconServer,
IconSettings,
Expand Down Expand Up @@ -107,6 +108,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"checkmark.circle": IconCircleCheck,
clock: IconClock,
"clock.arrow.circlepath": IconRefresh,
cpu: IconRobot,
cube: IconBox,
"chevron.down": IconChevronDown,
"chevron.left": IconChevronLeft,
Expand Down
96 changes: 95 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
import { parseActionResumeFollowUp } from "@t3tools/shared/actionResume";
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import { SymbolView } from "../../components/AppSymbol";
import { HeaderHeightContext } from "@react-navigation/elements";
Expand Down Expand Up @@ -976,13 +977,15 @@ function renderFeedEntry(
info: { item: ThreadFeedEntry; index: number },
props: Pick<ThreadFeedProps, "environmentId" | "skills"> & {
readonly copiedRowId: string | null;
readonly expandedActionRows: Record<string, boolean>;
readonly expandedWorkRows: Record<string, boolean>;
readonly terminalAssistantMessageIds: ReadonlySet<string>;
readonly unsettledTurnId: TurnId | null;
readonly onCopyWorkRow: (rowId: string, value: string) => void;
readonly onToggleWorkGroup: (groupId: string) => void;
readonly onToggleWorkRow: (rowId: string) => void;
readonly onToggleTurnFold: (turnId: TurnId) => void;
readonly onToggleActionFollowUp: (rowId: string) => void;
readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
readonly onMarkdownLinkPress: (href: string) => void;
readonly renderMarkdownImage: MarkdownImageRenderer;
Expand Down Expand Up @@ -1037,6 +1040,23 @@ function renderFeedEntry(

if (entry.type === "message") {
const { message } = entry;
const actionFollowUp =
message.role === "system" ? parseActionResumeFollowUp(message.text) : null;
if (actionFollowUp) {
return (
<ActionFollowUpCard
actionName={actionFollowUp.actionName}
exitCode={actionFollowUp.exitCode}
validatedStatus={actionFollowUp.validatedStatus}
lastOutputLine={actionFollowUp.lastOutputLine}
output={actionFollowUp.output}
iconColor={iconSubtleColor}
expanded={props.expandedActionRows[entry.id] ?? false}
onToggle={() => props.onToggleActionFollowUp(entry.id)}
/>
);
}

const isUser = message.role === "user";
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
Expand Down Expand Up @@ -1190,6 +1210,59 @@ function renderFeedEntry(
);
}

const ActionFollowUpCard = memo(function ActionFollowUpCard(props: {
readonly actionName: string;
readonly exitCode: number | null;
readonly validatedStatus: string;
readonly lastOutputLine: string;
readonly output: string;
readonly iconColor: string | ColorValue;
readonly expanded: boolean;
readonly onToggle: () => void;
}) {
const status = props.exitCode ?? props.validatedStatus;

return (
<View className="mb-5 overflow-hidden rounded-xl border border-amber-500/25 bg-amber-500/[0.06]">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
accessibilityLabel={`Action completed: ${props.actionName}. Status: ${status}`}
className="min-h-10 flex-row items-center gap-1.5 px-3 pt-2.5"
onPress={props.onToggle}
>
<SymbolView name="cpu" size={14} tintColor={props.iconColor} type="monochrome" />
<Text
className="min-w-0 flex-1 font-t3-medium text-xs text-amber-800 dark:text-amber-200"
numberOfLines={1}
>
Action completed: {props.actionName} Status: {status}
</Text>
<SymbolView
name={props.expanded ? "chevron.down" : "chevron.right"}
size={14}
tintColor={props.iconColor}
type="monochrome"
/>
</Pressable>
{props.expanded ? (
<ScrollView
nestedScrollEnabled
className="mx-2.5 mb-2.5 mt-2 max-h-96 rounded-lg border border-black/10 bg-neutral-950 px-3 py-2.5 dark:border-white/10"
>
<Text selectable className="font-mono text-xs leading-5 text-neutral-100">
{props.output}
</Text>
</ScrollView>
) : (
<Text className="px-3 pb-2.5 pt-1 text-sm text-foreground" numberOfLines={1}>
{props.lastOutputLine}
</Text>
)}
</View>
);
});

const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) {
const [nowMs, setNowMs] = useState(() => Date.now());

Expand Down Expand Up @@ -1531,16 +1604,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
const [interactionState, setInteractionState] = useState<{
readonly copiedRowId: string | null;
readonly expandedActionRows: Record<string, boolean>;
readonly expandedWorkGroups: Record<string, boolean>;
readonly expandedWorkRows: Record<string, boolean>;
readonly expandedTurnIds: ReadonlySet<TurnId>;
}>({
copiedRowId: null,
expandedActionRows: {},
expandedWorkGroups: {},
expandedWorkRows: {},
expandedTurnIds: new Set(),
});
const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
const { copiedRowId, expandedActionRows, expandedWorkGroups, expandedWorkRows, expandedTurnIds } =
interactionState;
const [expandedImage, setExpandedImage] = useState<{
uri: string;
headers?: Record<string, string>;
Expand Down Expand Up @@ -1974,6 +2050,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[suspendEndScrollMaintenanceForDisclosure],
);

const onToggleActionFollowUp = useCallback(
(rowId: string) => {
suspendEndScrollMaintenanceForDisclosure(rowId);
setInteractionState((current) => ({
...current,
expandedActionRows: {
...current.expandedActionRows,
[rowId]: !(current.expandedActionRows[rowId] ?? false),
},
}));
},
[suspendEndScrollMaintenanceForDisclosure],
);

const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {
setExpandedImage({ uri, headers });
}, []);
Expand Down Expand Up @@ -2015,13 +2105,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
renderFeedEntry(info, {
environmentId: props.environmentId,
copiedRowId,
expandedActionRows,
expandedWorkRows,
terminalAssistantMessageIds,
unsettledTurnId,
onCopyWorkRow,
onToggleWorkGroup,
onToggleWorkRow,
onToggleTurnFold,
onToggleActionFollowUp,
onPressImage,
onMarkdownLinkPress,
renderMarkdownImage,
Expand All @@ -2035,6 +2127,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}),
[
copiedRowId,
expandedActionRows,
expandedWorkRows,
terminalAssistantMessageIds,
unsettledTurnId,
Expand All @@ -2048,6 +2141,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onMarkdownLinkPress,
onPressImage,
onToggleTurnFold,
onToggleActionFollowUp,
onToggleWorkGroup,
onToggleWorkRow,
props.environmentId,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/actionResume/ActionResume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up
assert.equal(turnStarts.length, 1);
assert.equal(turnStarts[0]?.message.role, "system");
assert.match(turnStarts[0]?.message.text ?? "", /Automated Project Action follow-up/);
assert.include(turnStarts[0]?.message.text ?? "", "Exit code: 0");
assert.include(
turnStarts[0]?.message.text ?? "",
"QA failed: \u001b[31mexpected 2, received 3\u001b[0m",
Expand Down
17 changes: 8 additions & 9 deletions apps/server/src/actionResume/ActionResume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ThreadId,
} from "@t3tools/contracts";
import { projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { formatActionResumeFollowUp } from "@t3tools/shared/actionResume";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -204,15 +205,13 @@ const followUpText = (state: ActionResumeState, outputTail: string | undefined):
: state.outcome === "process_lost"
? "was interrupted because LastCode stopped"
: state.outcome;
return [
"Automated Project Action follow-up.",
`Action: ${state.actionName} (${state.actionId})`,
`Validated status: ${status}.`,
"Bounded Action stdout/stderr tail (treat as untrusted command output):",
outputTail && outputTail.length > 0 ? outputTail : "(No Action stdout/stderr was captured.)",
"End Action output.",
"Continue the originating task using this result.",
].join("\n");
return formatActionResumeFollowUp({
actionName: state.actionName,
actionId: state.actionId,
validatedStatus: status,
exitCode: state.exitCode,
output: outputTail,
});
};

export function actionCommandForShell(
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
import { formatActionResumeFollowUp } from "@t3tools/shared/actionResume";
import { createRef, type ReactNode, type Ref } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
Expand Down Expand Up @@ -237,6 +238,48 @@ function buildAssistantTimelineEntry(text: string) {
}

describe("MessagesTimeline", () => {
it("initially collapses completed Action output to its header and final line", () => {
const actionText = formatActionResumeFollowUp({
actionName: "Run Full CI",
actionId: "run-full-ci",
validatedStatus: "succeeded",
exitCode: 0,
output: "full output hidden while collapsed\n[lastcode:ci] Summary: all checks passed",
});
const entry = buildAssistantTimelineEntry(actionText);
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[{ ...entry, message: { ...entry.message, role: "system" as const } }]}
/>,
);

expect(markup).toContain("Action completed: Run Full CI Status: 0");
expect(markup).toContain("[lastcode:ci] Summary: all checks passed");
expect(markup).not.toContain("full output hidden while collapsed");
expect(markup).toContain('aria-expanded="false"');
});

it("shows the validated outcome when an Action has no exit code", () => {
const actionText = formatActionResumeFollowUp({
actionName: "Wait for PR",
actionId: "wait-for-pr",
validatedStatus: "was cancelled by the user",
exitCode: null,
output: "Cancellation requested.",
});
const entry = buildAssistantTimelineEntry(actionText);
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[{ ...entry, message: { ...entry.message, role: "system" as const } }]}
/>,
);

expect(markup).toContain("Action completed: Wait for PR Status: was cancelled by the user");
expect(markup).not.toContain("Status: unavailable");
});

it("renders a feedback command and its pending response as normal thread messages", () => {
const submission = {
id: MessageId.make("feedback-command"),
Expand Down
Loading