Skip to content
Open
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
24 changes: 24 additions & 0 deletions apps/mobile/src/components/GitHubDiscussionIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ColorValue } from "react-native";
import Svg, { Path } from "react-native-svg";
import { withUniwind } from "uniwind";

const ThemedPath = withUniwind(Path);

// GitHub Octicons comment-discussion-16. Copyright (c) 2026 GitHub Inc., MIT.
// https://github.com/primer/octicons/blob/main/LICENSE
export function GitHubDiscussionIcon(props: {
readonly size: number;
readonly color?: ColorValue;
readonly colorClassName?: string;
}) {
return (
<Svg width={props.size} height={props.size} viewBox="0 0 16 16" accessible={false}>
<ThemedPath
d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"
color={props.color}
colorClassName={props.colorClassName}
fill="currentColor"
/>
</Svg>
);
}
110 changes: 110 additions & 0 deletions apps/mobile/src/features/threads/GitHubReplySnooze.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {
githubReplyConversationLabel,
githubReplyConversationsForCondition,
githubReplySnoozeFailureMessage,
type GitHubReplyConversation,
} from "@t3tools/client-runtime/github-reply-conversations";
import type { GitHubReplyWakeCondition } from "@t3tools/contracts";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
import type { MenuAction } from "@react-native-menu/menu";
import { useCallback, useRef, useState } from "react";
import { Alert, Linking, Pressable, View } from "react-native";
import { AppText as Text } from "../../components/AppText";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";

export function githubReplySnoozeMenuAction(
conversations: ReadonlyArray<GitHubReplyConversation>,
wakeCondition?: GitHubReplyWakeCondition,
): MenuAction {
conversations = githubReplyConversationsForCondition(conversations, wakeCondition);
const changesRequested = wakeCondition === "changes-requested";
const prefix = changesRequested ? "snooze:github:changes-requested" : "snooze:github";
const only = conversations.length === 1 ? conversations[0] : undefined;
return {
id: only ? `${prefix}:${only.url}` : prefix,
title: changesRequested ? "Until changes are requested" : "Until a GitHub reply",
...(conversations.length === 0
? {
subtitle: changesRequested
? "Link an open GitHub PR first"
: "Link a GitHub PR or discussion first",
attributes: { disabled: true },
}
: {}),
...(conversations.length > 1
? {
subactions: conversations.map((conversation) => ({
id: `${prefix}:${conversation.url}`,
title: githubReplyConversationLabel(conversation),
})),
}
: {}),
};
}

export function useGitHubReplySnooze(thread: EnvironmentThreadShell) {
const snooze = useAtomCommand(threadEnvironment.snooze);
const [pending, setPending] = useState(false);
const pendingRef = useRef(false);
const snoozeUrl = useCallback(
(url: string, wakeCondition?: GitHubReplyWakeCondition) => {
if (
pendingRef.current ||
(wakeCondition === "changes-requested" &&
appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId)?.environment
.capabilities.threadGitHubChangesRequestedSnooze !== true)
)
return;
pendingRef.current = true;
setPending(true);
void snooze({
environmentId: thread.environmentId,
input: { threadId: thread.id, url, wakeCondition },
}).then((result) => {
pendingRef.current = false;
setPending(false);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
Alert.alert("Could not snooze thread", githubReplySnoozeFailureMessage);
}
});
},
[snooze, thread.environmentId, thread.id],
);
return { pending, snoozeUrl };
}

export function GitHubReplySnoozeCard({ thread }: { readonly thread: EnvironmentThreadShell }) {
const unsnooze = useAtomCommand(threadEnvironment.unsnooze);
const watch = thread.githubReplySnooze;
if (!watch) return null;
return (
<View className="mx-3 mb-2 gap-2 rounded-xl border border-border bg-background p-3">
<Text className="font-t3-semibold">
{watch.wakeCondition === "changes-requested"
? "Waiting for changes to be requested"
: "Waiting for a GitHub reply"}
</Text>
<View className="flex-row items-center gap-6">
<Pressable accessibilityRole="link" onPress={() => void Linking.openURL(watch.url)}>
<Text className="text-primary-text">View</Text>
</Pressable>
<View className="flex-1" />
<Pressable
accessibilityRole="button"
onPress={() =>
void unsnooze({
environmentId: thread.environmentId,
input: { threadId: thread.id, reason: "user" },
})
}
>
<Text>Wake now</Text>
</Pressable>
</View>
</View>
);
}
5 changes: 5 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GitHubReplySnoozeCard } from "./GitHubReplySnooze";
import { useAtomValue } from "@effect/atom-react";
import { useThreadReportedModelSelection } from "../../state/entities";
import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
Expand Down Expand Up @@ -1238,6 +1239,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
/>
</Animated.View>
) : null}
<GitHubReplySnoozeCard
key={`${props.environmentId}:${props.selectedThread.id}`}
thread={props.selectedThread}
/>
<UsageLimitRecoveryCard
key={props.selectedThread.latestRun?.runId}
thread={props.selectedThread}
Expand Down
62 changes: 54 additions & 8 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import { githubReplySnoozeMenuAction, useGitHubReplySnooze } from "./GitHubReplySnooze";
import {
eligibleThreadGitHubConversations,
githubReplySnoozeChoice,
} from "@t3tools/client-runtime/github-reply-conversations";
import { environmentServerConfigsAtom } from "../../state/server";
import type { ThreadRowProviderInstance } from "./thread-provider-instance";
import {
THREAD_LIST_V2_MONO_FONT as MONO_FONT,
Expand All @@ -18,10 +24,14 @@ import type {
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import {
canSnooze,
resolveSnoozePresets,
threadHasUnseenGitHubReply,
} from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Alert, Pressable, useWindowDimensions, View } from "react-native";
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";

import type { ThreadListProvider } from "../../state/thread-list-environments";
Expand Down Expand Up @@ -624,7 +634,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
// "Done" marks a completion the user has not opened yet — same emerald
// label as the web sidebar, sourced from the server-side visited watermark
// so checking a thread on any device clears it everywhere.
const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
const isUnread =
status === "ready" &&
threadHasUnseenCompletion({ latestRun: thread.latestRun, lastVisitedAt: thread.lastVisitedAt });
const showUnreadReplyDot = threadHasUnseenGitHubReply(thread);
const workingLabel = STATUS_LABEL_BY_STATUS[status];
const statusLabel =
// A native /goal keeps the agent going across turns until it is met.
Expand All @@ -645,6 +658,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
);
const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);
const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false);
const { snoozeUrl } = useGitHubReplySnooze(thread);
const githubConversations = useMemo(() => eligibleThreadGitHubConversations(thread), [thread]);
const githubSnoozeSupported =
appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId)?.environment
.capabilities.threadGitHubReplySnooze === true;
const githubChangesRequestedSupported =
appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId)?.environment
.capabilities.threadGitHubChangesRequestedSnooze === true;
// A recycled cell reassigns this mounted row to a different thread without
// remounting it, and the render closure stops running while list equality
// says the item is unchanged — so any row-local UI state must be dismissed
Expand Down Expand Up @@ -698,17 +719,28 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
() => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)),
[props.snoozePresetMinute, swipeActions.secondary],
);
const snoozePresetActions = useMemo<MenuAction[]>(
() => [
const snoozePresetActions = useMemo<MenuAction[]>(() => {
const githubActions = githubSnoozeSupported
? [
githubReplySnoozeMenuAction(githubConversations),
...(githubChangesRequestedSupported
? [githubReplySnoozeMenuAction(githubConversations, "changes-requested")]
: []),
]
: [];
return [
...snoozePresets.map((preset) => ({
id: `snooze:${preset.id}`,
title: preset.label,
subtitle: preset.whenLabel,
})),
// ios draws dividers around inline groups; android keeps its flat menu.
...(Platform.OS === "ios" && githubActions.length > 0
? [{ title: "", displayInline: true, subactions: githubActions }]
: githubActions),
{ id: "snooze:custom", title: "Custom…" },
],
[snoozePresets],
);
];
}, [snoozePresets, githubSnoozeSupported, githubChangesRequestedSupported, githubConversations]);
// Pinned cards keep the full lifecycle menu; only the pin item flips to
// Unpin. (Settling a pinned thread clears the pin server-side; snoozing
// hides the card until wake with the pin intact.)
Expand Down Expand Up @@ -863,6 +895,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
copyTextWithHaptic(thread.id, { target: "thread-id" });
}
if (nativeEvent.event === "delete") handleDelete();
if (nativeEvent.event.startsWith("snooze:github:")) {
const choice = githubReplySnoozeChoice(nativeEvent.event, githubConversations);
if (choice) snoozeUrl(choice.url, choice.wakeCondition);
return;
}
if (nativeEvent.event === "snooze:custom") {
setCustomSnoozeOpen(true);
return;
Expand Down Expand Up @@ -890,6 +927,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handlePin,
handleSettle,
handleSnooze,
githubConversations,
snoozeUrl,
handleSetAutoSettle,
handleUnpin,
handleUnsettle,
Expand Down Expand Up @@ -996,6 +1035,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
type="monochrome"
/>
) : null}
{showUnreadReplyDot ? (
<View
accessible
accessibilityLabel="Unread GitHub activity"
className="h-1.5 w-1.5 rounded-full bg-adaptive-emerald-600-400"
/>
) : null}
{statusLabel ? (
<View className="flex-row items-center gap-1">
<SymbolView
Expand Down
29 changes: 26 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";

import { AppText as Text } from "../../components/AppText";
import { GitHubDiscussionIcon } from "../../components/GitHubDiscussionIcon";
import { T3Wordmark } from "../../components/T3Wordmark";
import { cn } from "../../lib/cn";
import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
Expand Down Expand Up @@ -110,7 +111,13 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code" | "pull-request";
type WorkContentIcon =
| AppSymbolName
| "browser"
| "device"
| "t3-code"
| "pull-request"
| "discussion";

function WorkLogIcon(props: {
readonly icon: WorkContentIcon;
Expand All @@ -119,6 +126,14 @@ function WorkLogIcon(props: {
readonly highlighted?: boolean;
}) {
const colorClassName = props.highlighted ? "accent-foreground" : props.colorClassName;
if (props.icon === "discussion") {
return (
<GitHubDiscussionIcon
size={14}
{...(colorClassName ? { colorClassName } : { color: props.color })}
/>
);
}
if (props.icon === "t3-code") {
return (
<T3Wordmark height={10} {...(colorClassName ? { colorClassName } : { color: props.color })} />
Expand Down Expand Up @@ -1216,7 +1231,13 @@ export function ThreadWorkGroupToggle(props: {
readonly iconSubtleColor: import("react-native").ColorValue;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain";
readonly summaryToolIcon?:
| "browser"
| "device"
| "t3-code"
| "pull-request"
| "discussion"
| "brain";
readonly themeAppearance: "light" | "dark";
readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
readonly toolIcon?: ToolActivityIcon;
Expand Down Expand Up @@ -1549,8 +1570,10 @@ function ToolActivityImage(props: {
);
}

function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): WorkContentIcon {
switch (kind) {
case "discussion":
return "discussion";
case "pull-request":
case "link-pr":
case "unlink-pr":
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,24 @@ describe("resolveThreadListV2Status", () => {
},
);

it.each(["reply", "error"] as const)("marks a %s wake unread without a run", (type) => {
const thread = makeThread({
id: ThreadId.make("reply-wake"),
title: "Reply wake",
latestRun: null,
lastVisitedAt: "2026-06-01T23:59:00.000Z",
githubReplyNotice: {
type,
receivedAt: NOW,
url: "https://github.com/team/repo/pull/1",
text: "Feedback",
},
});
expect(threadHasUnseenCompletion(thread)).toBe(true);
expect(threadHasUnseenCompletion({ ...thread, lastVisitedAt: null })).toBe(true);
expect(threadHasUnseenCompletion({ ...thread, lastVisitedAt: NOW })).toBe(false);
});

it("resolves ready for quiescent threads", () => {
expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
"ready",
Expand Down
Loading
Loading