Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
checkmark: IconCheck,
"checkmark.circle": IconCircleCheck,
clock: IconClock,
"clock.arrow.circlepath": IconRefresh,
cube: IconBox,
"chevron.down": IconChevronDown,
"chevron.left": IconChevronLeft,
Expand Down
22 changes: 20 additions & 2 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";
import type { HomeGroupDisplayAction } from "../home/homeListItems";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import { resolveThreadStatus } from "./threadPresentation";
import { resolveThreadStatus, shouldShowActionWaitingIndicator } from "./threadPresentation";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";

/**
Expand Down Expand Up @@ -480,7 +480,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
const timestamp = relativeTime(
thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
);
const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title;
const showActionWaitingIndicator = shouldShowActionWaitingIndicator(thread, status?.kind ?? null);
const threadAccessibilityLabel = [
thread.title,
showActionWaitingIndicator && runningAction ? `Waiting for ${runningAction.actionName}` : null,
pr?.accessibilityLabel ?? null,
]
.filter((part): part is string => part !== null)
.join(", ");
const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string =>
Boolean(part),
);
Expand Down Expand Up @@ -616,6 +623,15 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
</Text>
</View>
) : null;
const actionStatusIndicator =
showActionWaitingIndicator && runningAction ? (
<View
accessibilityLabel={`Waiting for ${runningAction.actionName}`}
className="size-3 items-center justify-center"
>
<View className="size-1.5 rounded-full bg-yellow-500 dark:bg-yellow-300" />
</View>
) : null;

const subtitleRow =
subtitleParts.length > 0 || pr !== null ? (
Expand Down Expand Up @@ -697,6 +713,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
{thread.title}
</Text>
<View className="flex-row items-center gap-2">
{actionStatusIndicator}
{statusPill}
<Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text>
<SymbolView
Expand Down Expand Up @@ -762,6 +779,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
{thread.title}
</Text>
<View className="flex-row items-center gap-2">
{actionStatusIndicator}
{statusPill}
<Text
className={cn(
Expand Down
48 changes: 35 additions & 13 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import { useThreadPr } from "../../state/use-thread-pr";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import { resolveWorktreeCleanupStatus } from "./threadPresentation";
import {
resolveWorktreeCleanupStatus,
shouldShowActionWaitingIndicator,
} from "./threadPresentation";
import {
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand Down Expand Up @@ -443,16 +446,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const drawerColor = useThemeColor("--color-drawer");
const pressedBackgroundColor = useThemeColor("--color-subtle");
const selectedBackgroundColor = useThemeColor("--color-user-bubble");
const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground");
const pinTintColor = useThemeColor("--color-foreground-muted");
const sidebarPane = props.pane === "sidebar";
const selected = props.selected === true;

const status = resolveThreadListV2Status(thread);
const showActionWaitingIndicator = shouldShowActionWaitingIndicator(thread, status);
const cleanupStatus = resolveWorktreeCleanupStatus(thread);
const statusLabel = cleanupStatus
? { label: cleanupStatus.label, className: cleanupStatus.textClassName }
: STATUS_LABEL_BY_STATUS[status];
const timeLabel = threadTimeLabel(thread);
const threadAccessibilityLabel = [
thread.title,
showActionWaitingIndicator && runningAction ? `Waiting for ${runningAction.actionName}` : null,
]
.filter((part): part is string => part !== null)
.join(", ");

const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
const handleRegenerateTitle = useCallback(
Expand Down Expand Up @@ -814,16 +825,27 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
{pinnedRow ? (
<SymbolView name="pin" size={11} tintColor={pinTintColor} type="monochrome" />
) : null}
<Text
className={cn(
"text-xs tabular-nums",
selected
? "text-user-bubble-foreground"
: (statusLabel?.className ?? "text-foreground-tertiary"),
)}
>
{statusLabel?.label ?? timeLabel}
</Text>
<View className="flex-row items-center gap-1">
{showActionWaitingIndicator && runningAction ? (
<SymbolView
accessibilityLabel={`Waiting for ${runningAction.actionName}`}
name="clock.arrow.circlepath"
size={12}
tintColor={selected ? String(selectedForegroundColor) : "#eab308"}
type="monochrome"
/>
) : null}
<Text
className={cn(
"text-xs tabular-nums",
selected
? "text-user-bubble-foreground"
: (statusLabel?.className ?? "text-foreground-tertiary"),
)}
>
{statusLabel?.label ?? timeLabel}
</Text>
</View>
</View>
<Text
className={cn(
Expand Down Expand Up @@ -915,7 +937,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
variant === "card" ? (
<Pressable
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={thread.title}
accessibilityLabel={threadAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ disabled: cleanupPending, selected }}
disabled={cleanupPending}
Expand Down Expand Up @@ -956,7 +978,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
) : (
<Pressable
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={thread.title}
accessibilityLabel={threadAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ disabled: cleanupPending, selected }}
disabled={cleanupPending}
Expand Down
32 changes: 31 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import {
resolveThreadListV2SwipeActions,
sortThreadsForListV2,
} from "./threadListV2";
import { resolveThreadStatus, resolveWorktreeCleanupStatus } from "./threadPresentation";
import {
resolveThreadStatus,
resolveWorktreeCleanupStatus,
shouldShowActionWaitingIndicator,
} from "./threadPresentation";

const environmentId = EnvironmentId.make("environment-1");

Expand Down Expand Up @@ -201,6 +205,7 @@ describe("resolveThreadListV2Status", () => {
const waiting = makeThread({ id: ThreadId.make("waiting"), title: "Waiting", actionResume });
expect(resolveThreadListV2Status(waiting)).toBe("waiting");
expect(resolveThreadStatus(waiting)?.kind).toBe("waiting");
expect(shouldShowActionWaitingIndicator(waiting, "waiting")).toBe(false);

expect(
resolveThreadListV2Status({
Expand All @@ -210,6 +215,31 @@ describe("resolveThreadListV2Status", () => {
).toBe("approval");
});

it("keeps a secondary Action indicator while the primary status is Working", () => {
const actionResume = { outcome: "running" } as NonNullable<
EnvironmentThreadShell["actionResume"]
>;
const working = makeThread({
id: ThreadId.make("working-action"),
title: "Working with Action",
actionResume,
session: {
threadId: ThreadId.make("working-action"),
status: "running",
providerName: "Codex",
providerInstanceId: ProviderInstanceId.make("codex"),
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
updatedAt: NOW,
},
});

expect(resolveThreadListV2Status(working)).toBe("working");
expect(resolveThreadStatus(working)?.kind).toBe("working");
expect(shouldShowActionWaitingIndicator(working, "working")).toBe(true);
});

it("resolves ready for quiescent threads", () => {
expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
"ready",
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/features/threads/threadPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export const THREAD_STATUS_NEUTRAL_ICON = {
iconBackground: "rgba(142,142,147,0.22)",
} as const;

export function shouldShowActionWaitingIndicator(
thread: Pick<EnvironmentThreadShell, "actionResume">,
primaryStatus: string | null,
): boolean {
return thread.actionResume?.outcome === "running" && primaryStatus !== "waiting";
}

function isLatestTurnSettled(
latestTurn: OrchestrationLatestTurn | null,
session: OrchestrationSession | null,
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4759,6 +4759,45 @@ function ChatViewContent(props: ChatViewProps) {
]);
const [isResumingInterruptedAction, setIsResumingInterruptedAction] = useState(false);
const [isDiscardingInterruptedAction, setIsDiscardingInterruptedAction] = useState(false);
const runningResumableAction =
activeThreadShell?.actionResume?.outcome === "running" ? activeThreadShell.actionResume : null;
const activeComposerResumableAction = useMemo(
() =>
runningResumableAction === null
? null
: {
action: runningResumableAction,
command:
activeProject?.scripts.find((script) => script.id === runningResumableAction.actionId)
?.command ?? null,
Comment thread
lastobelus marked this conversation as resolved.
Outdated
},
[activeProject?.scripts, runningResumableAction],
);
const handleOpenResumableActionTerminal = useCallback(() => {
if (activeThreadRef === null || runningResumableAction === null) return;
storeEnsureTerminal(activeThreadRef, runningResumableAction.terminalId, {
open: true,
active: true,
});
setTerminalFocusRequestId((value) => value + 1);
}, [activeThreadRef, runningResumableAction, storeEnsureTerminal]);
const handleCancelResumableAction = useCallback(async () => {
if (activeThreadRef === null || runningResumableAction === null) return;
const result = await closeTerminalMutation({
environmentId: activeThreadRef.environmentId,
input: {
threadId: activeThreadRef.threadId,
terminalId: runningResumableAction.terminalId,
},
});
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
setThreadError(
activeThreadRef.threadId,
error instanceof Error ? error.message : "Failed to cancel the running Action.",
);
}
}, [activeThreadRef, closeTerminalMutation, runningResumableAction, setThreadError]);
const interruptedAction =
activeThreadShell?.actionResume?.delivery === "available"
? activeThreadShell.actionResume
Expand Down Expand Up @@ -7101,6 +7140,7 @@ function ChatViewContent(props: ChatViewProps) {
activeProposedPlan={activeProposedPlan}
activeTasksProgress={activeComposerTasksProgress}
activeTaskSteps={activeComposerTaskSteps}
activeResumableAction={activeComposerResumableAction}
runtimeMode={runtimeMode}
interactionMode={interactionMode}
lockedProvider={lockedProvider}
Expand All @@ -7123,6 +7163,8 @@ function ChatViewContent(props: ChatViewProps) {
onSend={onSend}
onInterrupt={onInterrupt}
onImplementPlanInNewThread={onImplementPlanInNewThread}
onOpenResumableActionTerminal={handleOpenResumableActionTerminal}
onCancelResumableAction={handleCancelResumableAction}
onRespondToApproval={onRespondToApproval}
onSelectActivePendingUserInputOption={
onSelectActivePendingUserInputOption
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,28 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
</TooltipPopup>
</Tooltip>
)}
{thread.actionResume?.outcome === "running" && threadStatus?.label !== "Waiting" ? (
<Tooltip>
<TooltipTrigger
render={
<span
aria-label={`Waiting for ${thread.actionResume.actionName}`}
className="inline-flex size-3.5 shrink-0 items-center justify-center text-yellow-700 dark:text-yellow-300"
/>
}
>
<span
data-legacy-sidebar-unscaled-content
className={
props.compactStatusIndicators
? "size-[9px] rounded-full bg-yellow-500 dark:bg-yellow-300"
: "size-1.5 rounded-full bg-yellow-500 dark:bg-yellow-300"
}
/>
</TooltipTrigger>
<TooltipPopup side="top">Waiting for {thread.actionResume.actionName}</TooltipPopup>
</Tooltip>
) : null}
{threadStatus && (
<ThreadStatusLabel status={threadStatus} compact={props.compactStatusIndicators} />
)}
Expand Down
23 changes: 19 additions & 4 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
Undo2Icon,
XIcon,
} from "lucide-react";
import { RotateCcwClockIcon } from "./icons/RotateCcwClockIcon";
import {
memo,
useCallback,
Expand Down Expand Up @@ -1461,10 +1462,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/>
}
>
<span
aria-hidden
className="size-2 shrink-0 rounded-full bg-yellow-500 dark:bg-yellow-300"
/>
<RotateCcwClockIcon aria-hidden className="size-4 shrink-0" />
<span role="status">Waiting</span>
</PopoverTrigger>
<PopoverPopup
Expand Down Expand Up @@ -1522,6 +1520,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
topStatus.className,
)}
>
{actionResume?.outcome === "running" && status !== "waiting" ? (
Comment thread
lastobelus marked this conversation as resolved.
<Tooltip>
<TooltipTrigger
render={
<span
aria-label={`Waiting for ${actionResume.actionName}`}
className="inline-flex shrink-0 items-center text-yellow-700 dark:text-yellow-300"
/>
}
>
<RotateCcwClockIcon aria-hidden className="size-4 shrink-0" />
</TooltipTrigger>
<TooltipPopup side="top">
Waiting for {actionResume.actionName}
</TooltipPopup>
</Tooltip>
) : null}
{topStatus.icon === "working" || topStatus.icon === "cleanup" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "waiting" ? (
Expand Down
Loading