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/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
8 changes: 8 additions & 0 deletions apps/server/src/actionResume/ActionResume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,14 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up
"qa",
);
assert.equal(running.outcome, "running");
assert.equal(running.command, "vp test run");
const launchedActivity = dispatched.findLast(
(command) => command.type === "thread.activity.append",
);
assert.equal(launchedActivity?.type, "thread.activity.append");
if (launchedActivity?.type === "thread.activity.append") {
assert.deepInclude(launchedActivity.activity.payload, { command: "vp test run" });
}
assert.equal(opened.length, 2);
assert.equal(written.length, 2);
assert.isBelow(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/actionResume/ActionResume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ const make = Effect.gen(function* () {
projectId: project.id,
actionId: script.id,
actionName: script.name,
command: script.command,
terminalId,
outcome: "running",
delivery: "armed",
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4759,6 +4759,42 @@ 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,
},
[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 +7137,7 @@ function ChatViewContent(props: ChatViewProps) {
activeProposedPlan={activeProposedPlan}
activeTasksProgress={activeComposerTasksProgress}
activeTaskSteps={activeComposerTaskSteps}
activeResumableAction={activeComposerResumableAction}
runtimeMode={runtimeMode}
interactionMode={interactionMode}
lockedProvider={lockedProvider}
Expand All @@ -7123,6 +7160,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