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
1 change: 1 addition & 0 deletions apps/mobile/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

/* ─── Typography ────────────────────────────────────────────────────── */
@theme {
--shadow-composer-bash: 0 0 32px 4px rgb(0 0 0 / 25%);
/* Keep these native family names aligned with app.config.ts. */
--font-sans: "DMSans-Regular";
--font-medium: "DMSans-Medium";
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/features/threads/BashMessageDisclosure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
composerBashStatusLabel,
parseComposerBashOutput,
type ComposerBashCommand,
} from "@t3tools/client-runtime/composer-bash";
import type { ReactNode } from "react";
import { Pressable, ScrollView, View, type ColorValue } from "react-native";
import Animated, { FadeIn, FadeInUp, FadeOut, ReduceMotion } from "react-native-reanimated";
import { AppText as Text } from "../../components/AppText";
import { cn } from "../../lib/cn";
import { ThreadDisclosureChevron } from "./thread-work-log";

export function BashMessageDisclosure({
command,
expanded,
onToggle,
children,
chevronColor,
}: {
command: ComposerBashCommand | undefined;
expanded: boolean;
onToggle: () => void;
children: ReactNode;
chevronColor: ColorValue;
}) {
if (!command) return children;

return (
<View className="min-w-0 gap-2 rounded-xl">
<Pressable
accessibilityRole="button"
accessibilityLabel={composerBashStatusLabel(command)}
accessibilityState={{ expanded }}
onPress={onToggle}
className="min-h-11 flex-row items-center justify-between gap-2"
>
<Animated.View key={command.status} entering={FadeIn.duration(200)} className="shrink">
<Text
className={cn(
"shrink font-t3-medium text-xs",
command.status === "failed" ? "text-danger-foreground" : "text-foreground-secondary",
)}
>
{composerBashStatusLabel(command)}
</Text>
</Animated.View>
<ThreadDisclosureChevron
expanded={expanded}
collapsedDirection="down"
size={14}
tintColor={chevronColor}
/>
</Pressable>
{children}
{expanded && (
<Animated.View
entering={FadeInUp.duration(220).reduceMotion(ReduceMotion.System)}
exiting={FadeOut.duration(140)}
>
<BashOutput command={command} />
</Animated.View>
)}
</View>
);
}

function BashOutput({ command }: { command: ComposerBashCommand }) {
if (!command.output) {
return (
<Text className="text-xs text-foreground-secondary">
{command.status === "running" ? "Running command…" : "No command output."}
</Text>
);
}
const { exitCode, stdout, stderr } = parseComposerBashOutput(command.output);
return (
<View className="gap-1">
<ScrollView nestedScrollEnabled style={{ maxHeight: 320 }}>
<Text selectable className="font-mono text-xs text-user-bubble-foreground">
{stdout}
{stdout && stderr ? "\n" : null}
{stderr ? (
<Text className="font-mono text-xs text-danger-foreground">{stderr}</Text>
) : null}
{!stdout && !stderr ? "No output" : null}
</Text>
</ScrollView>
{exitCode !== null && (
<Text className="self-end font-mono text-2xs text-foreground-secondary">
exit {exitCode}
</Text>
)}
</View>
);
}
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,7 @@ export function NewTaskDraftScreen(props: {
style={promptEnhancementHaloStyle}
/>
<ComposerSurface
bashMode={flow.prompt.startsWith("!")}
style={{
borderRadius: 26,
minHeight: 140,
Expand Down
27 changes: 26 additions & 1 deletion apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export function ComposerSurface(props: {
readonly style: ViewStyle;
/** Morphs between the compact and expanded composer layouts. */
readonly animateLayout?: boolean;
readonly bashMode?: boolean;
}) {
const colors = useUniwindTheme();
const targetBorderRadius =
Expand All @@ -232,7 +233,11 @@ export function ComposerSurface(props: {
return (
<Animated.View
className={
Platform.OS === "android" ? undefined : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
props.bashMode
? "shadow-composer-bash shadow-danger-foreground/25"
: Platform.OS === "android"
? undefined
: "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
}
layout={layoutTransition}
style={[
Expand Down Expand Up @@ -261,6 +266,25 @@ export function ComposerSurface(props: {
layout={layoutTransition}
style={[props.style, animatedShapeStyle]}
>
{props.bashMode && (
<Animated.View
entering={FadeIn.duration(180)}
exiting={FadeOut.duration(120)}
className="flex-row items-center gap-2 px-3.5 pt-2.5"
>
<View className="rounded-full border border-danger-border bg-danger px-2 py-0.5">
<Text
accessibilityLiveRegion="polite"
className="font-t3-medium text-xs text-danger-foreground"
>
Bash mode
</Text>
</View>
<Text numberOfLines={1} className="shrink text-xs text-foreground-secondary">
Output goes to the agent
</Text>
</Animated.View>
)}
{props.children}
</Animated.View>
</Animated.View>
Expand Down Expand Up @@ -675,6 +699,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
) : null}

<ComposerSurface
bashMode={props.draftMessage.startsWith("!")}
style={
isExpanded
? {
Expand Down
43 changes: 26 additions & 17 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BashMessageDisclosure } from "./BashMessageDisclosure";
import {
WorktreeWorkingHeader,
WorktreeSetupCard,
Expand Down Expand Up @@ -1559,7 +1560,8 @@ function renderFeedEntry(
maxWidth: props.userBubbleMaxWidth,
...(hasReviewCommentContext
? { width: props.reviewCommentBubbleWidth }
: hasWideBlock
: hasWideBlock ||
(entry.bashCommand && props.expandedWorkRows[`bash:${message.id}`])
? { width: props.userBubbleMaxWidth }
Comment on lines +1563 to 1565

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the collapsed-state key when sizing Bash output

In the mobile thread feed, expansion is now tracked under bash-collapsed:${message.id}, but this width condition still checks the obsolete bash:${message.id} key. Consequently, the condition never widens a normally expanded Bash result, so short commands such as !ls can leave multiline output cramped into the command bubble instead of using userBubbleMaxWidth.

Useful? React with 👍 / 👎.

: null),
}}
Expand Down Expand Up @@ -1618,22 +1620,29 @@ function renderFeedEntry(
})}
</View>
) : null}
{message.text.trim().length > 0 ? (
<MarkdownImageAvailableWidthContext
value={props.userBubbleMaxWidth - USER_BUBBLE_HORIZONTAL_PADDING * 2}
>
<UserMessageContent
text={renderedText}
environmentId={props.environmentId}
context={message.context}
markdownStyles={styles}
reviewCommentColors={props.reviewCommentColors}
skills={props.skills}
linkHandlers={props.markdownLinkHandlers}
renderImage={props.renderMarkdownImage}
/>
</MarkdownImageAvailableWidthContext>
) : null}
<BashMessageDisclosure
command={entry.bashCommand}
chevronColor={iconSubtleColor}
expanded={!props.expandedWorkRows[`bash-collapsed:${message.id}`]}
onToggle={() => props.onToggleWorkRow(`bash-collapsed:${message.id}`, entry.id)}
>
{message.text.trim().length > 0 ? (
<MarkdownImageAvailableWidthContext
value={props.userBubbleMaxWidth - USER_BUBBLE_HORIZONTAL_PADDING * 2}
>
<UserMessageContent
text={renderedText}
environmentId={props.environmentId}
context={message.context}
markdownStyles={styles}
reviewCommentColors={props.reviewCommentColors}
skills={props.skills}
linkHandlers={props.markdownLinkHandlers}
renderImage={props.renderMarkdownImage}
/>
</MarkdownImageAvailableWidthContext>
) : null}
</BashMessageDisclosure>
</View>
<View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">
<Text className="font-t3-medium text-xs tabular-nums text-foreground-secondary">
Expand Down
87 changes: 87 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3748,3 +3748,90 @@ it("keeps attachment-only question answers expandable outside mobile work groups
expect(running[1]).toBe(group);
expect(running[2]?.type).toBe("work-toggle");
});

describe("composer Bash message results", () => {
it("refreshes a cached message when its command finishes, preserving ordinary rows", () => {
const createdAt = "2026-09-01T00:00:00.000Z";
const thread = makeThread({
id: ThreadId.make("bash-thread"),
projectId: ProjectId.make("project"),
title: "Bash",
messages: [
{
id: MessageId.make("bash-user"),
role: "user",
text: "!ls",
turnId: null,
createdAt,
updatedAt: createdAt,
streaming: false,
},
{
id: MessageId.make("ordinary-user"),
role: "user",
text: "Explain this",
turnId: null,
createdAt,
updatedAt: createdAt,
streaming: false,
},
{
id: MessageId.make("other-bash-user"),
role: "user",
text: "!pwd",
turnId: null,
createdAt,
updatedAt: createdAt,
streaming: false,
},
],
});
const activity = makeActivity({
id: EventId.make("bash-activity"),
kind: "tool.updated",
summary: "Running Bash command",
tone: "tool",
createdAt,
payload: {
toolCallId: "composer-bash:bash-user",
itemType: "command_execution",
status: "inProgress",
data: { command: "ls" },
},
});
const messageRows = (feed: ThreadFeedEntry[]) =>
feed.filter((entry) => entry.type === "message");
const initial = messageRows(buildThreadFeed(thread));
expect(initial.every((entry) => entry.bashCommand === undefined)).toBe(true);
const running = messageRows(buildThreadFeed({ ...thread, activities: [activity] }));
expect(running[0]?.bashCommand).toEqual({ status: "running", output: null });
for (const status of ["completed", "failed"] as const) {
const detail =
status === "completed" ? "Exit code: 0\nfile.txt" : "Exit code: 1\nBuild failed";
const finishedThread = {
...thread,
activities: [
{
...activity,
kind: "tool.completed" as const,
payload: {
toolCallId: "composer-bash:bash-user",
itemType: "command_execution",
status,
detail,
data: { command: "ls", rawOutput: detail },
},
},
],
};
const finished = messageRows(buildThreadFeed(finishedThread));
expect(finished[0]?.bashCommand).toEqual({ status, output: detail });
expect(finished[0]).not.toBe(running[0]);
expect(messageRows(buildThreadFeed(finishedThread))[0]).toBe(finished[0]);
for (const index of [1, 2]) {
expect(finished[index]).toBe(initial[index]);
expect(finished[index]?.bashCommand).toBeUndefined();
}
}
});
});
35 changes: 32 additions & 3 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
indexComposerBashCommands,
isComposerBashEntry,
type ComposerBashCommand,
} from "@t3tools/client-runtime/composer-bash";
import * as Option from "effect/Option";
import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -139,6 +144,7 @@ type RawThreadFeedEntry =
readonly id: string;
readonly createdAt: string;
readonly message: OrchestrationThread["messages"][number];
readonly bashCommand?: ComposerBashCommand | undefined;
}
| {
readonly type: "activity";
Expand Down Expand Up @@ -574,6 +580,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
const message = asTrimmedString(payload?.message);
if (message) entry.detail = message;
}
// Composer Bash output is shown whole on its message. The client projection
// trims `data.rawOutput` to one line, so read the untrimmed `detail`.
const composerBashOutput = payload?.detail;
if (toolCallId?.startsWith("composer-bash:") && typeof composerBashOutput === "string") {
entry.detail = composerBashOutput;
}
if (viewedImagePath) {
entry.viewedImagePath = viewedImagePath;
}
Expand Down Expand Up @@ -2420,6 +2432,9 @@ export function buildThreadFeed(
(entry) =>
oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt,
);
const bashCommands = indexComposerBashCommands(
activityEntries.map((entry) => entry.activity.workEntry),
);
const foldedAnswerMessageIds = new Set(
activityEntries.flatMap((entry) =>
entry.activity.workEntry.questionAnswer
Expand All @@ -2432,14 +2447,28 @@ export function buildThreadFeed(
...messages
.filter((message) => message.role !== "user" || !foldedAnswerMessageIds.has(message.id))
.map((message) => {
const bashCommand =
message.role === "user" && message.text.startsWith("!")
? bashCommands.get(message.id)
: undefined;
let entry = messageEntriesCache.get(message);
if (!entry) {
entry = { type: "message", id: message.id, createdAt: message.createdAt, message };
if (
!entry ||
entry.bashCommand?.status !== bashCommand?.status ||
entry.bashCommand?.output !== bashCommand?.output
) {
entry = {
type: "message",
id: message.id,
createdAt: message.createdAt,
message,
bashCommand,
};
messageEntriesCache.set(message, entry);
}
return entry;
}),
...activityEntries,
...activityEntries.filter((entry) => !isComposerBashEntry(entry.activity.workEntry)),
],
(s) => new Date(s.createdAt),
Order.Date,
Expand Down
Loading
Loading