Skip to content
Closed
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
68 changes: 53 additions & 15 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ import {
type ThreadListV2Status,
} from "./threadListV2";
import { QueuedMessageIcon } from "./queued-message-icon";
import { shouldRecedeThreadRow } from "./thread-row-emphasis";
import {
hasThreadRowAsyncQuestion,
shouldRecedeThreadRow,
threadRowAccessibilityLabel,
threadRowStatusText,
} from "./thread-row-emphasis";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs";
Expand All @@ -88,19 +93,20 @@ interface StatusLabel {
readonly className: string;
readonly iconTintClassName: string;
}
const INPUT_STATUS_LABEL: StatusLabel = {
label: "Input",
icon: "questionmark.bubble",
className: "text-adaptive-indigo-600-300",
iconTintClassName: "accent-adaptive-indigo-600-300",
};
const STATUS_LABEL_BY_STATUS: Partial<Record<ThreadListV2Status, StatusLabel>> = {
approval: {
label: "Approval",
icon: "exclamationmark.shield",
className: "text-warning-foreground",
iconTintClassName: "accent-warning-foreground",
},
input: {
label: "Input",
icon: "questionmark.bubble",
className: "text-adaptive-indigo-600-300",
iconTintClassName: "accent-adaptive-indigo-600-300",
},
input: INPUT_STATUS_LABEL,
request: {
label: "Request",
icon: "person.crop.circle",
Expand Down Expand Up @@ -687,14 +693,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected);

const status = resolveThreadListV2Status(thread);
const hasAsyncQuestion = hasThreadRowAsyncQuestion({
status,
hasPendingAsyncQuestion: thread.hasPendingAsyncQuestion,
});
const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
const workingLabel = STATUS_LABEL_BY_STATUS[status];
const showsWoke = status === "ready" && props.wokeAt !== null;
const statusLabel =
(status === "working" && workingLabel !== undefined && thread.goal?.status === "active"
? { ...workingLabel, label: "Goal" }
: workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined);
const recede = shouldRecedeThreadRow({ status, selected });
const recede = shouldRecedeThreadRow({ status, selected, hasAsyncQuestion });
const timeLabel = props.timeLabel;

const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
Expand Down Expand Up @@ -1125,6 +1135,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
</Pressable>
) : statusLabel ? (
<View className="flex-row items-center gap-1">
{hasAsyncQuestion ? (
<>
<SymbolView
name={INPUT_STATUS_LABEL.icon}
size={13}
tintColorClassName={
selected
? selectedThreadRowColors.iconTintClassName
: INPUT_STATUS_LABEL.iconTintClassName
}
type="monochrome"
weight="semibold"
/>
<Text
className={cn(
"me-1.5 text-xs font-t3-bold",
selected
? selectedThreadRowColors.foregroundClassName
: INPUT_STATUS_LABEL.className,
)}
>
{INPUT_STATUS_LABEL.label}
</Text>
</>
) : null}
<SymbolView
name={statusLabel.icon}
size={13}
Expand All @@ -1143,7 +1178,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
selected ? selectedThreadRowColors.foregroundClassName : statusLabel.className,
)}
>
{statusLabel.label}
{threadRowStatusText({ hasAsyncQuestion, label: statusLabel.label })}
{status === "working" ? (
<ThreadListV2Duration startedAt={resolveThreadWorkingStartedAt(thread)} />
) : status === "waiting" ? (
Expand Down Expand Up @@ -1291,6 +1326,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
cardContent
);

const rowAccessibilityLabel = threadRowAccessibilityLabel({
title: thread.title,
hasAsyncQuestion,
statusLabel: statusLabel?.label ?? null,
hasQueuedMessages: props.hasQueuedMessages === true,
});

const rowContent = (close: () => void) =>
variant === "card" ? (
<RowPressable
Expand All @@ -1299,9 +1341,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
interactionOpacity={rowAppearance.interactionOpacity}
className={rowAppearance.className}
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={
props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
}
accessibilityLabel={rowAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityActions={rowAccessibilityActions}
Expand Down Expand Up @@ -1332,9 +1372,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
interactionClassName={rowAppearance.interactionClassName}
interactionOpacity={rowAppearance.interactionOpacity}
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={
props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
}
accessibilityLabel={rowAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ selected }}
className={rowAppearance.className}
Expand Down
49 changes: 49 additions & 0 deletions apps/mobile/src/features/threads/thread-row-emphasis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vite-plus/test";

import {
hasThreadRowAsyncQuestion,
shouldRecedeThreadRow,
threadRowAccessibilityLabel,
threadRowStatusText,
} from "./thread-row-emphasis";

describe("thread row with an open async question", () => {
it("shows Input only while the run keeps working", () => {
expect(hasThreadRowAsyncQuestion({ status: "working", hasPendingAsyncQuestion: true })).toBe(
true,
);
expect(hasThreadRowAsyncQuestion({ status: "working", hasPendingAsyncQuestion: false })).toBe(
false,
);
expect(hasThreadRowAsyncQuestion({ status: "working" })).toBe(false);
expect(hasThreadRowAsyncQuestion({ status: "input", hasPendingAsyncQuestion: true })).toBe(
false,
);
});

it("keeps full emphasis instead of receding like other working rows", () => {
expect(shouldRecedeThreadRow({ status: "working", selected: false })).toBe(true);
expect(
shouldRecedeThreadRow({ status: "working", selected: false, hasAsyncQuestion: true }),
).toBe(false);
});

it("leaves Working unsaid beside Input but keeps Goal", () => {
expect(threadRowStatusText({ hasAsyncQuestion: true, label: "Working" })).toBeNull();
expect(threadRowStatusText({ hasAsyncQuestion: true, label: "Goal" })).toBe("Goal");
expect(threadRowStatusText({ hasAsyncQuestion: false, label: "Working" })).toBe("Working");
});

it("announces the question with the title and queued messages", () => {
const label = (statusLabel: string | null, hasAsyncQuestion = true) =>
threadRowAccessibilityLabel({
title: "Refactor auth",
hasAsyncQuestion,
statusLabel,
hasQueuedMessages: true,
});
expect(label("Working")).toBe("Refactor auth, Input, still working, messages queued to send");
expect(label("Goal")).toBe("Refactor auth, Input, Goal, messages queued to send");
expect(label("Working", false)).toBe("Refactor auth, messages queued to send");
});
});
43 changes: 42 additions & 1 deletion apps/mobile/src/features/threads/thread-row-emphasis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,47 @@ import type { ThreadListV2Status } from "./threadListV2";
export function shouldRecedeThreadRow(input: {
readonly status: ThreadListV2Status;
readonly selected: boolean;
/** An open async question keeps the row at full emphasis while its run works. */
readonly hasAsyncQuestion?: boolean;
}): boolean {
return !input.selected && (input.status === "working" || input.status === "waiting");
return (
!input.selected &&
input.hasAsyncQuestion !== true &&
(input.status === "working" || input.status === "waiting")
);
}

/** Whether the row shows the Input marker for an async question its run keeps working through. */
export function hasThreadRowAsyncQuestion(input: {
readonly status: ThreadListV2Status;
readonly hasPendingAsyncQuestion?: boolean | undefined;
}): boolean {
return input.status === "working" && input.hasPendingAsyncQuestion === true;
}

/** Beside Input the spinner and timer already say Working; any other label, such as Goal, stays. */
export function threadRowStatusText(input: {
readonly hasAsyncQuestion: boolean;
readonly label: string;
}): string | null {
return input.hasAsyncQuestion && input.label === "Working" ? null : input.label;
}

export function threadRowAccessibilityLabel(input: {
readonly title: string;
readonly hasAsyncQuestion: boolean;
readonly statusLabel: string | null;
readonly hasQueuedMessages: boolean;
}): string {
const statusText =
input.statusLabel === null
? null
: threadRowStatusText({ hasAsyncQuestion: input.hasAsyncQuestion, label: input.statusLabel });
return [
input.title,
input.hasAsyncQuestion ? `Input, ${statusText ?? "still working"}` : null,
input.hasQueuedMessages ? "messages queued to send" : null,
]
.filter(Boolean)
.join(", ");
}
31 changes: 31 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
resolveProjectStatusIndicator,
resolveSidebarSweepKeys,
resolveSidebarThreadStatus,
resolveAsyncQuestionPill,
resolveThreadStatusPill,
resolveWorkingStartedAt,
searchSidebarThreads,
Expand Down Expand Up @@ -458,6 +459,36 @@ describe("resolveThreadStatusPill", () => {
});
});

it("pairs Working with Awaiting Input while an async question waits", () => {
const asking = { ...baseThread, hasPendingAsyncQuestion: true };
const working = resolveThreadStatusPill({ thread: asking });
expect(working).toMatchObject({ label: "Working" });
expect(resolveAsyncQuestionPill(asking, working)).toMatchObject({ label: "Awaiting Input" });

const approval = resolveThreadStatusPill({
thread: { ...asking, pendingSubagentRequest: "approval" },
});
expect(resolveAsyncQuestionPill(asking, approval)).toBeNull();

const connectingThread = {
...asking,
runtime: { ...baseThread.runtime, status: "starting" as const },
};
const connecting = resolveThreadStatusPill({ thread: connectingThread });
expect(connecting).toMatchObject({ label: "Connecting" });
expect(resolveAsyncQuestionPill(connectingThread, connecting)).toMatchObject({
label: "Awaiting Input",
});

const guest = resolveThreadStatusPill({ thread: { ...asking, pendingGuestMessageCount: 1 } });
expect(guest).toMatchObject({ label: "Guest Request" });
expect(resolveAsyncQuestionPill(asking, guest)).toBeNull();

const notAsking = { ...baseThread, hasPendingAsyncQuestion: false };
expect(resolveAsyncQuestionPill(notAsking, working)).toBeNull();
expect(resolveAsyncQuestionPill({}, working)).toBeNull();
});

it("shows waiting for an idle thread with pending background tasks", () => {
expect(
resolveThreadStatusPill({
Expand Down
25 changes: 19 additions & 6 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,24 @@ export function resolveWorkingStartedAt(
return resolveThreadWorkingStartedAt(thread);
}

const AWAITING_INPUT_PILL: ThreadStatusPill = {
label: "Awaiting Input",
colorClass: "text-indigo-600 dark:text-indigo-300/90",
dotClass: "bg-indigo-500 dark:bg-indigo-300/90",
pulse: false,
};

/** Shown beside Working while an async question waits, which the status pill alone would hide. */
export function resolveAsyncQuestionPill(
thread: Pick<SidebarThreadSummary, "hasPendingAsyncQuestion">,
statusPill: ThreadStatusPill | null,
): ThreadStatusPill | null {
return thread.hasPendingAsyncQuestion === true &&
(statusPill?.label === "Working" || statusPill?.label === "Connecting")
? AWAITING_INPUT_PILL
: null;
}

export function resolveThreadStatusPill(input: {
thread: ThreadStatusInput;
}): ThreadStatusPill | null {
Expand All @@ -1085,12 +1103,7 @@ export function resolveThreadStatusPill(input: {
}

if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
return {
label: "Awaiting Input",
colorClass: "text-indigo-600 dark:text-indigo-300/90",
dotClass: "bg-indigo-500 dark:bg-indigo-300/90",
pulse: false,
};
return AWAITING_INPUT_PILL;
}

if ((thread.pendingGuestMessageCount ?? 0) > 0) {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/ThreadStatusIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli
import {
pullRequestMatchesSearchQuery,
resolveThreadLastVisitedAt,
resolveAsyncQuestionPill,
resolveThreadStatusPill,
type ThreadStatusPill,
useRetainedValue,
Expand Down Expand Up @@ -704,6 +705,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
lastVisitedAt,
},
});
const asyncQuestionStatus = resolveAsyncQuestionPill(thread, threadStatus);

const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
const pendingLink =
Expand Down Expand Up @@ -741,6 +743,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
aria-label={`${changeRequestTerminologyForUrl(pendingLink.url).shortLabel} #${pendingLink.number}, status pending`}
/>
) : null}
{asyncQuestionStatus ? <ThreadStatusLabel status={asyncQuestionStatus} /> : null}
{threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}
</span>
);
Expand Down
Loading
Loading