Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
59 changes: 45 additions & 14 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,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 @@ -652,14 +653,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected);

const status = resolveThreadListV2Status(thread);
const hasAsyncQuestion = status === "working" && thread.hasPendingAsyncQuestion === true;
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 = !hasAsyncQuestion && shouldRecedeThreadRow({ status, selected });
const timeLabel = props.timeLabel;

const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
Expand Down Expand Up @@ -1070,6 +1072,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 @@ -1088,7 +1115,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
selected ? selectedThreadRowColors.foregroundClassName : statusLabel.className,
)}
>
{statusLabel.label}
{hasAsyncQuestion ? null : statusLabel.label}
{status === "working" ? (
<ThreadListV2Duration startedAt={resolveThreadWorkingStartedAt(thread)} />
) : status === "waiting" ? (
Expand Down Expand Up @@ -1236,6 +1263,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
cardContent
);

const rowAccessibilityLabel = [
thread.title,
hasAsyncQuestion ? "Input, still working" : null,
props.hasQueuedMessages ? "messages queued to send" : null,
]
.filter(Boolean)
.join(", ");

const rowContent = (close: () => void) =>
variant === "card" ? (
<RowPressable
Expand All @@ -1244,9 +1279,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 @@ -1277,9 +1310,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
3 changes: 3 additions & 0 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ import {
isTrailingDoubleClick,
resolveProjectStatusIndicator,
resolveThreadLastVisitedAt,
resolveAsyncQuestionPill,
resolveThreadStatusPill,
orderItemsByPreferredIds,
shouldClearThreadSelectionOnMouseDown,
Expand Down Expand Up @@ -459,6 +460,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
lastVisitedAt,
},
});
const asyncQuestionStatus = resolveAsyncQuestionPill(thread, threadStatus);
const linkedPullRequestStatus = useLinkedThreadPullRequest(
thread.environmentId,
thread.linkedPullRequest,
Expand Down Expand Up @@ -736,6 +738,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
<PullRequestGlyph.pullRequest className="size-3" />
</a>
) : null}
{asyncQuestionStatus && <ThreadStatusLabel status={asyncQuestionStatus} />}
{threadStatus && <ThreadStatusLabel status={threadStatus} />}
{canOperateThread && renamingThreadKey === threadKey ? (
<input
Expand Down
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 @@ -17,6 +17,7 @@ import {
resolveSidebarSweepKeys,
resolveSidebarThreadStatus,
resolveSidebarV2TopStatus,
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 @@ -1046,6 +1046,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 @@ -1061,12 +1079,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 @@ -912,6 +913,7 @@ export function ThreadRowLeadingStatus({
lastVisitedAt,
},
});
const asyncQuestionStatus = resolveAsyncQuestionPill(thread, threadStatus);

const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
const pendingLink =
Expand Down Expand Up @@ -949,6 +951,7 @@ export function ThreadRowLeadingStatus({
aria-label={`${changeRequestTerminologyForUrl(pendingLink.url).shortLabel} #${pendingLink.number}, status pending`}
/>
) : null}
{asyncQuestionStatus ? <ThreadStatusLabel status={asyncQuestionStatus} /> : null}
{threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}
</span>
);
Expand Down
56 changes: 40 additions & 16 deletions apps/web/src/components/sidebar/SidebarThreadRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ function JumpHintBadge(props: { label: string }) {
);
}

const INPUT_STATUS = {
label: "Input",
icon: "input",
className: "text-indigo-600 dark:text-indigo-300",
} as const;

function WorkingDuration(props: { startedAt: string | null }) {
const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN;
if (Number.isNaN(startedMs)) return null;
Expand Down Expand Up @@ -683,19 +689,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP

const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
const status = resolveSidebarThreadStatus(thread);
const hasAsyncQuestion = status === "working" && thread.hasPendingAsyncQuestion === true;
const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
const isWoke =
wokeAtDate !== null &&
(lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
thread.settledOverride !== "settled";
const shouldRecede = shouldRecedeSidebarThread({
status,
isUnread,
isWoke,
isActive: props.isActive,
isSelected,
});
const shouldRecede =
!hasAsyncQuestion &&
shouldRecedeSidebarThread({
status,
isUnread,
isWoke,
isActive: props.isActive,
isSelected,
});
const topStatus =
status === "working"
? {
Expand All @@ -716,11 +725,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
className: "text-warning-foreground",
}
: status === "input"
? {
label: "Input",
icon: "input" as const,
className: "text-indigo-600 dark:text-indigo-300",
}
? INPUT_STATUS
: status === "request"
? {
label: "Request",
Expand Down Expand Up @@ -964,7 +969,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP

const accessibility = resolveSidebarRowAccessibility({
title: thread.title,
statusLabel: topStatus?.label ?? null,
statusLabel: hasAsyncQuestion ? "Input, still working" : (topStatus?.label ?? null),
projectDisplayName: props.projectDisplayName,
isActive: props.isActive,
});
Expand All @@ -987,7 +992,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
"truncate",
shouldRecede
? "text-secondary-label redesign:text-foreground/75 redesign:group-hover/sidebar-row:text-foreground redesign:group-focus-visible/sidebar-row:text-foreground"
: isUnread || isWoke || status === "input" || status === "request"
: isUnread ||
isWoke ||
status === "input" ||
status === "request" ||
hasAsyncQuestion
? "text-foreground"
: status === "failed"
? "text-foreground/95"
Expand All @@ -997,7 +1006,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
"truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground",
shouldRecede
? "text-secondary-label/70 redesign:text-secondary-label"
: props.isActive || isWoke || status === "input" || status === "request"
: props.isActive ||
isWoke ||
status === "input" ||
status === "request" ||
hasAsyncQuestion
? "text-foreground"
: isUnread
? "text-muted-foreground"
Expand Down Expand Up @@ -1365,6 +1378,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
topStatus.className,
)}
>
{hasAsyncQuestion ? (
Comment thread
bmjcoding marked this conversation as resolved.
Resolved
<span
className={cn(
"me-1.5 inline-flex items-center gap-1",
INPUT_STATUS.className,
)}
>
<MessageCircleQuestionIcon aria-hidden className="size-4 shrink-0" />
{INPUT_STATUS.label}
</span>
) : null}
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "waiting" ? (
Expand All @@ -1383,7 +1407,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
{/* Not a live region: a collapsed sidebar is inert, so
BackgroundThreadAnnouncer and the open thread's
timeline speak status changes instead. */}
{topStatus.label}
{hasAsyncQuestion ? null : topStatus.label}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
Expand Down
Loading
Loading