diff --git a/apps/mobile/assets/icons/issue.png b/apps/mobile/assets/icons/issue.png
new file mode 100644
index 0000000000000..d90e5053a9395
Binary files /dev/null and b/apps/mobile/assets/icons/issue.png differ
diff --git a/apps/mobile/assets/icons/issue@2x.png b/apps/mobile/assets/icons/issue@2x.png
new file mode 100644
index 0000000000000..98518dda6098b
Binary files /dev/null and b/apps/mobile/assets/icons/issue@2x.png differ
diff --git a/apps/mobile/assets/icons/issue@3x.png b/apps/mobile/assets/icons/issue@3x.png
new file mode 100644
index 0000000000000..6b43b0cf37caf
Binary files /dev/null and b/apps/mobile/assets/icons/issue@3x.png differ
diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt
index 736f7cd3019f0..1208f3176935d 100644
--- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt
+++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt
@@ -126,7 +126,7 @@ class T3WorkspaceNavigationView(
private val onTabSelected by EventDispatcher()
private val onNewThread by EventDispatcher()
private val onOverlayChange by EventDispatcher()
- var tabs by mutableStateOf(listOf("threads", "pullRequests", "usage", "settings"))
+ var tabs by mutableStateOf(listOf("threads", "pullRequests", "issues", "usage", "settings"))
var selectedTab by mutableStateOf("threads")
var rail by mutableStateOf(false)
var floating by mutableStateOf(false)
@@ -415,6 +415,7 @@ class T3WorkspaceNavigationView(
private fun tabTitle(tab: String) = when (tab) {
"threads" -> "Chats"
"pullRequests" -> "Pulls"
+ "issues" -> "Issues"
"usage" -> "Usage"
else -> "Settings"
}
@@ -427,6 +428,7 @@ class T3WorkspaceNavigationView(
when (tab) {
"threads" -> R.drawable.t3_chats
"pullRequests" -> R.drawable.t3_pull_requests
+ "issues" -> R.drawable.t3_issues
"usage" -> R.drawable.t3_usage
else -> R.drawable.t3_settings
}
diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_issues.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_issues.xml
new file mode 100644
index 0000000000000..14de69947215f
--- /dev/null
+++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_issues.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue.png
new file mode 100644
index 0000000000000..d90e5053a9395
Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue.png differ
diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@2x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@2x.png
new file mode 100644
index 0000000000000..98518dda6098b
Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@2x.png differ
diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@3x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@3x.png
new file mode 100644
index 0000000000000..6b43b0cf37caf
Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@3x.png differ
diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec
index c32f88b0dacb3..ed5fd7f091dba 100644
--- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec
+++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec
@@ -15,6 +15,6 @@ Pod::Spec.new do |s|
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
}
- s.resource_bundles = { 'T3NativeControlIcons' => ['Resources/Icons/pull-request*.png'] }
+ s.resource_bundles = { 'T3NativeControlIcons' => ['Resources/Icons/pull-request*.png', 'Resources/Icons/issue*.png'] }
s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}'
end
diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift
index 243490678c530..ddfdc92147465 100644
--- a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift
+++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift
@@ -40,7 +40,7 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate {
private let composeButton = UIButton(type: .system)
private lazy var tabBarTrailing = tabBar.trailingAnchor.constraint(equalTo: trailingAnchor)
private var itemsByTab: [String: UITabBarItem] = [:]
- var tabs = ["threads", "pullRequests", "usage", "settings"] {
+ var tabs = ["threads", "pullRequests", "issues", "usage", "settings"] {
didSet { if tabs != oldValue { updateItems() } }
}
var selectedTab = "threads" { didSet { updateSelection() } }
@@ -106,10 +106,11 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate {
private func prepareItems() {
let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:))
let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate)
- let labels = ["threads": "Chats", "pullRequests": "PRs", "usage": "Usage", "settings": "Settings"]
+ let labels = ["threads": "Chats", "pullRequests": "PRs", "issues": "Issues", "usage": "Usage", "settings": "Settings"]
+ let issueIcon = UIImage(named: "issue", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate)
let symbols = ["threads": "text.bubble", "usage": "chart.bar.xaxis", "settings": "gearshape"]
for (tab, label) in labels {
- let icon = tab == "pullRequests" ? pullRequestIcon : UIImage(systemName: symbols[tab] ?? "circle")
+ let icon = tab == "pullRequests" ? pullRequestIcon : tab == "issues" ? issueIcon : UIImage(systemName: symbols[tab] ?? "circle")
let item = UITabBarItem(title: nil, image: tabIcon(icon), tag: 0)
item.accessibilityLabel = label
item.accessibilityIdentifier = "workspace-tab-\(tab)"
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index b2ad65cf05374..e736ced34503c 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -37,6 +37,11 @@ import { PullRequestReviewersScreen } from "./features/pull-requests/PullRequest
import { PullRequestLabelsScreen } from "./features/pull-requests/PullRequestLabelsScreen";
import { PullRequestEditScreen } from "./features/pull-requests/PullRequestEditScreen";
import { PullRequestFilterSearchScreen } from "./features/pull-requests/PullRequestFilterSearchScreen";
+import { IssueAssigneesScreen } from "./features/issues/IssueAssigneesScreen";
+import { IssueCreateScreen } from "./features/issues/IssueCreateScreen";
+import { IssueEditScreen } from "./features/issues/IssueEditScreen";
+import { IssueLabelsScreen } from "./features/issues/IssueLabelsScreen";
+import { IssueRouteScreen } from "./features/issues/IssueRouteScreen";
import {
RenderErrorBoundary,
RenderFailureView,
@@ -552,6 +557,27 @@ const RootStackConfig = createWorkspaceStackNavigator({
screen: PullRequestEditScreen,
options: sheetRoute({ detent: 0.75, androidFit: true }),
}),
+ Issue: createNativeStackScreen({
+ screen: IssueRouteScreen,
+ linking: "issues/:environmentId/:projectId/:tracker/:scope/:issueKey",
+ options: pageRoute({ duoMinimize: "never" }),
+ }),
+ IssueEdit: createNativeStackScreen({
+ screen: IssueEditScreen,
+ options: sheetRoute({ detent: 0.75, androidFit: true }),
+ }),
+ IssueLabels: createNativeStackScreen({
+ screen: IssueLabelsScreen,
+ options: SEARCH_SHEET_ROUTE,
+ }),
+ IssueAssignees: createNativeStackScreen({
+ screen: IssueAssigneesScreen,
+ options: SEARCH_SHEET_ROUTE,
+ }),
+ IssueCreate: createNativeStackScreen({
+ screen: IssueCreateScreen,
+ options: sheetRoute({ detent: 0.75, androidFit: true }),
+ }),
Thread: createNativeStackScreen({
screen: ThreadRouteScreen,
linking: { path: "threads/:environmentId?/:threadId?", alias: ["new/draft"] },
diff --git a/apps/mobile/src/components/ComposerSurface.tsx b/apps/mobile/src/components/ComposerSurface.tsx
new file mode 100644
index 0000000000000..1d6197fdebda4
--- /dev/null
+++ b/apps/mobile/src/components/ComposerSurface.tsx
@@ -0,0 +1,249 @@
+import { useState, type ReactNode } from "react";
+import { I18nManager, Platform, type ViewStyle } from "react-native";
+import Animated, {
+ type LayoutAnimationFunction,
+ ReduceMotion,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout";
+import { useSidebarWorkspaceTabs } from "../features/layout/workspace/useSystemWorkspaceTabBar";
+import { useAndroidFloatingTabs } from "../features/settings/appearance/AppearancePreferencesProvider";
+import { compactEditorInsets } from "../features/threads/compactEditorInsets";
+import {
+ useDeviceType,
+ useDuoBottomBarInset,
+ useNativeLayoutMetrics,
+} from "../native/native-layout-metrics";
+import type { ScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
+import { MOBILE_TYPOGRAPHY } from "../lib/typography";
+import { useUniwindTheme } from "../lib/useUniwindTheme";
+import { GlassSurface } from "./ui/GlassSurface";
+
+const COLLAPSED_EDITOR_DEFAULT_INSET = 6;
+// iOS puts line-height slack above glyphs. DM Sans's lowercase midline is
+// descender (0.31em) + half x-height (0.504em) above the line bottom.
+const LOWERCASE_MIDLINE_EM = 0.562;
+
+function lowercaseMidline(text: ScaledTextRole): number {
+ return text.lineHeight - LOWERCASE_MIDLINE_EM * text.fontSize;
+}
+
+function collapsedEditorInset(text: ScaledTextRole): number {
+ const shift = lowercaseMidline(text) - lowercaseMidline(MOBILE_TYPOGRAPHY.body);
+ return Math.max(0, Math.round(COLLAPSED_EDITOR_DEFAULT_INSET - shift));
+}
+
+export function useCompactComposerEditor(text: ScaledTextRole, value: string) {
+ const [contentSize, setContentSize] = useState({ width: 0, height: 0 });
+ const [compactWidth, setCompactWidth] = useState(0);
+ const contentHeight = contentSize.height;
+ const rowWidth = useSharedValue(0);
+ const editorX = useSharedValue(0);
+ const editorWidth = useSharedValue(0);
+ const inset =
+ Platform.OS === "android" ? COLLAPSED_EDITOR_DEFAULT_INSET : collapsedEditorInset(text);
+ const minimum = Math.max(INLINE_EDITOR_HEIGHT, Math.ceil(text.lineHeight) + 2 * inset);
+ const height = Math.max(
+ minimum,
+ Math.min(text.lineHeight * 4 + 2 * inset, contentHeight + 2 * inset),
+ );
+ // A line that fits the expanded editor may still overflow its compact slot.
+ // Compare its measured width too so widening cannot trigger a collapse loop.
+ const isExpanded =
+ value.length > 0 &&
+ (contentHeight > text.lineHeight * 1.5 ||
+ (compactWidth > 0 && contentSize.width > compactWidth));
+ const compactRowHeight = Math.max(44, minimum);
+ const isRTL = I18nManager.isRTL;
+ const editorStyle = useAnimatedStyle(() => ({
+ position: "absolute",
+ overflow: "hidden",
+ height,
+ ...(isExpanded
+ ? { left: 14, right: 14 }
+ : compactEditorInsets({
+ slotX: editorX.value,
+ slotWidth: editorWidth.value,
+ rowWidth: rowWidth.value,
+ isRTL,
+ })),
+ // The full-width editor must move above the controls in the same frame it widens.
+ bottom: isExpanded ? 52 : (compactRowHeight - minimum) / 2,
+ }));
+ return {
+ isExpanded,
+ containerStyle: { height: isExpanded ? height + 52 : compactRowHeight },
+ editorStyle,
+ onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => {
+ rowWidth.set(event.nativeEvent.layout.width);
+ },
+ onEditorLayout: (event: { nativeEvent: { layout: { x: number; width: number } } }) => {
+ // The expanded toolbar gives this slot to the model control. Keep the
+ // compact geometry for the return transition, rather than recording zero.
+ if (!isExpanded) {
+ editorX.set(event.nativeEvent.layout.x);
+ editorWidth.set(event.nativeEvent.layout.width);
+ setCompactWidth(Math.max(0, event.nativeEvent.layout.width - 8));
+ }
+ },
+ editorProps: {
+ singleLineCentered: !isExpanded,
+ scrollEnabled: Platform.OS === "android" || contentHeight >= text.lineHeight * 4,
+ contentInsetVertical: inset,
+ onContentSizeChange: (size: { width: number; height: number }) => {
+ setContentSize(size);
+ },
+ style: { flex: 1 },
+ },
+ };
+}
+
+// Android KeyboardStickyView tracks the IME; concurrent timed layout morphs cause jitter.
+export const COMPOSER_TRANSITION_DURATION_MS = 220;
+// Side panes animate dock width; nested horizontal transitions would make the surface trail the toolbar.
+const composerHeightTransition: LayoutAnimationFunction = (values) => {
+ "worklet";
+ const timing = {
+ duration: COMPOSER_TRANSITION_DURATION_MS,
+ reduceMotion: ReduceMotion.System,
+ };
+ return {
+ initialValues: {
+ originX: values.targetOriginX,
+ originY: values.currentOriginY,
+ width: values.targetWidth,
+ height: values.currentHeight,
+ },
+ animations: {
+ originX: values.targetOriginX,
+ originY: withTiming(values.targetOriginY, timing),
+ width: values.targetWidth,
+ height: withTiming(values.targetHeight, timing),
+ },
+ };
+};
+const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : composerHeightTransition;
+// Share of the one-row composer the draft keeps; a long model name truncates first.
+export const INLINE_EDITOR_MIN_WIDTH = "40%";
+const INLINE_EDITOR_HEIGHT = 36;
+
+const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface);
+
+export function ComposerSurface(props: {
+ readonly children: ReactNode;
+ readonly style: ViewStyle;
+ readonly animateLayout?: boolean;
+}) {
+ const colors = useUniwindTheme();
+ const targetBorderRadius =
+ typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;
+ const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android";
+ const animatedBorderRadius = useDerivedValue(() =>
+ shouldAnimate
+ ? withTiming(targetBorderRadius, {
+ duration: COMPOSER_TRANSITION_DURATION_MS,
+ reduceMotion: ReduceMotion.System,
+ })
+ : targetBorderRadius,
+ );
+ const borderCurve = props.style.borderCurve;
+ const animatedShapeStyle = useAnimatedStyle(() => ({
+ borderRadius: animatedBorderRadius.value,
+ ...(borderCurve ? { borderCurve } : {}),
+ }));
+ const layoutTransition = shouldAnimate ? COMPOSER_LAYOUT_TRANSITION : undefined;
+
+ // Animate every native frame or glass and content jump to final height inside the clip.
+ return (
+
+
+ {null}
+
+
+ {props.children}
+
+
+ );
+}
+
+/** Space below the composer pill, inside the bottom inset it is given. */
+export const COMPOSER_BOTTOM_GUTTER = 6;
+
+/** Control size and pill shape: larger beside a sidebar tab picker and on iPad. */
+export function useComposerChrome() {
+ const { layout } = useAdaptiveWorkspaceLayout();
+ const floatingSidebarComposer = useAndroidFloatingTabs() && layout.usesSplitView;
+ const sidebarWorkspaceTabs = useSidebarWorkspaceTabs();
+ const deviceType = useDeviceType();
+ const usesLargeControls = sidebarWorkspaceTabs === "picker" || deviceType === "pad";
+ return {
+ usesLargeControls,
+ controlSize: usesLargeControls ? ("large" as const) : ("regular" as const),
+ borderRadius: usesLargeControls ? 31 : 27,
+ paddingVertical: usesLargeControls ? 9 : floatingSidebarComposer ? 6 : 2,
+ };
+}
+
+/**
+ * The inset below a composer pinned to the bottom of a page. Fixed while the keyboard animates:
+ * changing it at keyboardDidHide makes the composer land low, then jump back above the safe area.
+ */
+export function useComposerBottomInset() {
+ const insets = useSafeAreaInsets();
+ const windowMetrics = useNativeLayoutMetrics();
+ const { layout } = useAdaptiveWorkspaceLayout();
+ const bottomBarInset = useDuoBottomBarInset();
+ const hasSidebarTabBar = useSidebarWorkspaceTabs() === "picker";
+ // A pushed screen temporarily inherits the outgoing tab bar's safe area.
+ // Use the window edge so hiding that bar cannot move the composer mid-push.
+ const bottomSafeArea =
+ Platform.OS === "ios" ? (windowMetrics?.safeArea.bottom ?? insets.bottom) : insets.bottom;
+ // The pill already adds its own gutter; iPad and Duo align its edge with the native bar.
+ const alignedBottomInset =
+ bottomBarInset ??
+ (Platform.OS === "ios" && Platform.isPad
+ ? Math.max(bottomSafeArea, 12) - (layout.usesSplitView ? 5 : 0)
+ : undefined);
+ // The Duo's floating sidebar pill ends three points below the composer's usual gutter.
+ return Math.max(
+ 0,
+ (alignedBottomInset !== undefined
+ ? alignedBottomInset - COMPOSER_BOTTOM_GUTTER
+ : Math.max(bottomSafeArea, 12)) - (hasSidebarTabBar ? 3 : 0),
+ );
+}
+
+/** The editor's text style: the composer's scaled role in the theme's foreground color. */
+export function useComposerTextStyle(text: ScaledTextRole) {
+ return { ...text, color: useUniwindTheme()["--color-foreground"] };
+}
diff --git a/apps/mobile/src/components/ui/AppSymbol.android.tsx b/apps/mobile/src/components/ui/AppSymbol.android.tsx
index b34c27bd471f3..ea0cedd52447e 100644
--- a/apps/mobile/src/components/ui/AppSymbol.android.tsx
+++ b/apps/mobile/src/components/ui/AppSymbol.android.tsx
@@ -148,6 +148,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
"git.pull-request.closed": IconGitPullRequestClosed,
"git.pull-request.draft": IconGitPullRequestDraft,
"git.commit": IconGitCommit,
+ "git.issue": IconCircleDot,
"arrow.branch": IconGitBranch,
"arrow.left": IconArrowLeft,
"arrow.right": IconArrowRight,
@@ -300,6 +301,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
| "git.pull-request.closed"
| "git.pull-request.draft"
| "git.commit"
+ | "git.issue"
| "file.diff"
| "robot",
Icon
@@ -311,6 +313,7 @@ const SF_ICON_LOOKUP: Partial<
| "git.pull-request.closed"
| "git.pull-request.draft"
| "git.commit"
+ | "git.issue"
| "file.diff"
| "robot",
Icon
diff --git a/apps/mobile/src/components/ui/AppSymbol.tsx b/apps/mobile/src/components/ui/AppSymbol.tsx
index 146a9ccb305e7..896765db5d233 100644
--- a/apps/mobile/src/components/ui/AppSymbol.tsx
+++ b/apps/mobile/src/components/ui/AppSymbol.tsx
@@ -4,6 +4,7 @@ import IconGitCommit from "@tabler/icons-react-native/IconGitCommit";
import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequestClosed";
import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft";
import type { SFSymbol } from "expo-symbols";
+import IconCircleDot from "@tabler/icons-react-native/IconCircleDot";
import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest";
import IconFileDiff from "@tabler/icons-react-native/IconFileDiff";
import IconRobot from "@tabler/icons-react-native/IconRobot";
@@ -23,6 +24,7 @@ const CUSTOM_ICONS = {
"git.pull-request.closed": IconGitPullRequestClosed,
"git.pull-request.draft": IconGitPullRequestDraft,
"git.commit": IconGitCommit,
+ "git.issue": IconCircleDot,
robot: IconRobot,
};
diff --git a/apps/mobile/src/features/issues/IssueAssigneesScreen.tsx b/apps/mobile/src/features/issues/IssueAssigneesScreen.tsx
new file mode 100644
index 0000000000000..131f51e0923bd
--- /dev/null
+++ b/apps/mobile/src/features/issues/IssueAssigneesScreen.tsx
@@ -0,0 +1,78 @@
+import type { StaticScreenProps } from "@react-navigation/native";
+import { useRef, useState } from "react";
+
+import { issueEnvironment } from "../../state/issues";
+import { useEnvironmentQuery } from "../../state/query";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { PullRequestAvatar } from "../pull-requests/PullRequestPresentation";
+import { IssueCandidatesSheet } from "./IssueCandidatesSheet";
+import { issueTarget, type IssueParams } from "./issueParams";
+import { issueCommandError, useIssueAccess } from "./issuePresentation";
+
+export function IssueAssigneesScreen({ route }: StaticScreenProps) {
+ const target = issueTarget(route.params);
+ const detail = useEnvironmentQuery(issueEnvironment.detail(target)).data;
+ const access = useIssueAccess(route.params, detail);
+ const result = useEnvironmentQuery(issueEnvironment.candidates(target));
+ const update = useAtomCommand(issueEnvironment.update, { reportFailure: false });
+ const [query, setQuery] = useState("");
+ const [pending, setPending] = useState<{ readonly id: string; readonly assigned: boolean }>();
+ const [error, setError] = useState(null);
+ const inFlight = useRef(false);
+ const allowed = access?.assignees === true;
+ const assigned = new Set(detail?.assignees.map((actor) => actor.login.toLowerCase()));
+ const needle = query.trim().toLowerCase();
+ const candidates = (result.data?.assignees ?? []).filter((candidate) =>
+ `${candidate.login} ${candidate.name ?? ""}`.toLowerCase().includes(needle),
+ );
+ const toggle = async (id: string) => {
+ const candidate = candidates.find((entry) => entry.id === id);
+ if (!detail || !candidate || !allowed || inFlight.current) return;
+ inFlight.current = true;
+ const login = candidate.login.toLowerCase();
+ setPending({ id, assigned: !assigned.has(login) });
+ setError(null);
+ // The whole set is written: people already assigned by login, the new one by tracker id.
+ const assignees = assigned.has(login)
+ ? detail.assignees
+ .filter((actor) => actor.login.toLowerCase() !== login)
+ .map((actor) => actor.login)
+ : [...detail.assignees.map((actor) => actor.login), candidate.id];
+ const failure = issueCommandError(
+ await update({ ...target, input: { ...target.input, assignees } }),
+ );
+ inFlight.current = false;
+ setPending(undefined);
+ setError(failure);
+ };
+ return (
+ ({
+ key: candidate.id,
+ title: candidate.login,
+ ...(candidate.name ? { subtitle: candidate.name } : {}),
+ leading: ,
+ selected:
+ pending?.id === candidate.id
+ ? pending.assigned
+ : assigned.has(candidate.login.toLowerCase()),
+ }))}
+ allowed={allowed}
+ notice={
+ detail && !allowed
+ ? (access?.blocked ?? "Changing assignees needs triage access on this tracker.")
+ : null
+ }
+ error={error}
+ pending={result.isPending && !result.data}
+ loadError={result.data ? null : result.error}
+ onRetry={result.refresh}
+ truncated={result.data?.truncated === true}
+ onToggle={(id) => void toggle(id)}
+ />
+ );
+}
diff --git a/apps/mobile/src/features/issues/IssueCandidatesSheet.tsx b/apps/mobile/src/features/issues/IssueCandidatesSheet.tsx
new file mode 100644
index 0000000000000..6757e1bee6a3a
--- /dev/null
+++ b/apps/mobile/src/features/issues/IssueCandidatesSheet.tsx
@@ -0,0 +1,125 @@
+import { LegendList } from "@legendapp/list/react-native";
+import type { ReactNode } from "react";
+import { Platform, View } from "react-native";
+
+import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
+import { AppText as Text } from "../../components/ui/AppText";
+import { Button } from "../../components/ui/Button";
+import { EmptyState } from "../../components/ui/EmptyState";
+import { SelectionRow } from "../../components/ui/SelectionRow";
+import { NativeScrollView } from "../../native/NativeScrollViewMarker";
+import { PullRequestSearchSheet } from "../pull-requests/PullRequestSearchSheet";
+
+interface Candidate {
+ readonly key: string;
+ readonly title: string;
+ readonly subtitle?: string;
+ readonly leading: ReactNode;
+ readonly selected: boolean;
+}
+
+/** The searchable checklist behind the label and assignee pickers. */
+export function IssueCandidatesSheet(props: {
+ readonly title: string;
+ readonly noun: "labels" | "people";
+ readonly query: string;
+ readonly onQueryChange: (query: string) => void;
+ readonly candidates: ReadonlyArray;
+ readonly allowed: boolean;
+ readonly notice: string | null;
+ readonly error: string | null;
+ readonly pending: boolean;
+ readonly loadError: string | null;
+ readonly onRetry: () => void;
+ readonly truncated: boolean;
+ readonly onToggle: (key: string) => void;
+}) {
+ const searching = props.query.trim().length > 0;
+ return (
+
+ {({ paddingBottom }) => (
+ candidate.key}
+ extraData={props.candidates}
+ estimatedItemSize={64}
+ recycleItems
+ className="flex-1"
+ contentInsetAdjustmentBehavior="automatic"
+ automaticallyAdjustKeyboardInsets={Platform.OS === "ios"}
+ keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
+ keyboardShouldPersistTaps="handled"
+ showsVerticalScrollIndicator={false}
+ contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom }}
+ ListHeaderComponent={
+ props.notice || props.error ? (
+
+ {props.notice ? (
+ {props.notice}
+ ) : null}
+ {props.error ? (
+
+ {props.error}
+
+ ) : null}
+
+ ) : undefined
+ }
+ renderItem={({ item, index }) => (
+ props.onToggle(item.key)}
+ />
+ )}
+ ListEmptyComponent={
+ props.pending ? (
+
+ ) : (
+
+
+ {props.loadError ? : null}
+
+ )
+ }
+ ListFooterComponent={
+ props.truncated ? (
+
+ More {props.noun} exist than are listed here.
+
+ ) : undefined
+ }
+ />
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/features/issues/IssueCommentComposer.tsx b/apps/mobile/src/features/issues/IssueCommentComposer.tsx
new file mode 100644
index 0000000000000..8d2d7efddbc97
--- /dev/null
+++ b/apps/mobile/src/features/issues/IssueCommentComposer.tsx
@@ -0,0 +1,247 @@
+import { useRef, useState } from "react";
+import { Platform, View } from "react-native";
+import Animated from "react-native-reanimated";
+
+import {
+ ComposerEditor,
+ type ComposerEditorHandle,
+ type ComposerEditorSelection,
+} from "../../components/ComposerEditor";
+import {
+ COMPOSER_BOTTOM_GUTTER,
+ ComposerSurface,
+ INLINE_EDITOR_MIN_WIDTH,
+ useCompactComposerEditor,
+ useComposerChrome,
+ useComposerTextStyle,
+} from "../../components/ComposerSurface";
+import { ComposerActionButton, ComposerToolbarRow } from "../../components/ComposerToolbar";
+import { AppText as Text } from "../../components/ui/AppText";
+import { useComposerTextRole } from "../settings/appearance/useScaledTextRole";
+import {
+ ComposerDictationCancelAction,
+ ComposerDictationDraftContent,
+ ComposerDictationError,
+ ComposerDictationPrimaryAction,
+ ComposerDictationStartAction,
+ ComposerDictationStatus,
+ ComposerDictationToolbar,
+} from "../voice-input/ComposerDictationControl";
+import { useVoiceInputController } from "../voice-input/useVoiceInputController";
+import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation";
+
+// The thread composer's editor starts after its attachment button; this keeps the same text inset.
+const EDITOR_LEADING_INSET = 14;
+
+/**
+ * The comment box at the foot of an issue, in the thread composer's chrome: one row that grows
+ * with the draft, dictation, and a round send button. `blocked` replaces the editor with the reason.
+ */
+export function IssueCommentComposer(props: {
+ /** Names this draft to dictation, so a recording started here lands here. */
+ readonly ownerKey: string;
+ readonly label: string;
+ readonly blocked: string | null;
+ readonly posting: boolean;
+ readonly bottomInset: number;
+ readonly contentMaxWidth?: number;
+ /** Resolves true once the comment is posted, which clears the draft. */
+ readonly onSubmit: (body: string) => Promise;
+ readonly onHeightChange: (height: number) => void;
+ readonly onFocusChange: (focused: boolean) => void;
+}) {
+ const editorRef = useRef(null);
+ const [draft, setDraft] = useState("");
+ // Read by dictation and by send after an await, when the render's `draft` may be stale.
+ const latestDraft = useRef(draft);
+ const changeDraft = (text: string) => {
+ latestDraft.current = text;
+ setDraft(text);
+ };
+ const [selection, setSelection] = useState({ start: 0, end: 0 });
+ const bodyText = useComposerTextRole();
+ const textStyle = useComposerTextStyle(bodyText);
+ const chrome = useComposerChrome();
+ const compactEditor = useCompactComposerEditor(bodyText, draft);
+ const voiceInput = useVoiceInputController({
+ ownerKey: props.ownerKey,
+ label: props.label,
+ readDraftMessage: () => latestDraft.current,
+ selection,
+ disabled: props.blocked !== null,
+ onChangeDraftMessage: changeDraft,
+ onChangeSelection: setSelection,
+ });
+ const voicePresentation = resolveVoiceComposerPresentation(
+ voiceInput.state,
+ voiceInput.elapsedSeconds,
+ );
+ const dictating = voicePresentation.statusKind === "active";
+ const locked = props.posting || voiceInput.blocksSubmission;
+ const canSend = props.blocked === null && !locked && draft.trim().length > 0;
+
+ const send = async () => {
+ if (!canSend) return;
+ await editorRef.current?.flushPendingInput();
+ const submitted = latestDraft.current;
+ if (submitted.trim().length === 0) return;
+ if ((await props.onSubmit(submitted)) && latestDraft.current === submitted) changeDraft("");
+ };
+ const sendButton = (
+ void send()}
+ />
+ );
+
+ return (
+ props.onHeightChange(event.nativeEvent.layout.height)}
+ className={Platform.OS === "android" ? "bg-screen px-[12px]" : "px-[12px]"}
+ style={{ paddingTop: 6, paddingBottom: props.bottomInset + COMPOSER_BOTTOM_GUTTER }}
+ >
+
+
+
+
+ {props.blocked !== null ? (
+
+
+ {props.blocked}
+
+ {sendButton}
+
+ ) : (
+ <>
+
+
+
+
+ {/* TalkBack orders siblings by position; outside this row the editor reads after Send. */}
+
+ {
+ if (!locked) changeDraft(text);
+ }}
+ onSelectionChange={setSelection}
+ placeholder="Leave a comment"
+ onFocus={() => props.onFocusChange(true)}
+ onBlur={() => props.onFocusChange(false)}
+ onSubmit={() => void send()}
+ submitTitle="Comment"
+ {...compactEditor.editorProps}
+ textStyle={textStyle}
+ />
+
+
+
+ {sendButton}
+
+
+
+
+
+
+
+
+
+
+ {voicePresentation.showsSend ? sendButton : null}
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/issues/IssueCreateScreen.tsx b/apps/mobile/src/features/issues/IssueCreateScreen.tsx
new file mode 100644
index 0000000000000..4f240500624e4
--- /dev/null
+++ b/apps/mobile/src/features/issues/IssueCreateScreen.tsx
@@ -0,0 +1,243 @@
+import { useAtomValue } from "@effect/atom-react";
+import { StackActions, useNavigation } from "@react-navigation/native";
+import { AsyncResult } from "effect/reactivity";
+import { useRef, useState } from "react";
+import { Pressable, View } from "react-native";
+
+import { SymbolView } from "../../components/ui/AppSymbol";
+import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText";
+import { Button } from "../../components/ui/Button";
+import { Menu } from "../../components/ui/Menu";
+import { useIssueCreateScopes } from "./useIssueCreateScopes";
+import { issueEnvironment, refreshIssueLists } from "../../state/issues";
+import { useEnvironmentQuery } from "../../state/query";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { PullRequestSheetContent } from "../pull-requests/PullRequestSheetContent";
+import { usePullRequestConnected } from "../pull-requests/usePullRequestConnected";
+import type { IssueParams } from "./issueParams";
+import { issueCommandError } from "./issuePresentation";
+
+const BLANK = "__blank__";
+
+function PickerRow(props: {
+ readonly label: string;
+ readonly value: string;
+ readonly options: ReadonlyArray<{ readonly id: string; readonly title: string }>;
+ readonly selected: string;
+ readonly disabled?: boolean;
+ readonly onSelect: (id: string) => void;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * One draft on any tracker: a chosen template fills the title, body, labels and assignees, and
+ * the writer takes it from there.
+ */
+export function IssueCreateScreen() {
+ const { scopes, isPending: scopesPending } = useIssueCreateScopes();
+ const navigation = useNavigation();
+ const [scopeIndex, setScopeIndex] = useState(0);
+ const scope = scopes[Math.min(scopeIndex, scopes.length - 1)] ?? null;
+ const [templateKey, setTemplateKey] = useState(BLANK);
+ const [title, setTitle] = useState("");
+ const [body, setBody] = useState("");
+ const [extras, setExtras] = useState<{
+ readonly labels: ReadonlyArray;
+ readonly assignees: ReadonlyArray;
+ }>({ labels: [], assignees: [] });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const inFlight = useRef(false);
+ const create = useAtomCommand(issueEnvironment.create, { reportFailure: false });
+ const canWrite = useAtomValue(
+ issueEnvironment.create.permissionAtom(scope?.environmentId ?? null),
+ );
+ const connected = usePullRequestConnected(scope?.environmentId ?? "");
+ const templatesQuery = useEnvironmentQuery(
+ scope !== null && scope.templates
+ ? issueEnvironment.templates({ environmentId: scope.environmentId, input: scope.scope })
+ : null,
+ );
+ const templates = templatesQuery.data?.templates ?? [];
+ const blankAllowed = templatesQuery.data?.blankIssuesEnabled ?? true;
+
+ const chooseTemplate = (key: string) => {
+ setTemplateKey(key);
+ const template = templates.find((entry) => entry.key === key);
+ if (!template) {
+ setExtras({ labels: [], assignees: [] });
+ return;
+ }
+ // A template is a starting point: it only overwrites what the writer has not typed yet.
+ if (title.trim().length === 0) setTitle(template.title);
+ if (body.trim().length === 0) setBody(template.body);
+ setExtras({ labels: template.labels, assignees: template.assignees });
+ };
+
+ const blocked =
+ scope === null
+ ? scopesPending
+ ? "Looking for trackers…"
+ : "No project here can file issues."
+ : !connected
+ ? "Reconnect to file an issue."
+ : !canWrite
+ ? "This device isn't allowed to change source control on this environment."
+ : !blankAllowed && templateKey === BLANK && templates.length > 0
+ ? "This tracker requires a template."
+ : null;
+
+ const submit = async () => {
+ const trimmedTitle = title.trim();
+ if (scope === null || blocked !== null || trimmedTitle.length === 0 || inFlight.current) return;
+ inFlight.current = true;
+ setBusy(true);
+ setError(null);
+ const result = await create({
+ environmentId: scope.environmentId,
+ input: {
+ ...scope.scope,
+ title: trimmedTitle,
+ body,
+ labels: extras.labels,
+ assignees: extras.assignees,
+ },
+ });
+ inFlight.current = false;
+ setBusy(false);
+ if (AsyncResult.isFailure(result)) {
+ setError(issueCommandError(result));
+ return;
+ }
+ const created: IssueParams = {
+ environmentId: scope.environmentId,
+ projectId: scope.scope.projectId,
+ tracker: scope.scope.tracker,
+ ...(scope.scope.host ? { host: scope.scope.host } : {}),
+ scope: scope.scope.scope,
+ issueKey: result.value.key,
+ };
+ refreshIssueLists(scope.environmentId);
+ navigation.dispatch(StackActions.replace("Issue", created));
+ };
+
+ return (
+ void submit()}
+ />
+ }
+ >
+ {scopes.length > 1 && scope ? (
+ ({
+ id: String(index),
+ title: `${entry.projectTitle} · ${entry.scope.scope}`,
+ }))}
+ onSelect={(id) => {
+ setScopeIndex(Number(id));
+ setTemplateKey(BLANK);
+ setExtras({ labels: [], assignees: [] });
+ }}
+ />
+ ) : null}
+ {scope?.templates && (templates.length > 0 || templatesQuery.isPending) ? (
+ entry.key === templateKey)?.name ?? "Blank issue")
+ }
+ selected={templateKey}
+ disabled={templatesQuery.isPending}
+ options={[
+ ...(blankAllowed ? [{ id: BLANK, title: "Blank issue" }] : []),
+ ...templates.map((template) => ({ id: template.key, title: template.name })),
+ ]}
+ onSelect={chooseTemplate}
+ />
+ ) : null}
+
+
+ {extras.labels.length > 0 || extras.assignees.length > 0 ? (
+
+ {extras.labels.length > 0 ? (
+
+ Labels: {extras.labels.join(", ")}
+
+ ) : null}
+ {extras.assignees.length > 0 ? (
+
+ Assignees: {extras.assignees.join(", ")}
+
+ ) : null}
+
+ ) : null}
+ {blocked ? {blocked} : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/issues/IssueEditScreen.tsx b/apps/mobile/src/features/issues/IssueEditScreen.tsx
new file mode 100644
index 0000000000000..b1df082266c44
--- /dev/null
+++ b/apps/mobile/src/features/issues/IssueEditScreen.tsx
@@ -0,0 +1,99 @@
+import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import { useRef, useState } from "react";
+
+import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText";
+import { Button } from "../../components/ui/Button";
+import { issueEnvironment } from "../../state/issues";
+import { useEnvironmentQuery } from "../../state/query";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { PullRequestSheetContent } from "../pull-requests/PullRequestSheetContent";
+import { issueTarget, type IssueParams } from "./issueParams";
+import { issueCommandError, useIssueAccess } from "./issuePresentation";
+
+type IssueEditParams = IssueParams & { readonly field: "title" | "body" };
+
+export function IssueEditScreen({ route }: StaticScreenProps) {
+ const params = route.params;
+ const navigation = useNavigation();
+ const target = issueTarget(params);
+ const detailQuery = useEnvironmentQuery(issueEnvironment.detail(target));
+ const detail = detailQuery.data;
+ const access = useIssueAccess(params, detail);
+ const update = useAtomCommand(issueEnvironment.update, { reportFailure: false });
+ const isTitle = params.field === "title";
+ const original = detail ? (isTitle ? detail.title : detail.body) : null;
+ const [draft, setDraft] = useState(null);
+ const value = draft ?? original ?? "";
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const inFlight = useRef(false);
+ const allowed = access?.edit === true;
+ const unchanged = draft === null || draft === original;
+ const invalid = isTitle && value.trim().length === 0;
+ const save = async () => {
+ if (!allowed || unchanged || invalid || inFlight.current) return;
+ inFlight.current = true;
+ setBusy(true);
+ setError(null);
+ const failure = issueCommandError(
+ await update({
+ ...target,
+ input: { ...target.input, ...(isTitle ? { title: value.trim() } : { body: value }) },
+ }),
+ );
+ inFlight.current = false;
+ setBusy(false);
+ if (failure !== null) {
+ setError(failure);
+ return;
+ }
+ navigation.goBack();
+ };
+ return (
+ void save()}
+ />
+ }
+ >
+ void save() : undefined}
+ className={
+ isTitle
+ ? "min-h-12 rounded-[20px] bg-grouped-card px-4 text-base text-foreground"
+ : "min-h-48 rounded-[20px] bg-grouped-card p-4 text-base text-foreground"
+ }
+ style={isTitle ? undefined : { textAlignVertical: "top" }}
+ />
+ {detail && !allowed ? (
+
+ {access?.blocked ?? "You can't edit this issue."}
+
+ ) : null}
+ {error || detailQuery.error ? (
+
+ {error ?? detailQuery.error}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/issues/IssueLabelsScreen.tsx b/apps/mobile/src/features/issues/IssueLabelsScreen.tsx
new file mode 100644
index 0000000000000..fab5f12821d3f
--- /dev/null
+++ b/apps/mobile/src/features/issues/IssueLabelsScreen.tsx
@@ -0,0 +1,74 @@
+import type { StaticScreenProps } from "@react-navigation/native";
+import { useRef, useState } from "react";
+
+import { issueEnvironment } from "../../state/issues";
+import { useEnvironmentQuery } from "../../state/query";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { PullRequestLabelDot } from "../pull-requests/PullRequestLabelDot";
+import { pullRequestLabelColor } from "../pull-requests/pullRequestList";
+import { IssueCandidatesSheet } from "./IssueCandidatesSheet";
+import { issueTarget, type IssueParams } from "./issueParams";
+import { issueCommandError, useIssueAccess } from "./issuePresentation";
+
+export function IssueLabelsScreen({ route }: StaticScreenProps) {
+ const target = issueTarget(route.params);
+ const detail = useEnvironmentQuery(issueEnvironment.detail(target)).data;
+ const access = useIssueAccess(route.params, detail);
+ const result = useEnvironmentQuery(issueEnvironment.candidates(target));
+ const update = useAtomCommand(issueEnvironment.update, { reportFailure: false });
+ const [query, setQuery] = useState("");
+ const [pending, setPending] = useState<{ readonly name: string; readonly applied: boolean }>();
+ const [error, setError] = useState(null);
+ const inFlight = useRef(false);
+ const allowed = access?.labels === true;
+ const applied = new Set(detail?.labels.map((label) => label.name));
+ const needle = query.trim().toLowerCase();
+ // Host order, not applied-first: re-sorting on toggle would move the row out from under a tap.
+ const candidates = (result.data?.labels ?? []).filter((label) =>
+ `${label.name} ${label.description ?? ""}`.toLowerCase().includes(needle),
+ );
+ const toggle = async (name: string) => {
+ if (!detail || !allowed || inFlight.current) return;
+ inFlight.current = true;
+ setPending({ name, applied: !applied.has(name) });
+ setError(null);
+ // The tracker takes the whole set, so the change is written against what the detail holds.
+ const labels = applied.has(name)
+ ? detail.labels.filter((label) => label.name !== name).map((label) => label.name)
+ : [...applied, name];
+ const failure = issueCommandError(
+ await update({ ...target, input: { ...target.input, labels } }),
+ );
+ inFlight.current = false;
+ setPending(undefined);
+ setError(failure);
+ };
+ return (
+ ({
+ key: label.name,
+ title: label.name,
+ ...(label.description ? { subtitle: label.description } : {}),
+ leading: ,
+ // The change shows as soon as it is tapped; a failure puts the tracker's answer back.
+ selected: pending?.name === label.name ? pending.applied : applied.has(label.name),
+ }))}
+ allowed={allowed}
+ notice={
+ detail && !allowed
+ ? (access?.blocked ?? "Changing labels needs triage access on this tracker.")
+ : null
+ }
+ error={error}
+ pending={result.isPending && !result.data}
+ loadError={result.data ? null : result.error}
+ onRetry={result.refresh}
+ truncated={result.data?.truncated === true}
+ onToggle={(name) => void toggle(name)}
+ />
+ );
+}
diff --git a/apps/mobile/src/features/issues/IssueListRow.tsx b/apps/mobile/src/features/issues/IssueListRow.tsx
new file mode 100644
index 0000000000000..4809f8abd73ea
--- /dev/null
+++ b/apps/mobile/src/features/issues/IssueListRow.tsx
@@ -0,0 +1,99 @@
+import type { EnvironmentIssueEntry } from "@t3tools/client-runtime/issue-list";
+import { memo } from "react";
+import { Pressable, View } from "react-native";
+
+import { RelativeTime } from "../../components/RelativeTime";
+import { SymbolView } from "../../components/ui/AppSymbol";
+import { AppText as Text } from "../../components/ui/AppText";
+import { cn } from "../../lib/cn";
+import { ListRowLabel } from "../pull-requests/PullRequestListRow";
+import { PullRequestAvatar } from "../pull-requests/PullRequestPresentation";
+import {
+ selectedThreadRowColors,
+ THREAD_LIST_V2_SIDEBAR_ROW_CONTAINER_STYLE,
+ THREAD_LIST_V2_SIDEBAR_ROW_STYLE,
+} from "../threads/thread-list-v2-row-appearance";
+import { issueStatus } from "./issuePresentation";
+
+export const IssueListRow = memo(function IssueListRow(props: {
+ readonly item: EnvironmentIssueEntry;
+ readonly onSelect: (item: EnvironmentIssueEntry) => void;
+ readonly selected: boolean;
+ readonly sidebar: boolean;
+}) {
+ const { item } = props;
+ const foreground = props.selected
+ ? selectedThreadRowColors.foregroundClassName
+ : props.sidebar
+ ? "text-drawer-foreground"
+ : "text-foreground";
+ const muted = props.selected
+ ? selectedThreadRowColors.mutedForegroundClassName
+ : props.sidebar
+ ? "text-drawer-foreground-muted"
+ : "text-foreground-muted";
+ const status = issueStatus(item.state);
+ return (
+
+ label.name).join(", ")}` : ""}${item.commentCount ? `, ${item.commentCount} comments` : ""}`}
+ onPress={() => props.onSelect(item)}
+ style={props.sidebar ? THREAD_LIST_V2_SIDEBAR_ROW_STYLE : undefined}
+ className={cn(
+ "flex-row gap-3 overflow-hidden",
+ !props.sidebar && "px-4 active:bg-row-hover",
+ props.sidebar && "bg-drawer",
+ props.sidebar && !props.selected && "active:bg-thread-hover",
+ props.selected && "bg-thread-selected",
+ )}
+ >
+
+
+
+
+
+
+
+ {item.scope} {item.displayKey}
+
+
+
+
+
+
+ {item.title}
+
+ {item.labels.length > 0 || item.commentCount > 0 ? (
+
+
+ {item.labels.slice(0, 2).map((label) => (
+
+ ))}
+ {item.labels.length > 2 ? (
+ +{item.labels.length - 2}
+ ) : null}
+
+ {item.commentCount > 0 ? (
+
+
+ {item.commentCount}
+
+ ) : null}
+
+ ) : null}
+
+
+
+ );
+});
diff --git a/apps/mobile/src/features/issues/IssueRouteScreen.tsx b/apps/mobile/src/features/issues/IssueRouteScreen.tsx
new file mode 100644
index 0000000000000..63c5e68d1e723
--- /dev/null
+++ b/apps/mobile/src/features/issues/IssueRouteScreen.tsx
@@ -0,0 +1,665 @@
+import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import {
+ issueCloseReasonLabel,
+ issueEventSentence,
+} from "@t3tools/client-runtime/issue-presentation";
+import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
+import { prependIssueComments } from "@t3tools/client-runtime/state/issues";
+import type {
+ IssueComment,
+ IssueDetail,
+ IssueEvent,
+ IssueLinkedPullRequest,
+ IssueReaction,
+ IssueState,
+ IssueSummary,
+} from "@t3tools/contracts";
+import { AsyncResult } from "effect/reactivity";
+import { useState } from "react";
+import { Linking, Platform, Pressable, Share, View } from "react-native";
+import { KeyboardStickyView } from "react-native-keyboard-controller";
+
+import { RelativeTime } from "../../components/RelativeTime";
+import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
+import { AppBar } from "../../components/ui/AppBar";
+import { SymbolView } from "../../components/ui/AppSymbol";
+import { AppText as Text } from "../../components/ui/AppText";
+import { Button } from "../../components/ui/Button";
+import { Chip } from "../../components/ui/Chip";
+import { EmptyState } from "../../components/ui/EmptyState";
+import { Menu } from "../../components/ui/Menu";
+import { RefreshControl } from "../../components/ui/RefreshControl";
+import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
+import { useComposerBottomInset } from "../../components/ComposerSurface";
+import { ANDROID_READING_MAX_WIDTH, CHAT_CONTENT_MAX_WIDTH } from "../../lib/layout";
+import { relativeTime } from "../../lib/time";
+import { useDeviceType } from "../../native/native-layout-metrics";
+import { NativeScrollView } from "../../native/NativeScrollViewMarker";
+import { issueEnvironment } from "../../state/issues";
+import { useEnvironmentQuery } from "../../state/query";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { usePullToRefresh } from "../../state/use-pull-to-refresh";
+import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
+import { useAndroidWorkspaceBack } from "../layout/workspace/useAndroidWorkspaceNavigation";
+import { PullRequestDescription } from "../pull-requests/PullRequestDescription";
+import {
+ PullRequestMarkdown,
+ PullRequestMarkdownRepository,
+} from "../pull-requests/PullRequestMarkdown";
+import { PullRequestAvatar } from "../pull-requests/PullRequestPresentation";
+import { ReactionPills } from "../pull-requests/PullRequestReactions";
+import { PullRequestSectionHeading } from "../pull-requests/PullRequestSectionHeading";
+import { pullRequestStatus } from "../pull-requests/pullRequestStatus";
+import { usePullRequestConnected } from "../pull-requests/usePullRequestConnected";
+import { IssueCommentComposer } from "./IssueCommentComposer";
+import { issueIdentity, issueParamsKey, issueTarget, type IssueParams } from "./issueParams";
+import {
+ IssueLabelPill,
+ issueCommandError,
+ issueStatus,
+ useIssueAccess,
+} from "./issuePresentation";
+
+type IssueAccess = NonNullable>;
+
+function IssueReactions(props: {
+ readonly params: IssueParams;
+ readonly reactions: ReadonlyArray;
+ readonly canReact: boolean;
+ /** Absent reacts to the issue itself. */
+ readonly subjectId?: string;
+}) {
+ const target = issueTarget(props.params);
+ const react = useAtomCommand(issueEnvironment.react, { reportFailure: false });
+ if (props.reactions.length === 0 && !props.canReact) return null;
+ return (
+
+
+ !AsyncResult.isFailure(
+ await react({
+ ...target,
+ input: {
+ ...target.input,
+ ...(props.subjectId === undefined ? {} : { subjectId: props.subjectId }),
+ content,
+ reacted,
+ },
+ }),
+ )
+ }
+ />
+
+ );
+}
+
+function IssueStateControl(props: {
+ readonly state: IssueState;
+ readonly detail: IssueDetail | null;
+ readonly states: ReadonlyArray;
+ readonly busy: boolean;
+ readonly onMove: (state: string, closeReason?: string) => void;
+}) {
+ const status = issueStatus(props.state);
+ const reasons = props.detail?.capabilities.closeReasons ?? [];
+ const moves = props.states.flatMap(
+ (
+ state,
+ ): ReadonlyArray<{
+ readonly id: string;
+ readonly state: string;
+ readonly reason?: string;
+ readonly title: string;
+ }> =>
+ state.category === "closed" && reasons.length > 0
+ ? reasons.map((reason) => ({
+ id: `${state.name}\u0000${reason}`,
+ state: state.name,
+ reason,
+ title: `${state.name === "closed" ? "Close" : state.name} as ${issueCloseReasonLabel(reason).toLowerCase()}`,
+ }))
+ : [
+ {
+ id: state.name,
+ state: state.name,
+ title:
+ state.category === "open" && state.name === "open"
+ ? "Reopen"
+ : state.name === "closed"
+ ? "Close"
+ : `Move to ${state.name}`,
+ },
+ ],
+ );
+ const trigger = (
+
+
+ {status.label}
+ {moves.length ? (
+
+ ) : null}
+
+ );
+ if (!moves.length) return trigger;
+ return (
+
+ );
+}
+
+function IssueIdentity(props: {
+ readonly issue: IssueSummary;
+ readonly detail: IssueDetail | null;
+ readonly access: IssueAccess | null;
+ readonly busy: boolean;
+ readonly onMove: (state: string, closeReason?: string) => void;
+ readonly onEditLabels: () => void;
+ readonly onEditAssignees: () => void;
+}) {
+ const { issue, access } = props;
+ const canLabel = access?.labels === true;
+ const canAssign = access?.assignees === true;
+ return (
+
+
+
+ {issue.title}
+
+
+
+
+ {issue.author?.login ?? "Unknown author"}
+
+
+ {issue.closedAt ? (
+ <>
+ Closed ago
+ >
+ ) : (
+ <>
+ Opened ago
+ >
+ )}
+
+
+ {issue.labels.length || canLabel ? (
+
+ {issue.labels.map((label) => (
+
+ ))}
+ {canLabel ? (
+
+ ) : null}
+
+ ) : null}
+
+ Assignees
+ {issue.assignees.length === 0 ? (
+ none
+ ) : null}
+ {issue.assignees.map((actor) => (
+
+
+ {actor.login}
+
+ ))}
+ {canAssign ? (
+
+ ) : null}
+
+
+ );
+}
+
+function LinkedPullRequests(props: {
+ readonly params: IssueParams;
+ readonly links: ReadonlyArray;
+}) {
+ const navigation = useNavigation();
+ // Only a git host's tracker shares its host with the change request it links.
+ const sameHost = sourceControlClients.find(props.params.tracker) !== undefined;
+ return (
+
+ Linked pull requests
+ {props.links.map((link) => {
+ const status = pullRequestStatus(link);
+ return (
+
+ sameHost
+ ? navigation.dispatch(
+ StackActions.push("PullRequest", {
+ environmentId: props.params.environmentId,
+ projectId: props.params.projectId,
+ repository: link.repository,
+ number: link.number,
+ ...(props.params.host ? { host: props.params.host } : {}),
+ }),
+ )
+ : void Linking.openURL(link.url)
+ }
+ className="flex-row items-center gap-2 rounded-2xl bg-grouped-card p-3 active:opacity-70"
+ >
+
+
+
+ {link.title}
+
+
+ {link.repository}#{link.number}
+ {link.closesIssue ? " · closes" : ""}
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+function IssueCommentCard(props: {
+ readonly params: IssueParams;
+ readonly comment: IssueComment;
+ readonly canReact: boolean;
+}) {
+ const { comment } = props;
+ return (
+
+
+
+
+ {comment.author?.login ?? "Unknown author"}
+
+ {relativeTime(comment.createdAt)}
+
+
+ {comment.reactions !== undefined ? (
+
+ ) : null}
+
+ );
+}
+
+function IssueEventRow({ event }: { readonly event: IssueEvent }) {
+ return (
+
+
+
+ {event.actor?.login ?? "Someone"}{" "}
+ {issueEventSentence(event)}
+
+ {relativeTime(event.createdAt)}
+
+ );
+}
+
+function IssueLoadingBody() {
+ return (
+
+
+ Description
+
+
+
+
+
+
+
+ Activity
+
+
+
+
+ );
+}
+
+export function IssueRouteScreen({ route }: StaticScreenProps) {
+ const params = route.params;
+ const identity = issueIdentity(params);
+ const target = issueTarget(params);
+ const navigation = useNavigation();
+ const returnToList = useAndroidWorkspaceBack();
+ const { layout } = useAdaptiveWorkspaceLayout();
+ const composerBottomInset = useComposerBottomInset();
+ const deviceType = useDeviceType();
+ const [composerFocused, setComposerFocused] = useState(false);
+ const connected = usePullRequestConnected(params.environmentId);
+ const detailAtom = issueEnvironment.detail(target);
+ const detailQuery = useEnvironmentQuery(detailAtom);
+ const pullRefresh = usePullToRefresh(detailAtom);
+ const detail = detailQuery.data;
+ const issue: IssueSummary | null = detail ?? params.initialSummary ?? null;
+ const access = useIssueAccess(params, detail);
+
+ const loadComments = useAtomCommand(issueEnvironment.comments, { reportFailure: false });
+ const postComment = useAtomCommand(issueEnvironment.comment, { reportFailure: false });
+ const setState = useAtomCommand(issueEnvironment.setState, { reportFailure: false });
+ const [older, setOlder] = useState<{
+ readonly seed: string | null;
+ readonly comments: ReadonlyArray;
+ readonly cursor: string | null;
+ } | null>(null);
+ const [busy, setBusy] = useState<"older" | "comment" | "state" | null>(null);
+ const [error, setError] = useState(null);
+ const [descriptionExpanded, setDescriptionExpanded] = useState(false);
+ const [composerHeight, setComposerHeight] = useState(0);
+
+ // Older pages are tied to the cursor the detail was read with; a refreshed detail starts over.
+ const olderComments = detail && older?.seed === detail.commentsCursor ? older.comments : [];
+ const nextCursor = !detail
+ ? null
+ : older?.seed === detail.commentsCursor
+ ? older.cursor
+ : detail.commentsCursor;
+ const timeline = detail
+ ? [
+ ...prependIssueComments(detail.comments, olderComments).map((comment) => ({
+ key: `c:${comment.id}`,
+ at: comment.createdAt,
+ content: (
+
+ ),
+ })),
+ ...detail.events.map((event) => ({
+ key: `e:${event.id}`,
+ at: event.createdAt,
+ content: ,
+ })),
+ ].sort((left, right) => Date.parse(left.at) - Date.parse(right.at))
+ : [];
+
+ const run = async (
+ kind: NonNullable,
+ command: () => Promise>,
+ ) => {
+ if (busy !== null) return false;
+ setBusy(kind);
+ setError(null);
+ const failure = issueCommandError(await command());
+ setBusy(null);
+ setError(failure);
+ return failure === null;
+ };
+ const loadOlder = async () => {
+ if (!detail || nextCursor === null || busy !== null) return;
+ setBusy("older");
+ setError(null);
+ const result = await loadComments({
+ ...target,
+ input: { ...target.input, cursor: nextCursor },
+ });
+ setBusy(null);
+ if (AsyncResult.isFailure(result)) {
+ setError("Older comments could not be read.");
+ return;
+ }
+ setOlder({
+ seed: detail.commentsCursor,
+ comments: prependIssueComments(olderComments, result.value.comments),
+ cursor: result.value.nextCursor,
+ });
+ };
+ const submitComment = (body: string) =>
+ access?.comment === true
+ ? run("comment", () => postComment({ ...target, input: { ...target.input, body } }))
+ : Promise.resolve(false);
+ const moveTo = (state: string, closeReason?: string) =>
+ void run("state", () =>
+ setState({
+ ...target,
+ input: { ...target.input, state, ...(closeReason === undefined ? {} : { closeReason }) },
+ }),
+ );
+
+ const showComposer = detail?.capabilities.comment === true;
+ const commentBlocked =
+ access?.comment === true ? null : (access?.blocked ?? "You cannot comment here.");
+ const contentMaxWidth =
+ Platform.OS === "android"
+ ? ANDROID_READING_MAX_WIDTH
+ : layout.usesSplitView
+ ? CHAT_CONTENT_MAX_WIDTH
+ : undefined;
+ const url = issue?.url;
+ return (
+
+
+
+ navigation.navigate("IssueEdit", { ...identity, field: "title" }),
+ },
+ {
+ id: "edit-description",
+ title: "Edit description",
+ icon: "doc.text",
+ onPress: () =>
+ navigation.navigate("IssueEdit", { ...identity, field: "body" }),
+ },
+ ]
+ : []),
+ ...(url
+ ? [
+ {
+ id: "open-on-host",
+ title: "Open on the tracker",
+ icon: "arrow.up.right",
+ onPress: () => void Linking.openURL(url),
+ },
+ {
+ id: "copy-link",
+ title: "Copy link",
+ icon: "link",
+ onPress: () => copyTextWithHaptic(url, { target: "issue link" }),
+ },
+ {
+ id: "share",
+ title: "Share…",
+ icon: "square.and.arrow.up",
+ onPress: () =>
+ // Android ignores `url`; iOS shows a `message` URL as plain text.
+ void Share.share(
+ Platform.OS === "ios" ? { url } : { message: url },
+ ).catch(() => undefined),
+ },
+ ]
+ : []),
+ ],
+ },
+ ]}
+ />
+ {/* Not a bare marker: Android wraps a refreshable scroll view, which the marker rejects. */}
+ pullRefresh.onRefresh()}
+ />
+ }
+ contentContainerStyle={{
+ paddingHorizontal: 16,
+ paddingTop: 8,
+ paddingBottom: showComposer ? composerHeight + 16 : 24,
+ }}
+ >
+ {!issue ? (
+ detailQuery.error && !detailQuery.isPending ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ navigation.navigate("IssueLabels", identity)}
+ onEditAssignees={() => navigation.navigate("IssueAssignees", identity)}
+ />
+ {error || (detail && detailQuery.error) ? (
+
+ {error ?? detailQuery.error}
+
+ ) : null}
+ {!detail ? (
+ detailQuery.error && !detailQuery.isPending ? (
+
+ ) : (
+
+ )
+ ) : (
+ <>
+ undefined}
+ footer={
+ detail.reactions !== undefined ? (
+
+ ) : undefined
+ }
+ />
+ {detail.linkedPullRequests.length > 0 ? (
+
+ ) : null}
+
+
+ Activity
+ {detail.commentCount > 0
+ ? ` · ${detail.commentCount} ${detail.commentCount === 1 ? "comment" : "comments"}`
+ : ""}
+
+ {nextCursor !== null ? (
+
+ >
+ )}
+
+ )}
+
+ {showComposer ? (
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/issues/IssuesScreen.tsx b/apps/mobile/src/features/issues/IssuesScreen.tsx
new file mode 100644
index 0000000000000..b5cdb7e59e551
--- /dev/null
+++ b/apps/mobile/src/features/issues/IssuesScreen.tsx
@@ -0,0 +1,366 @@
+import { useAtomValue } from "@effect/atom-react";
+import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
+import { useNavigation } from "@react-navigation/native";
+import {
+ continueIssuePage,
+ groupIssues,
+ holdIssuePages,
+ issueCreateScopes,
+ type EnvironmentIssueEntry,
+ type HeldIssuePage,
+} from "@t3tools/client-runtime/issue-list";
+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import type {
+ EnvironmentId,
+ IssueInvolvement,
+ IssueListInput,
+ IssueListState,
+} from "@t3tools/contracts";
+import { AsyncResult } from "effect/reactivity";
+import { use, useRef, useState, type ReactNode } from "react";
+import { Keyboard, Platform, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
+import { AppText as Text } from "../../components/ui/AppText";
+import { Button } from "../../components/ui/Button";
+import { Chip } from "../../components/ui/Chip";
+import { EmptyState } from "../../components/ui/EmptyState";
+import { usePaneRole } from "../../components/ui/Pane";
+import { RefreshControl } from "../../components/ui/RefreshControl";
+import { useContentTopInset } from "../../components/ui/useBarInset";
+import { NativeScrollView } from "../../native/NativeScrollViewMarker";
+import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
+import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
+import { useProjects } from "../../state/entities";
+import { issueEnvironment, useIssueList } from "../../state/issues";
+import { useDebouncedValue } from "../../state/queries";
+import { environmentServerConfigsAtom } from "../../state/server";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { useWorkspaceState } from "../../state/workspace";
+import { ListFilterMenu } from "../layout/workspace/ListFilterMenu";
+import { useSearchScrollRestoration } from "../layout/workspace/useSearchScrollRestoration";
+import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail";
+import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext";
+import { issueParamsKey } from "./issueParams";
+import { DEFAULT_ISSUE_LIST_INPUT } from "./useIssueCreateScopes";
+import { IssueListRow } from "./IssueListRow";
+
+const STATES = {
+ open: "Open",
+ closed: "Closed",
+ all: "All states",
+} as const satisfies Record;
+const INVOLVEMENT = {
+ all: "Everyone",
+ assigned: "Assigned to you",
+ authored: "Opened by you",
+ mentioned: "Mentioning you",
+} as const satisfies Record;
+// A 12dp inset keeps 16dp chips concentric with Android's 28dp ScreenContent corner.
+const FILTER_ROW_STYLE =
+ Platform.OS === "android"
+ ? { paddingHorizontal: 12, paddingTop: 12, paddingBottom: 8, gap: 8 }
+ : { paddingHorizontal: 16, paddingTop: 2, paddingBottom: 10, gap: 8 };
+
+function choices(options: Readonly>, selected: Value) {
+ return (Object.entries(options) as Array<[Value, string]>).map(([id, title]) => ({
+ id,
+ title,
+ state: id === selected ? ("on" as const) : ("off" as const),
+ }));
+}
+
+const rowKey = (item: EnvironmentIssueEntry) =>
+ issueParamsKey({
+ environmentId: item.environmentId,
+ projectId: item.projectId,
+ host: item.host,
+ scope: item.scope,
+ issueKey: item.key,
+ });
+
+export function IssuesScreen(
+ props: {
+ readonly sidebar?: boolean;
+ readonly selectedIssueKey?: string | null;
+ /** Places the list under a header of the caller's (the Android toolbar). */
+ readonly renderFrame?: (list: ReactNode) => ReactNode;
+ } = {},
+) {
+ const navigation = useNavigation();
+ const primaryColumn = use(NativePrimaryColumnContext);
+ const workspace = useWorkspaceTabs()!;
+ const { environments } = useWorkspaceState();
+ const configs = useAtomValue(environmentServerConfigsAtom);
+ const projects = useProjects();
+ const insets = useSafeAreaInsets();
+ const contentTopInset = useContentTopInset();
+ // UIKit insets the list by its column's safe area, which this context can report as 0.
+ const columnSafeBottom = useNativeColumnLayoutMetrics()?.safeArea.bottom;
+ const bottomSafeArea =
+ Platform.OS === "ios" ? (columnSafeBottom ?? insets.bottom) : insets.bottom;
+ const listRef = useRef(null);
+ const tab = workspace.state.tabs.issues;
+ const [initialScrollOffset] = useState(() => workspace.getScrollOffset("issues"));
+ useSearchScrollRestoration(tab.searchMode, () => {
+ void listRef.current?.scrollToOffset({
+ offset: tab.scrollOffset - contentTopInset,
+ animated: false,
+ });
+ });
+ const debouncedQuery = useDebouncedValue(tab.query.trim(), 300);
+ const query = tab.searchMode === "resting" ? "" : debouncedQuery;
+ const [state, setState] = useState("open");
+ const [involvement, setInvolvement] = useState("all");
+ const listInput: IssueListInput = {
+ ...DEFAULT_ISSUE_LIST_INPUT,
+ state,
+ involvement,
+ ...(query ? { query } : {}),
+ };
+ const capable = environments.filter(
+ (environment) =>
+ configs.get(environment.environmentId)?.environment.capabilities.issues === true,
+ );
+ const connected = capable.filter((environment) => environment.connectionState === "connected");
+ const targets = connected
+ .map((environment) => ({ environmentId: environment.environmentId, input: listInput }))
+ .sort((left, right) => left.environmentId.localeCompare(right.environmentId));
+ const list = useIssueList(targets);
+
+ const [pages, setPages] = useState>(new Map());
+ const held = holdIssuePages(list.answers, pages);
+ const loadPage = useAtomCommand(issueEnvironment.listPage, { reportFailure: false });
+ const [loadingMore, setLoadingMore] = useState(false);
+ const loading = useRef(false);
+ const continuable = [...held].filter(([, page]) => Object.keys(page.nextCursors).length > 0);
+ const moreFailure = [...held.values()].find((page) => page.moreFailure)?.moreFailure ?? null;
+ const loadMore = async (retry = false) => {
+ if (loading.current || (moreFailure !== null && !retry)) return;
+ loading.current = true;
+ setLoadingMore(true);
+ const loaded = await Promise.all(
+ continuable.map(async ([environmentId, page]) => {
+ const result = await loadPage({
+ environmentId,
+ input: { ...listInput, cursors: page.nextCursors },
+ });
+ const failure = AsyncResult.isFailure(result) ? squashAtomCommandFailure(result) : null;
+ return [
+ environmentId,
+ continueIssuePage(
+ page,
+ AsyncResult.isFailure(result)
+ ? { failure: failure instanceof Error ? failure.message : "The next page failed." }
+ : result.value,
+ ),
+ ] as const;
+ }),
+ );
+ loading.current = false;
+ setLoadingMore(false);
+ setPages(new Map([...held, ...loaded]));
+ };
+
+ const groups = groupIssues(held, query, projects);
+ const entries = groups
+ .flatMap((group) => group.rows)
+ .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
+ const notices = groups.flatMap((group) => [
+ ...(group.tracker.configured
+ ? []
+ : [`${group.tracker.host}: ${group.tracker.detail ?? "This tracker cannot be read."}`]),
+ ...group.errors.map((error) => `${error.projectTitle}: ${error.message}`),
+ ]);
+ const createScopes = issueCreateScopes(held, projects);
+
+ const paneRole = usePaneRole();
+ const sidebar = props.sidebar === true || (primaryColumn !== null && paneRole === "sidebar");
+ const selectedKey = props.selectedIssueKey ?? primaryColumn?.selectedIssueKey;
+ const selectDetail = useSelectWorkspaceDetail();
+ const selectIssue = (item: EnvironmentIssueEntry) => {
+ Keyboard.dismiss();
+ selectDetail({
+ name: "Issue",
+ params: {
+ environmentId: String(item.environmentId),
+ projectId: String(item.projectId),
+ tracker: item.tracker,
+ host: item.host,
+ scope: item.scope,
+ issueKey: item.key,
+ initialSummary: item,
+ },
+ });
+ };
+ const scrollToTop = () =>
+ void listRef.current?.scrollToOffset({ offset: -contentTopInset, animated: false });
+
+ const content = (
+
+ void loadMore()}
+ onEndReachedThreshold={0.5}
+ refreshControl={
+ 0}
+ onRefresh={list.refresh}
+ // The list already accounts for the navigation header in its content inset.
+ progressViewOffset={Platform.OS === "ios" ? 60 : undefined}
+ />
+ }
+ alwaysBounceVertical
+ showsVerticalScrollIndicator={Platform.OS !== "android"}
+ recycleItems
+ renderScrollComponent={Platform.OS === "ios" ? NativeScrollView : undefined}
+ contentInsetAdjustmentBehavior="never"
+ contentInset={Platform.OS === "ios" ? { top: contentTopInset } : undefined}
+ initialScrollOffset={
+ initialScrollOffset > 0 ? initialScrollOffset - contentTopInset : undefined
+ }
+ contentContainerStyle={{
+ paddingBottom: Math.max(bottomSafeArea, workspace.bottomClearance),
+ }}
+ onScroll={(event) => {
+ workspace.recordScrollOffset(
+ "issues",
+ Math.max(0, event.nativeEvent.contentOffset.y + contentTopInset),
+ );
+ }}
+ keyboardDismissMode="on-drag"
+ keyboardShouldPersistTaps="handled"
+ renderItem={({ item }) => (
+
+ )}
+ // Lifted above the rows so an iOS filter menu's dismissal morph is not covered.
+ ListHeaderComponentStyle={{ zIndex: 1 }}
+ ListHeaderComponent={
+ <>
+
+ {
+ setState(id as IssueListState);
+ scrollToTop();
+ }}
+ />
+ {
+ setInvolvement(id as IssueInvolvement);
+ scrollToTop();
+ }}
+ />
+ {Platform.OS === "android" && createScopes.length > 0 ? (
+ navigation.navigate("IssueCreate")}
+ />
+ ) : null}
+
+ {notices.map((notice) => (
+
+ {notice}
+
+ ))}
+ >
+ }
+ ListFooterComponent={
+ loadingMore ? (
+
+
+
+ ) : moreFailure ? (
+
+ {moreFailure}
+
+ ) : continuable.length > 0 ? (
+ // A filtered page can be too short to scroll, which leaves nothing to reach the end of.
+
+
+ ) : undefined
+ }
+ ListEmptyComponent={
+ list.isPending && list.answers.length === 0 ? (
+
+
+
+ ) : (
+
+ 0
+ ? query
+ ? "No results"
+ : "No matching issues"
+ : capable.length > 0
+ ? "Environment offline"
+ : environments.length > 0
+ ? "Issues unavailable"
+ : "Connect an environment"
+ }
+ detail={
+ list.error ??
+ (connected.length > 0
+ ? "Issues from your environments' projects appear here."
+ : capable.length > 0
+ ? "Reconnect to load issues."
+ : environments.length > 0
+ ? "Update the environment's T3 Code server to browse issues."
+ : "Add an environment in Settings to browse its issues.")
+ }
+ variant="plain"
+ />
+
+ )
+ }
+ />
+
+ );
+ return props.renderFrame ? props.renderFrame(content) : content;
+}
diff --git a/apps/mobile/src/features/issues/issueParams.ts b/apps/mobile/src/features/issues/issueParams.ts
new file mode 100644
index 0000000000000..0709a7e5466ca
--- /dev/null
+++ b/apps/mobile/src/features/issues/issueParams.ts
@@ -0,0 +1,78 @@
+import type { NavigationState } from "@react-navigation/native";
+import {
+ EnvironmentId,
+ IssueTrackerKind,
+ ProjectId,
+ type IssueListEntry,
+ type IssueRef,
+} from "@t3tools/contracts";
+
+/** Route params that name one issue. Plain strings, so a link can carry them. */
+export type IssueParams = {
+ readonly environmentId: string;
+ readonly projectId: string;
+ readonly tracker: string;
+ readonly host?: string;
+ readonly scope: string;
+ readonly issueKey: string;
+ /** The list row the issue was opened from, shown until its detail arrives. */
+ readonly initialSummary?: IssueListEntry;
+};
+
+export function issueTarget(params: IssueParams) {
+ const input: IssueRef = {
+ projectId: ProjectId.make(params.projectId),
+ tracker: IssueTrackerKind.make(params.tracker),
+ ...(params.host ? { host: params.host } : {}),
+ scope: params.scope,
+ key: params.issueKey,
+ };
+ return { environmentId: EnvironmentId.make(params.environmentId), input };
+}
+
+/** The params without the list row, for the screens an issue opens. */
+export function issueIdentity({ initialSummary: _, ...identity }: IssueParams) {
+ return identity;
+}
+
+/** The identity the Issues tab remembers and the list marks as selected. */
+export function issueParamsKey(
+ params: Pick,
+) {
+ return [
+ params.environmentId,
+ params.projectId,
+ (params.host ?? "").toLowerCase(),
+ params.scope.toLowerCase(),
+ params.issueKey,
+ ].join("\u0000");
+}
+
+/** The selected issue's key from a route's params, when they name one. */
+export function selectedIssueKeyOf(params: object | undefined) {
+ const value = params as Partial> | undefined;
+ return typeof value?.environmentId === "string" &&
+ typeof value.projectId === "string" &&
+ typeof value.scope === "string" &&
+ typeof value.issueKey === "string"
+ ? issueParamsKey({
+ environmentId: value.environmentId,
+ projectId: value.projectId,
+ scope: value.scope,
+ issueKey: value.issueKey,
+ ...(typeof value.host === "string" ? { host: value.host } : {}),
+ })
+ : null;
+}
+
+/** The issue the detail pane shows, for marking its row in the Android sidebar. */
+export function selectedIssueKey(state: Pick) {
+ for (let index = state.index; index >= 0; index--) {
+ const route = state.routes[index];
+ if (!route) continue;
+ if (route.name === "Home" || !route.name.startsWith("Issue")) return null;
+ const key = selectedIssueKeyOf(route.params);
+ if (key !== null || route.name === "Issue") return key;
+ }
+ return null;
+}
diff --git a/apps/mobile/src/features/issues/issuePresentation.tsx b/apps/mobile/src/features/issues/issuePresentation.tsx
new file mode 100644
index 0000000000000..d842f264635cb
--- /dev/null
+++ b/apps/mobile/src/features/issues/issuePresentation.tsx
@@ -0,0 +1,90 @@
+import { useAtomValue } from "@effect/atom-react";
+import { issueStateLabel, issueWriteAccess } from "@t3tools/client-runtime/issue-presentation";
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import type { IssueDetail, IssueLabel, IssueState } from "@t3tools/contracts";
+import { AsyncResult } from "effect/reactivity";
+import { View } from "react-native";
+
+import type { AppSymbolName } from "../../components/ui/AppSymbol";
+import { AppText as Text } from "../../components/ui/AppText";
+import { issueEnvironment } from "../../state/issues";
+import { ENVIRONMENT_REQUEST_INTERRUPTED } from "../../state/query";
+import { pullRequestLabelColor } from "../pull-requests/pullRequestList";
+import { usePullRequestConnected } from "../pull-requests/usePullRequestConnected";
+import { issueTarget, type IssueParams } from "./issueParams";
+
+// Open and completed wear the pull request colors for open and merged; not planned is grey,
+// because work that stopped must not look like work that finished.
+export function issueStatus(state: IssueState): {
+ readonly label: string;
+ readonly icon: AppSymbolName;
+ readonly tint: string;
+ readonly text: string;
+} {
+ const label = issueStateLabel(state);
+ if (state.category === "open")
+ return {
+ label,
+ icon: "git.issue",
+ tint: "accent-adaptive-emerald-600-400",
+ text: "text-adaptive-emerald-600-400",
+ };
+ if (state.closeReason === "not-planned")
+ return {
+ label,
+ icon: "slash.circle",
+ tint: "accent-icon-muted",
+ text: "text-foreground-muted",
+ };
+ return {
+ label,
+ icon: "checkmark.circle",
+ tint: "accent-adaptive-violet-600-400",
+ text: "text-adaptive-violet-600-400",
+ };
+}
+
+/** What this device may do to the issue, and the first thing in the way when it may not. */
+export function useIssueAccess(params: IssueParams, detail: IssueDetail | null | undefined) {
+ const connected = usePullRequestConnected(params.environmentId);
+ const canWrite = useAtomValue(
+ issueEnvironment.update.permissionAtom(issueTarget(params).environmentId),
+ );
+ if (!detail) return null;
+ const access = issueWriteAccess(detail, connected && canWrite);
+ return {
+ ...access,
+ blocked: connected
+ ? canWrite
+ ? access.blocked
+ : "This device isn't allowed to change source control on this environment."
+ : "Reconnect to make changes.",
+ };
+}
+
+export function issueCommandError(result: AsyncResult.AsyncResult) {
+ if (!AsyncResult.isFailure(result)) return null;
+ if (isAtomCommandInterrupted(result)) return ENVIRONMENT_REQUEST_INTERRUPTED;
+ const cause = squashAtomCommandFailure(result);
+ return cause instanceof Error ? cause.message : "The tracker refused it.";
+}
+
+export function IssueLabelPill({ label }: { readonly label: IssueLabel }) {
+ const color = pullRequestLabelColor(label.color);
+ return (
+
+ {color ? (
+
+ ) : null}
+
+ {label.name}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/issues/useIssueCreateScopes.ts b/apps/mobile/src/features/issues/useIssueCreateScopes.ts
new file mode 100644
index 0000000000000..9bbe80eabb4d8
--- /dev/null
+++ b/apps/mobile/src/features/issues/useIssueCreateScopes.ts
@@ -0,0 +1,40 @@
+import { useAtomValue } from "@effect/atom-react";
+import { holdIssuePages, issueCreateScopes } from "@t3tools/client-runtime/issue-list";
+import type { IssueListInput } from "@t3tools/contracts";
+
+import { useProjects } from "../../state/entities";
+import { useIssueList } from "../../state/issues";
+import { environmentServerConfigsAtom } from "../../state/server";
+import { useWorkspaceState } from "../../state/workspace";
+
+/** The Issues tab's resting listing; its answer names every tracker and what each can do. */
+export const DEFAULT_ISSUE_LIST_INPUT = {
+ state: "open",
+ involvement: "all",
+ limit: 50,
+} as const satisfies IssueListInput;
+
+const NO_PAGES = new Map();
+
+/** Every tracker scope a new issue can be filed in, across connected environments. */
+export function useIssueCreateScopes() {
+ const { environments } = useWorkspaceState();
+ const configs = useAtomValue(environmentServerConfigsAtom);
+ const projects = useProjects();
+ const targets = environments
+ .filter(
+ (environment) =>
+ environment.connectionState === "connected" &&
+ configs.get(environment.environmentId)?.environment.capabilities.issues === true,
+ )
+ .map((environment) => ({
+ environmentId: environment.environmentId,
+ input: DEFAULT_ISSUE_LIST_INPUT,
+ }))
+ .sort((left, right) => left.environmentId.localeCompare(right.environmentId));
+ const list = useIssueList(targets);
+ return {
+ scopes: issueCreateScopes(holdIssuePages(list.answers, NO_PAGES), projects),
+ isPending: list.isPending && list.answers.length === 0,
+ };
+}
diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx
index e04b72cb9685d..de5344961ea2c 100644
--- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx
+++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx
@@ -14,6 +14,7 @@ export function WorkspaceEmptyDetail(props: {
readonly onAddConnection?: () => void;
}) {
const pullRequests = props.tab === "pullRequests";
+ const issues = props.tab === "issues";
const settings = props.tab === "settings";
if (props.tab === "usage") return ;
return (
@@ -55,18 +56,22 @@ export function WorkspaceEmptyDetail(props: {
? "Select a setting"
: pullRequests
? "Select a pull request"
- : "Select a thread"}
+ : issues
+ ? "Select an issue"
+ : "Select a thread"}
{settings
? "Choose a page from the sidebar."
: pullRequests
? "Choose a pull request from the sidebar to review it."
- : Platform.OS === "android"
- ? "Choose a thread from the sidebar or start a new thread."
- : "Choose a thread from the sidebar or start a new task."}
+ : issues
+ ? "Choose an issue from the sidebar to read it."
+ : Platform.OS === "android"
+ ? "Choose a thread from the sidebar or start a new thread."
+ : "Choose a thread from the sidebar or start a new task."}
- {props.onStartNewTask && !pullRequests && !settings ? (
+ {props.onStartNewTask && !pullRequests && !issues && !settings ? (
Platform.OS === "android" ? (
) : (
diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx
index fb144bdd16c7e..42ad5a74aa8cf 100644
--- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx
+++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx
@@ -41,6 +41,9 @@ import {
WorkspaceSidebarTabsFooter,
} from "./workspace/WorkspaceNativeTabs.ios";
import { workspaceUsesColumns } from "./workspace/workspace-tabs-state";
+import { useWorkspaceCompose } from "./workspace/useWorkspaceCompose";
+import { NAVIGATION_TAB_ORDER } from "./workspace/navigation-tab-order";
+import { selectedIssueKeyOf } from "../issues/issueParams";
import { isSettingsRoute, settingsOpenIn } from "../settings/settings-navigation";
import { workspaceRoutesOf } from "./workspace-overlay-routes";
import { setNativelyPoppingWorkspaceRoute } from "./workspace/workspace-detail-selection";
@@ -90,6 +93,8 @@ const isPullRequestRoute = (route: Route) =>
route.name === "PullRequestFile" ||
route.name === "PullRequestFiles";
+const isIssueRoute = (route: Route) => route.name.startsWith("Issue");
+
// Every step of starting a task, from the project picker to the draft thread itself.
const isNewTaskRoute = (route: Route) =>
route.name.startsWith("NewTask") ||
@@ -106,6 +111,7 @@ function ColumnScreen(props: {
readonly attached?: boolean;
readonly selectedThreadKey?: string | null;
readonly selectedPullRequestKey?: string | null;
+ readonly selectedIssueKey?: string | null;
readonly onDismiss: (routeKey: string, native: boolean) => void;
readonly onAppearance: (
type: "willAppear" | "didAppear" | "willDisappear" | "didDisappear",
@@ -131,6 +137,7 @@ function ColumnScreen(props: {
presented,
selectedThreadKey: props.selectedThreadKey ?? null,
selectedPullRequestKey: props.selectedPullRequestKey ?? null,
+ selectedIssueKey: props.selectedIssueKey ?? null,
}
: null;
const key = descriptor.route.key;
@@ -260,10 +267,8 @@ function WorkspaceColumns(
const openNewTask = startNewTask;
// The cover detail rail needs room for thread tools above the fixed tabs.
// The unfolded Duo keeps compose in its detail bar.
- const detailCompose =
- deviceType === "duo-unfolded" && selectedTab !== "pullRequests" && !settingsOpen
- ? openNewTask
- : undefined;
+ const compose = useWorkspaceCompose();
+ const detailCompose = deviceType === "duo-unfolded" ? compose?.onPress : undefined;
const inspector = use(NativeWorkspaceInspectorContext);
const hideInspector = () => {
if (inspector.visible) toggleAuxiliaryPane();
@@ -287,6 +292,7 @@ function WorkspaceColumns(
typeof pullRequestParams.number === "number"
? `${pullRequestParams.environmentId}:${pullRequestParams.host}:${pullRequestParams.repository}:${pullRequestParams.number}`
: null;
+ const selectedIssueKey = selectedIssueKeyOf(props.detail.findLast(isIssueRoute)?.params);
const selectedThreadKey =
threadParams &&
"environmentId" in threadParams &&
@@ -364,9 +370,7 @@ function WorkspaceColumns(
if (workspace.some((route) => route.name !== "Home" && route.name !== "Settings")) return;
const tab = workspace.some((route) => route.name === "Settings")
? "settings"
- : (["threads", "pullRequests", "usage"] as const).find((candidate) =>
- workspaceTabs?.isTabActive(candidate),
- );
+ : NAVIGATION_TAB_ORDER.find((candidate) => workspaceTabs?.isTabActive(candidate));
openWorkspaceTab(tab ?? "threads", { columns: true });
}}
preferredDisplayMode={
@@ -419,6 +423,7 @@ function WorkspaceColumns(
}
selectedThreadKey={selectedThreadKey}
selectedPullRequestKey={selectedPullRequestKey}
+ selectedIssueKey={selectedIssueKey}
onDismiss={handleNativeDismiss}
onAppearance={followCollapsedSplit}
navigation={props.navigation}
diff --git a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceSidebar.android.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceSidebar.android.tsx
index 5d784359d4f42..17f6193d17619 100644
--- a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceSidebar.android.tsx
+++ b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceSidebar.android.tsx
@@ -9,7 +9,9 @@ import { FLOATING_TABS } from "./floating-workspace-tabs";
import { View } from "react-native";
import { ScreenContent } from "../../../components/ui/ScreenContent";
import { PullRequestsScreen } from "../../pull-requests/PullRequestsScreen";
-import { PullRequestToolbar } from "./PullRequestToolbar.android";
+import { ListSearchToolbar } from "./ListSearchToolbar.android";
+import { IssuesScreen } from "../../issues/IssuesScreen";
+import { selectedIssueKey } from "../../issues/issueParams";
import { selectedPullRequestKey } from "../../pull-requests/pullRequestSelection";
import { AndroidHomeFabLayout } from "../../home/AndroidHomeFab";
import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context";
@@ -28,6 +30,7 @@ export function AndroidWorkspaceSidebar() {
const { fabSize } = useAndroidControlSizing();
const { revealPrimarySidebar, startNewTask } = useAdaptiveWorkspaceLayout();
const selectedPullRequest = useNavigationState(selectedPullRequestKey);
+ const selectedIssue = useNavigationState(selectedIssueKey);
// Settings and Usage cover the sidebar.
const coveredByPage =
(useAndroidWorkspaceRoot()?.settingsOpen ?? false) || workspace.state.selectedTab === "usage";
@@ -46,8 +49,21 @@ export function AndroidWorkspaceSidebar() {
- ) : tab !== "pullRequests" ? (
+ ) : tab === "usage" ? (
+ ) : tab === "issues" ? (
+
+ (
+
+
+ {list}
+
+ )}
+ />
+
) : (
(
-
diff --git a/apps/mobile/src/features/layout/workspace/PullRequestToolbar.android.tsx b/apps/mobile/src/features/layout/workspace/ListSearchToolbar.android.tsx
similarity index 69%
rename from apps/mobile/src/features/layout/workspace/PullRequestToolbar.android.tsx
rename to apps/mobile/src/features/layout/workspace/ListSearchToolbar.android.tsx
index 332c1befe18ba..8b4d3b6aa14b8 100644
--- a/apps/mobile/src/features/layout/workspace/PullRequestToolbar.android.tsx
+++ b/apps/mobile/src/features/layout/workspace/ListSearchToolbar.android.tsx
@@ -9,62 +9,69 @@ import { useWorkspaceTabs } from "./WorkspaceTabsContext";
import type { PullRequestFilterMenu } from "../../pull-requests/PullRequestListFilters";
import { openSettings } from "../../settings/settings-navigation";
-/** PR search shares the thread list's Android header. */
-export function PullRequestToolbar(props: {
- readonly filters: PullRequestFilterMenu | null;
+const SECTIONS = {
+ pullRequests: { title: "Pull requests", noun: "pull requests" },
+ issues: { title: "Issues", noun: "issues" },
+} as const;
+
+/** Pull request and issue search share the thread list's Android header. */
+export function ListSearchToolbar(props: {
+ readonly tab: keyof typeof SECTIONS;
+ readonly filters?: PullRequestFilterMenu | null;
readonly onRequestVisibility?: () => void;
}) {
const navigation = useNavigation();
- const { filters } = props;
+ const { filters, tab: section } = props;
+ const { title, noun } = SECTIONS[section];
const workspace = useWorkspaceTabs()!;
const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace;
const { onRequestVisibility } = props;
- const tab = workspace.state.tabs.pullRequests;
+ const tab = workspace.state.tabs[section];
const searching = tab.searchMode !== "resting";
const openSearch = () => {
onRequestVisibility?.();
dispatch({
type: "scroll",
- tab: "pullRequests",
- offset: getScrollOffset("pullRequests"),
+ tab: section,
+ offset: getScrollOffset(section),
});
- dispatch({ type: "search", tab: "pullRequests" });
+ dispatch({ type: "search", tab: section });
return true;
};
useHardwareKeyboardCommand("focusSearch", openSearch);
const closeSearch = () => {
Keyboard.dismiss();
- recordScrollOffset("pullRequests", tab.searchEntryOffset);
- dispatch({ type: "cancel", tab: "pullRequests" });
+ recordScrollOffset(section, tab.searchEntryOffset);
+ dispatch({ type: "cancel", tab: section });
};
- // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to BackHandler while searching to close the PR search on Android back
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to BackHandler while searching to close the list search on Android back
useEffect(() => {
if (!searching) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
// Frozen Home screens stay mounted under a detail route or another tab,
- // so back only applies while the PR list is in front.
+ // so back only applies while this list is in front.
const state = navigation.getState();
- if (!state || state.routes[state.index]?.name !== "Home" || !isTabActive("pullRequests"))
+ if (!state || state.routes[state.index]?.name !== "Home" || !isTabActive(section))
return false;
if (Keyboard.isVisible()) Keyboard.dismiss();
else closeSearch();
return true;
});
return () => subscription.remove();
- }, [searching, closeSearch, navigation, isTabActive]);
+ }, [searching, closeSearch, navigation, isTabActive, section]);
return (
openSettings(navigation, "SettingsEnvironments")}
/>
}
search={{
value: tab.query,
- onChangeText: (query) => dispatch({ type: "query", tab: "pullRequests", query }),
- placeholder: "Search pull requests",
+ onChangeText: (query) => dispatch({ type: "query", tab: section, query }),
+ placeholder: `Search ${noun}`,
active: searching,
onOpen: openSearch,
onClose: closeSearch,
@@ -73,7 +80,7 @@ export function PullRequestToolbar(props: {
filters
? [
{
- title: "Filter pull requests",
+ title: `Filter ${noun}`,
icon: filters.customized
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle",
diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceExtraTab.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceExtraTab.tsx
index 17fc0069eb8e6..7d1bc992cb791 100644
--- a/apps/mobile/src/features/layout/workspace/WorkspaceExtraTab.tsx
+++ b/apps/mobile/src/features/layout/workspace/WorkspaceExtraTab.tsx
@@ -2,9 +2,7 @@ import { type NavigationTab } from "./navigation-tab-order";
import { NativeStackScreenOptions } from "../../../native/StackHeader";
import { UsageRouteScreen } from "../../usage/UsageRouteScreen";
-export function WorkspaceExtraTab(_props: {
- tab: Exclude;
-}) {
+export function WorkspaceExtraTab(_props: { tab: Extract }) {
return (
<>
diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx
index 2247ea50c9a4a..6ad031298af72 100644
--- a/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx
+++ b/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx
@@ -28,8 +28,10 @@ import { useDeviceType } from "../../../native/native-layout-metrics";
import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs";
import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "../AdaptiveWorkspaceLayout";
import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab";
+import { useWorkspaceCompose } from "./useWorkspaceCompose";
const PULL_REQUEST_ICON = require("../../../../assets/icons/pull-request.png");
+const ISSUE_ICON = require("../../../../assets/icons/issue.png");
/** Retain each native controller tree; removing its root can leave RNS's stack empty. */
function RetainedWorkspaceTab(props: {
@@ -140,7 +142,9 @@ export function WorkspaceNativeTabs(props: {
? { type: "sfSymbol", name: "gearshape" }
: tab === "pullRequests"
? { type: "templateSource", templateSource: PULL_REQUEST_ICON }
- : { type: "sfSymbol", name: NAVIGATION_TAB_ICONS[tab] },
+ : tab === "issues"
+ ? { type: "templateSource", templateSource: ISSUE_ICON }
+ : { type: "sfSymbol", name: NAVIGATION_TAB_ICONS[tab] },
selectedIcon:
!settings && tab === "threads"
? { type: "sfSymbol", name: "text.bubble.fill" }
@@ -188,18 +192,13 @@ export function WorkspaceNativeTabs(props: {
/** The workspace tabs at the foot of the sidebar. */
export function WorkspaceSidebarTabsFooter() {
- const workspace = useWorkspaceTabs()!;
- const { startNewTask } = useAdaptiveWorkspaceLayout();
+ const compose = useWorkspaceCompose();
const compact = useFloatingWorkspaceTabs();
return (
);
}
diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx
index 75d139621562a..270eba9b0096f 100644
--- a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx
+++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx
@@ -13,7 +13,8 @@ import { ScreenContent } from "../../../components/ui/ScreenContent";
import { useAndroidControlSizing } from "../../../components/ui/useAndroidControlSizing";
import { HomeRouteScreen } from "../../home/HomeRouteScreen";
import { PullRequestsScreen } from "../../pull-requests/PullRequestsScreen";
-import { PullRequestToolbar } from "./PullRequestToolbar.android";
+import { ListSearchToolbar } from "./ListSearchToolbar.android";
+import { IssuesScreen } from "../../issues/IssuesScreen";
import { useAdaptiveWorkspaceLayout } from "../AdaptiveWorkspaceLayout";
import { WorkspaceSettledContentWidthContext } from "../workspace-content-width";
import { AndroidWorkspaceNavigation, FloatingWorkspaceTabs } from "./AndroidWorkspaceNavigation";
@@ -95,21 +96,30 @@ function WorkspaceRoot() {
renderTab={(tab) =>
tab === "threads" ? (
- ) : tab !== "pullRequests" ? (
+ ) : tab === "usage" ? (
) : layout.usesSplitView ? (
<>
-
-
+
+
>
+ ) : tab === "issues" ? (
+ (
+ <>
+
+ {list}
+ >
+ )}
+ />
) : (
(
<>
-
+
{list}
>
)}
diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx
index e8e7c0d325983..bbdf9a74ee611 100644
--- a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx
+++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx
@@ -24,6 +24,7 @@ import { HomeRouteScreen } from "../../home/HomeRouteScreen";
import { WorkspaceConnectionTitle } from "../../home/WorkspaceConnectionTitle";
import { useAdaptiveWorkspaceLayout } from "../AdaptiveWorkspaceLayout";
import { PullRequestsScreen } from "../../pull-requests/PullRequestsScreen";
+import { IssuesScreen } from "../../issues/IssuesScreen";
import {
WorkspaceTabsContext,
WorkspaceTabsProvider,
@@ -157,6 +158,8 @@ function WorkspaceRootContent() {
) : state.selectedTab === "pullRequests" ? (
+ ) : state.selectedTab === "issues" ? (
+
) : (
)}
@@ -165,8 +168,7 @@ function WorkspaceRootContent() {
) : null}
{enabled.length === 0 && !settingsOpen ? null : settingsOpen ||
- state.selectedTab === "threads" ||
- state.selectedTab === "pullRequests" ? (
+ state.selectedTab !== "usage" ? (
) : (
diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceSectionToolbar.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSectionToolbar.tsx
index 1676a927ecc6f..a78199e6910b5 100644
--- a/apps/mobile/src/features/layout/workspace/WorkspaceSectionToolbar.tsx
+++ b/apps/mobile/src/features/layout/workspace/WorkspaceSectionToolbar.tsx
@@ -13,6 +13,7 @@ import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab";
import { useCompactWorkspaceTabs, useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar";
const PULL_REQUEST_ICON = require("../../../../assets/icons/pull-request.png");
+const ISSUE_ICON = require("../../../../assets/icons/issue.png");
export function WorkspaceTabToolbarButtons() {
const order = useNavigationTabOrder();
@@ -36,7 +37,9 @@ export function WorkspaceTabToolbarButtons() {
icon={
tab === "pullRequests"
? { type: "image", source: PULL_REQUEST_ICON, tinted: true }
- : NAVIGATION_TAB_ICONS[tab]
+ : tab === "issues"
+ ? { type: "image", source: ISSUE_ICON, tinted: true }
+ : NAVIGATION_TAB_ICONS[tab]
}
tintColor={selected === tab ? selectedTint : undefined}
onPress={() => selectTab(tab)}
diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx
index 81344be55e4d2..63795d7b1302d 100644
--- a/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx
+++ b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx
@@ -59,6 +59,7 @@ function createWorkspaceTabsModel(selectedTab?: WorkspaceTab) {
const scrollOffsets: Record = {
threads: 0,
pullRequests: 0,
+ issues: 0,
usage: 0,
settings: 0,
};
diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx
index f07076a8fb141..657ca8174fe21 100644
--- a/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx
+++ b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx
@@ -20,10 +20,12 @@ import { useWorkspaceTabs } from "./WorkspaceTabsContext";
import { shouldRestoreNativeSearch } from "./workspace-tabs-state";
import { useFloatingWorkspaceTabs, useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar";
import { WorkspaceTabToolbarButtons } from "./WorkspaceSectionToolbar";
+import { useWorkspaceCompose } from "./useWorkspaceCompose";
const SEARCH_LABELS = {
threads: "Search threads",
pullRequests: "Search pull requests",
+ issues: "Search issues",
settings: "Search settings",
} as const;
@@ -38,12 +40,12 @@ export function WorkspaceToolbar(props: {
const floatingTabs = useFloatingWorkspaceTabs();
const duoSearch = NATIVE_LIQUID_GLASS_SUPPORTED && isDuoDevice(deviceType);
const bottomSearch = duoSearch && sidebarTabs !== "picker";
- // On the Duo, compose belongs to the threads tab, in the detail column's bar when unfolded.
+ const compose = useWorkspaceCompose();
+ // On the Duo, compose sits in the list's bar when folded and the detail column's when unfolded.
const nativeToolbarCompose =
deviceType === "duo-folded" &&
- !workspace.settingsOpen &&
- workspace.state.selectedTab === "threads" &&
- activeNewTask === null;
+ compose !== null &&
+ (compose.tab !== "threads" || activeNewTask === null);
const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true;
const focused = useIsFocused();
const { layout, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
@@ -126,9 +128,9 @@ export function WorkspaceToolbar(props: {
const composeButton = nativeToolbarCompose ? (
) : null;
@@ -197,15 +199,15 @@ export function WorkspaceToolbar(props: {
{searchButton}
{nativeToolbarCompose && foldedSearch ? : null}
{composeButton}
- {selectedTab !== "threads" ||
+ {compose === null ||
isDuoDevice(deviceType) ||
(deviceType === "pad" && layout.usesSplitView) ||
sidebarTabs === "picker" ||
floatingTabs ? null : (
)}
{NATIVE_LIQUID_GLASS_SUPPORTED ? null : (
diff --git a/apps/mobile/src/features/layout/workspace/android-workspace-tabs.ts b/apps/mobile/src/features/layout/workspace/android-workspace-tabs.ts
index 63fde490b6fa6..1e824630ac625 100644
--- a/apps/mobile/src/features/layout/workspace/android-workspace-tabs.ts
+++ b/apps/mobile/src/features/layout/workspace/android-workspace-tabs.ts
@@ -11,7 +11,7 @@ import { rememberedSettingsPage, workspaceSelectionsIn } from "./workspace-tab-n
/** Settings joins the two list tabs as a top-level Android destination. */
export type AndroidWorkspaceNavigationTab = WorkspaceTab | "settings";
-const WORKSPACE_ROUTES = new Set(["Home", "Thread", "PullRequest"]);
+const WORKSPACE_ROUTES = new Set(["Home", "Thread", "PullRequest", "Issue"]);
/** The Settings tab resets over Home alone so stale chat and PR history is dropped. */
export function androidWorkspaceTabNavigationState(
diff --git a/apps/mobile/src/features/layout/workspace/navigation-tab-order.test.ts b/apps/mobile/src/features/layout/workspace/navigation-tab-order.test.ts
index 71ea901ed3b7b..ed99e153d5e2e 100644
--- a/apps/mobile/src/features/layout/workspace/navigation-tab-order.test.ts
+++ b/apps/mobile/src/features/layout/workspace/navigation-tab-order.test.ts
@@ -23,8 +23,8 @@ describe("navigation tab order", () => {
it("fills missing sections and drops removed placeholders, duplicates, and Settings", () => {
expect(
- resolveNavigationTabOrder(["usage", "home", "issues", "settings", "usage", null]),
- ).toEqual(["usage", "threads", "pullRequests"]);
+ resolveNavigationTabOrder(["usage", "home", "inbox", "settings", "usage", null]),
+ ).toEqual(["usage", "threads", "pullRequests", "issues"]);
expect(resolveNavigationTabOrder()).toEqual(NAVIGATION_TAB_ORDER);
});
@@ -51,10 +51,10 @@ describe("navigation tab order", () => {
it("removes hidden sections from navigation and restores their saved positions", () => {
const order = moveNavigationTab(undefined, "usage", 0);
const hidden = toggleNavigationTabVisibility([], "usage");
- expect(enabledNavigationTabs(order, hidden)).toEqual(["threads", "pullRequests"]);
+ expect(enabledNavigationTabs(order, hidden)).toEqual(["threads", "pullRequests", "issues"]);
expect(toggleNavigationTabVisibility(hidden, "usage")).toEqual([]);
expect(visibleNavigationTabs(order, [], 5)).toEqual(order);
- expect(enabledNavigationTabs(undefined, ["home", "issues", "settings"])).toEqual(
+ expect(enabledNavigationTabs(undefined, ["home", "inbox", "settings"])).toEqual(
NAVIGATION_TAB_ORDER,
);
});
diff --git a/apps/mobile/src/features/layout/workspace/navigation-tab-order.ts b/apps/mobile/src/features/layout/workspace/navigation-tab-order.ts
index 85e3e1c6f29fe..e3adc56cb3dbb 100644
--- a/apps/mobile/src/features/layout/workspace/navigation-tab-order.ts
+++ b/apps/mobile/src/features/layout/workspace/navigation-tab-order.ts
@@ -1,4 +1,4 @@
-export const NAVIGATION_TAB_ORDER = ["threads", "pullRequests", "usage"] as const;
+export const NAVIGATION_TAB_ORDER = ["threads", "pullRequests", "issues", "usage"] as const;
export type NavigationTab = (typeof NAVIGATION_TAB_ORDER)[number];
export function navigationCustomizationAvailable(
@@ -10,6 +10,7 @@ export function navigationCustomizationAvailable(
export const NAVIGATION_TAB_LABELS: Readonly> = {
threads: "Threads",
pullRequests: "Pulls",
+ issues: "Issues",
usage: "Usage",
};
diff --git a/apps/mobile/src/features/layout/workspace/navigation-tabs.ts b/apps/mobile/src/features/layout/workspace/navigation-tabs.ts
index 3472242dc4f56..01b25eebc297c 100644
--- a/apps/mobile/src/features/layout/workspace/navigation-tabs.ts
+++ b/apps/mobile/src/features/layout/workspace/navigation-tabs.ts
@@ -21,6 +21,7 @@ export const WORKSPACE_TAB_LABELS = {
export const NAVIGATION_TAB_ICONS = {
threads: "text.bubble",
pullRequests: PullRequestGlyph.pullRequest,
+ issues: "git.issue",
usage: "chart.bar.xaxis",
} as const satisfies Readonly>;
diff --git a/apps/mobile/src/features/layout/workspace/useWorkspaceCompose.ts b/apps/mobile/src/features/layout/workspace/useWorkspaceCompose.ts
new file mode 100644
index 0000000000000..8255bb2a0f5ec
--- /dev/null
+++ b/apps/mobile/src/features/layout/workspace/useWorkspaceCompose.ts
@@ -0,0 +1,24 @@
+import { useNavigation } from "@react-navigation/native";
+
+import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context";
+import { useWorkspaceTabs } from "./WorkspaceTabsContext";
+
+/** What the compose button starts on the selected tab; null where the tab has nothing to start. */
+export function useWorkspaceCompose() {
+ const workspace = useWorkspaceTabs();
+ const navigation = useNavigation();
+ const { startNewTask } = useAdaptiveWorkspaceLayout();
+ if (!workspace || workspace.settingsOpen) return null;
+ switch (workspace.state.selectedTab) {
+ case "threads":
+ return { tab: "threads", label: "New chat", onPress: startNewTask } as const;
+ case "issues":
+ return {
+ tab: "issues",
+ label: "New issue",
+ onPress: () => navigation.navigate("IssueCreate"),
+ } as const;
+ default:
+ return null;
+ }
+}
diff --git a/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts
index ad7f37f14036c..62ea09debf5d6 100644
--- a/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts
+++ b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts
@@ -13,7 +13,8 @@ type RootParams = ReactNavigation.RootParamList;
/** A page the workspace's detail column shows on its own: what a list row selects. */
export type WorkspaceDetail =
| { readonly name: "Thread"; readonly params: RootParams["Thread"] }
- | { readonly name: "PullRequest"; readonly params: RootParams["PullRequest"] };
+ | { readonly name: "PullRequest"; readonly params: RootParams["PullRequest"] }
+ | { readonly name: "Issue"; readonly params: RootParams["Issue"] };
/** Android opens the reviewers sheet over the pull request in the same step. */
export type WorkspaceDetailSheet = {
@@ -42,6 +43,7 @@ export function selectWorkspaceDetail(
const IDENTITY = {
Thread: ["environmentId", "threadId"],
PullRequest: ["environmentId", "projectId", "repository", "number", "host"],
+ Issue: ["environmentId", "projectId", "tracker", "host", "scope", "issueKey"],
} as const;
type RouteLike = { readonly name: string; readonly key?: string; readonly params?: object };
@@ -102,13 +104,14 @@ export function workspaceDetailRouter(
};
}
-/** A link to a single thread or pull request selects it, like its list row would. */
+/** A link to a single thread, pull request or issue selects it, like its list row would. */
export function workspaceDetailLinkAction(
state: PartialState | NavigationState,
): SelectWorkspaceDetailAction | undefined {
const pages = state.routes.filter((route) => route.name !== "Home");
const [page] = pages;
if (pages.length !== 1 || !page || page.state) return undefined;
- if (page.name !== "Thread" && page.name !== "PullRequest") return undefined;
+ if (page.name !== "Thread" && page.name !== "PullRequest" && page.name !== "Issue")
+ return undefined;
return selectWorkspaceDetail({ name: page.name, params: page.params } as WorkspaceDetail);
}
diff --git a/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts b/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts
index 19d2653ddedfd..57e5b0f2bf2f6 100644
--- a/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts
+++ b/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts
@@ -55,11 +55,19 @@ function save() {
function withoutRouteKeys(saved: WorkspaceSelections | undefined): WorkspaceSelections {
const omitKey = ({ key: _, ...rest }: Selection) =>
rest;
- const { threads, pullRequests, settingsKey: _, threadsDraft: _draft, ...rest } = saved ?? {};
+ const {
+ threads,
+ pullRequests,
+ issues,
+ settingsKey: _,
+ threadsDraft: _draft,
+ ...rest
+ } = saved ?? {};
return {
...rest,
...(threads ? { threads: omitKey(threads) } : {}),
...(pullRequests ? { pullRequests: omitKey(pullRequests) } : {}),
+ ...(issues ? { issues: omitKey(issues) } : {}),
};
}
@@ -115,6 +123,7 @@ export const workspaceSelectionCheck: WorkspaceSelectionCheck = {
.has(selection.threadId as ThreadId);
},
pullRequest: (selection) => !environmentRemoved(selection.environmentId),
+ issue: (selection) => !environmentRemoved(selection.environmentId),
};
/** Whether the window lays the workspace out in columns, by the rule the layout itself uses. */
diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts
index 3cd3fe6d0978f..de0d9665a2ab8 100644
--- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts
+++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts
@@ -31,7 +31,7 @@ const { StackRouter } = NodeModule.createRequire(
)("@react-navigation/routers") as {
readonly StackRouter: typeof import("@react-navigation/native").StackRouter;
};
-const available = { thread: () => true, pullRequest: () => true };
+const available = { thread: () => true, pullRequest: () => true, issue: () => true };
const names = (state: { routes: readonly { name: string }[] }) =>
state.routes.map((route) => route.name);
@@ -172,7 +172,7 @@ describe("opening a workspace tab", () => {
});
it("falls back to the list for a chat that no longer exists, or an unknown Settings page", () => {
- const missing = { thread: () => false, pullRequest: () => true };
+ const missing = { thread: () => false, pullRequest: () => true, issue: () => true };
expect(
names(
workspaceTabNavigationState(
diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts
index 6a44e617e53ed..412a92bc82457 100644
--- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts
+++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts
@@ -13,6 +13,7 @@ type RouteLike = {
};
type ThreadSelection = NonNullable;
type PullRequestSelection = NonNullable;
+type IssueSelection = NonNullable;
/** Opened when Settings has columns and none of its pages has been opened yet. */
export const DEFAULT_SETTINGS_PAGE = "SettingsEnvironments" satisfies SettingsPageTarget;
@@ -38,10 +39,11 @@ export function rememberedSettingsPage(selections: WorkspaceSelections): Setting
: DEFAULT_SETTINGS_PAGE;
}
-/** Whether a remembered chat or pull request can still be opened. */
+/** Whether a remembered chat, pull request or issue can still be opened. */
export interface WorkspaceSelectionCheck {
readonly thread: (selection: ThreadSelection) => boolean;
readonly pullRequest: (selection: PullRequestSelection) => boolean;
+ readonly issue: (selection: IssueSelection) => boolean;
}
function stringParam(params: object | undefined, key: string) {
@@ -83,8 +85,32 @@ function samePullRequest(
);
}
+const ISSUE_IDENTITY = ["environmentId", "projectId", "tracker", "scope", "issueKey"] as const;
+
+function issueSelection(params: object | undefined): Omit | undefined {
+ const value = params as Record | undefined;
+ if (ISSUE_IDENTITY.some((key) => typeof value?.[key] !== "string")) return undefined;
+ const identity = value as Record<(typeof ISSUE_IDENTITY)[number], string>;
+ return {
+ environmentId: identity.environmentId,
+ projectId: identity.projectId,
+ tracker: identity.tracker,
+ scope: identity.scope,
+ issueKey: identity.issueKey,
+ ...(typeof value?.host === "string" ? { host: value.host } : {}),
+ };
+}
+
+function sameIssue(left: IssueSelection | undefined, right: Omit) {
+ return (
+ left !== undefined &&
+ ISSUE_IDENTITY.every((key) => left[key] === right[key]) &&
+ left.host === right.host
+ );
+}
+
/**
- * The page each tab shows in a root route chain: the newest chat, the newest pull request, and
+ * The page each tab shows in a root route chain: the newest chat, pull request and issue, and
* the Settings page directly above the Settings route. Returns `previous` when nothing changed.
*/
export function workspaceSelectionsIn(
@@ -121,6 +147,11 @@ export function workspaceSelectionsIn(
) {
next = { ...next, pullRequests: { ...pullRequest, key: pullRequestRoute?.key } };
}
+ const issueRoute = routes.findLast((route) => route.name === "Issue");
+ const issue = issueSelection(issueRoute?.params);
+ if (issue && (!sameIssue(next.issues, issue) || next.issues?.key !== issueRoute?.key)) {
+ next = { ...next, issues: { ...issue, key: issueRoute?.key } };
+ }
const list = routes.findIndex((route) => route.name === "Settings");
const page = list < 0 ? undefined : routes[list + 1];
if (
@@ -133,7 +164,10 @@ export function workspaceSelectionsIn(
return next;
}
-function identityOf({ key: _key, ...identity }: PullRequestSelection) {
+function identityOf({
+ key: _key,
+ ...identity
+}: Selection) {
return identity;
}
@@ -148,7 +182,7 @@ function keyed(route: Route): Route & { readonly key: s
function detailRoute(
state: { readonly routes: readonly RouteLike[] },
- tab: "threads" | "pullRequests",
+ tab: "threads" | "pullRequests" | "issues",
selections: WorkspaceSelections,
check: WorkspaceSelectionCheck,
seedPullRequest?: (identity: Omit) => object,
@@ -179,6 +213,16 @@ function detailRoute(
}
);
}
+ if (tab === "issues") {
+ const issue = selections.issues;
+ if (!issue || !check.issue(issue)) return undefined;
+ return (
+ state.routes.find((route) => {
+ const shown = route.name === "Issue" ? issueSelection(route.params) : undefined;
+ return shown !== undefined && sameIssue(issue, shown);
+ }) ?? { key: issue.key, name: "Issue", params: identityOf(issue) }
+ );
+ }
const pullRequest = selections.pullRequests;
if (!pullRequest || !check.pullRequest(pullRequest)) return undefined;
return (
diff --git a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts
index 797da115a20eb..20e4e2fe90b5a 100644
--- a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts
+++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts
@@ -63,7 +63,12 @@ describe("workspace root navigation", () => {
});
it("keeps the registered defaults while customization is unavailable", () => {
- expect(resolveWorkspaceTabOrder(undefined)).toEqual(["threads", "pullRequests", "usage"]);
+ expect(resolveWorkspaceTabOrder(undefined)).toEqual([
+ "threads",
+ "pullRequests",
+ "issues",
+ "usage",
+ ]);
expect(resolveWorkspaceTabOrder(["home", "usage", "issues", "threads"])).toEqual(
resolveWorkspaceTabOrder(undefined),
);
@@ -245,7 +250,8 @@ vi.mock("../../pull-requests/PullRequestFilterMenuContext", () => ({
createElement("div", null, children),
}));
vi.mock("../../pull-requests/PullRequestsScreen", () => ({ PullRequestsScreen: () => null }));
-vi.mock("./PullRequestToolbar.android", () => ({ PullRequestToolbar: () => null }));
+vi.mock("../../issues/IssuesScreen", () => ({ IssuesScreen: () => null }));
+vi.mock("./ListSearchToolbar.android", () => ({ ListSearchToolbar: () => null }));
vi.mock("./AndroidWorkspaceNavigation", () => ({
AndroidWorkspaceNavigation: () => null,
FloatingWorkspaceTabs: () => null,
diff --git a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts
index 3f6754c679c5a..77a80e89cdf35 100644
--- a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts
+++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts
@@ -16,9 +16,9 @@ export type WorkspaceTab = NavigationTab;
export type WorkspaceSearchTab = WorkspaceTab | "settings";
export type WorkspaceSearchMode = "resting" | "active" | "expanded";
-/** Settings, Chats and pull requests pair a list with a detail column; Usage is one page. */
+/** Settings, Chats, pull requests and issues pair a list with a detail column; Usage is one page. */
export function workspaceUsesColumns(tab: WorkspaceTab, settingsOpen = false) {
- return settingsOpen || tab === "threads" || tab === "pullRequests";
+ return settingsOpen || tab !== "usage";
}
export interface WorkspaceTabState {
@@ -40,6 +40,7 @@ function emptyTab(): WorkspaceTabState {
/** The list a workspace page belongs to. */
export function workspaceTabOf(routeName: string | undefined): WorkspaceTab {
+ if (routeName === "Issue") return "issues";
return routeName === "PullRequest" ? "pullRequests" : "threads";
}
@@ -52,6 +53,7 @@ export function initialWorkspaceTabsState(
tabs: {
threads: emptyTab(),
pullRequests: emptyTab(),
+ issues: emptyTab(),
usage: emptyTab(),
settings: emptyTab(),
},
@@ -79,7 +81,7 @@ export function workspaceTabsReducer(
selectionRevision: state.selectionRevision + 1,
};
const tab = action.tab ?? state.selectedTab;
- if (tab !== "threads" && tab !== "pullRequests" && tab !== "settings") return state;
+ if (tab === "usage") return state;
const current = state.tabs[tab];
let next: WorkspaceTabState;
switch (action.type) {
diff --git a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx
index 6bdb399d0ad30..931ac773ed27c 100644
--- a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx
+++ b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx
@@ -33,6 +33,27 @@ import {
} from "./pullRequestStatus";
import { PullRequestGlyph } from "./pullRequestGlyphs";
+/** A label as list rows show it: small, tinted by its color, truncating. */
+export function ListRowLabel(props: {
+ readonly label: { readonly name: string; readonly color: string | null };
+ readonly textClassName: string;
+}) {
+ const color = pullRequestLabelColor(props.label.color);
+ return (
+
+ {color ? (
+
+ ) : null}
+
+ {props.label.name}
+
+
+ );
+}
+
export const PullRequestListRow = memo(
function PullRequestListRow(props: {
readonly item: EnvironmentPullRequestListEntry;
@@ -144,31 +165,9 @@ export const PullRequestListRow = memo(
- {item.labels.slice(0, 2).map((label) => {
- const color = pullRequestLabelColor(label.color);
- return (
-
- {color ? (
-
- ) : null}
-
- {label.name}
-
-
- );
- })}
+ {item.labels.slice(0, 2).map((label) => (
+
+ ))}
{item.labels.length > 2 ? (
+{item.labels.length - 2}
diff --git a/apps/mobile/src/features/pull-requests/PullRequestReactions.tsx b/apps/mobile/src/features/pull-requests/PullRequestReactions.tsx
index 722a435d2f118..c02c40b7beac9 100644
--- a/apps/mobile/src/features/pull-requests/PullRequestReactions.tsx
+++ b/apps/mobile/src/features/pull-requests/PullRequestReactions.tsx
@@ -29,16 +29,13 @@ import { usePullRequestPermissions } from "./usePullRequestPermissions";
const EMPTY_PENDING: ReadonlyMap = new Map();
const PILL = "min-h-8 flex-row items-center gap-1 rounded-full border px-2.5 active:opacity-70";
-function ReactionPills(props: {
- readonly params: PullRequestParams;
+/** Reaction pills for any subject; `onReact` resolves false when the host refused the change. */
+export function ReactionPills(props: {
readonly reactions: ReadonlyArray;
readonly canReact: boolean;
- /** Absent reacts to the change request itself. */
- readonly subjectId?: string;
- readonly onChanged: () => void;
+ readonly onReact: (content: PullRequestReactionContent, reacted: boolean) => Promise;
+ readonly onChanged?: () => void;
}) {
- const target = pullRequestTarget(props.params);
- const setReaction = useAtomCommand(composerPullRequests.setReaction, { reportFailure: false });
// Keyed by what the host last reported, so a refresh that lands the change drops the overlay.
const [pending, setPending] = useState({ signature: "", values: EMPTY_PENDING });
const signature = pullRequestReactionsSignature(props.reactions);
@@ -46,16 +43,7 @@ function ReactionPills(props: {
const shown = applyPendingPullRequestReactions(props.reactions, values);
const toggle = async (content: PullRequestReactionContent, reacted: boolean) => {
setPending({ signature, values: new Map([...values, [content, reacted]]) });
- const result = await setReaction({
- ...target,
- input: {
- ...target.input,
- ...(props.subjectId === undefined ? {} : { subjectId: props.subjectId }),
- content,
- reacted,
- },
- });
- if (AsyncResult.isFailure(result)) {
+ if (!(await props.onReact(content, reacted))) {
setPending((current) => {
const next = new Map(current.values);
next.delete(content);
@@ -64,7 +52,7 @@ function ReactionPills(props: {
Dialog.alert("Couldn't save the reaction", "Try again in a moment.");
return;
}
- props.onChanged();
+ props.onChanged?.();
};
return (
<>
@@ -117,6 +105,23 @@ function ReactionPills(props: {
);
}
+function usePullRequestReact(params: PullRequestParams, subjectId?: string) {
+ const target = pullRequestTarget(params);
+ const setReaction = useAtomCommand(composerPullRequests.setReaction, { reportFailure: false });
+ return async (content: PullRequestReactionContent, reacted: boolean) =>
+ !AsyncResult.isFailure(
+ await setReaction({
+ ...target,
+ input: {
+ ...target.input,
+ ...(subjectId === undefined ? {} : { subjectId }),
+ content,
+ reacted,
+ },
+ }),
+ );
+}
+
/** Reactions on the change request itself, which is where its description's reactions live. */
export function PullRequestReactions(props: { readonly params: PullRequestParams }) {
const target = pullRequestTarget(props.params);
@@ -124,13 +129,14 @@ export function PullRequestReactions(props: { readonly params: PullRequestParams
const activity = useEnvironmentQuery(composerPullRequests.activity(target));
const { canReact } = usePullRequestPermissions(props.params, detail.data);
const reactions = activity.data?.reactions ?? [];
+ const onReact = usePullRequestReact(props.params);
if (!activity.data || (reactions.length === 0 && !canReact)) return null;
return (
@@ -156,14 +162,14 @@ export function PullRequestCommentFooter(props: {
const editableKind = comment.kind === "review" ? null : comment.kind;
const canEdit = editableKind !== null && props.permissions.canEditComment(comment);
const canReact = props.permissions.canReactComment(comment);
+ const onReact = usePullRequestReact(props.params, comment.id);
if (reactions.length === 0 && !canReact && !canEdit) return null;
return (
{canEdit && editableKind !== null ? (
diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index fdd6b414a46e5..570ec4cdd5566 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -1,13 +1,7 @@
-import { useSidebarWorkspaceTabs } from "../layout/workspace/useSystemWorkspaceTabBar";
import { Button } from "../../components/ui/Button";
import * as Dialog from "../../components/ui/Dialog";
import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
-import { useDeviceType } from "../../native/native-layout-metrics";
-import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
-import {
- useAppearancePreferences,
- useAndroidFloatingTabs,
-} from "../settings/appearance/AppearancePreferencesProvider";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { AsyncResult } from "effect/reactivity";
@@ -34,7 +28,7 @@ import { parseSideChatCommand } from "@t3tools/shared/composerTrigger";
import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
import type { ReactNode } from "react";
import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "react";
-import { I18nManager, Keyboard, Platform, View, type ViewStyle } from "react-native";
+import { Keyboard, Platform, View } from "react-native";
import { composerImageGallery } from "../../lib/composerImageGallery";
import { filePreviewGallery } from "../../lib/filePreviewGallery";
import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal";
@@ -43,16 +37,7 @@ import {
composerAttachmentsStillUploading,
composerAttachmentUploadsAtom,
} from "../../state/composer-attachment-uploads";
-import Animated, {
- FadeIn,
- FadeOut,
- type LayoutAnimationFunction,
- ReduceMotion,
- useAnimatedStyle,
- useDerivedValue,
- useSharedValue,
- withTiming,
-} from "react-native-reanimated";
+import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { themeColorWithAlpha } from "../../lib/mobileTheme";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
@@ -78,8 +63,15 @@ import {
ComposerAttachmentThumbnail,
} from "../../components/ComposerAttachmentStrip";
import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal";
-import { GlassSurface } from "../../components/ui/GlassSurface";
import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
+import {
+ COMPOSER_BOTTOM_GUTTER,
+ COMPOSER_TRANSITION_DURATION_MS,
+ ComposerSurface,
+ INLINE_EDITOR_MIN_WIDTH,
+ useComposerChrome,
+ useCompactComposerEditor,
+} from "../../components/ComposerSurface";
import { fileRoutePathSegments } from "../files/filePath";
import {
ComposerActionButton,
@@ -97,9 +89,8 @@ import {
isModelSelectionUnavailable,
serverConfigForCwd,
} from "../../lib/modelOptions";
-import { useComposerTextRole, type ScaledTextRole } from "../settings/appearance/useScaledTextRole";
+import { useComposerTextRole } from "../settings/appearance/useScaledTextRole";
import type { RemoteClientConnectionState } from "../../lib/connection";
-import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
import { Menu } from "../../components/ui/Menu";
import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
import type { FollowUpBehavior } from "../../lib/followUpBehavior";
@@ -107,7 +98,6 @@ import {
resolveComposerSendPresentation,
type ComposerSendPresentation,
} from "./composerSendPresentation";
-import { compactEditorInsets } from "./compactEditorInsets";
import { ComposerCommandPopover } from "./ComposerCommandPopover";
import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor";
import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit";
@@ -132,86 +122,9 @@ import {
} from "./use-thread-settings-sheet-presentation";
export const COMPOSER_COLLAPSED_CHROME = 60;
+export const THREAD_COMPOSER_BOTTOM_GUTTER = COMPOSER_BOTTOM_GUTTER;
const NO_MACHINES: ReadonlyArray = [];
-const COLLAPSED_EDITOR_DEFAULT_INSET = 6;
-// iOS puts line-height slack above glyphs. DM Sans's lowercase midline is
-// descender (0.31em) + half x-height (0.504em) above the line bottom.
-const LOWERCASE_MIDLINE_EM = 0.562;
-
-function lowercaseMidline(text: ScaledTextRole): number {
- return text.lineHeight - LOWERCASE_MIDLINE_EM * text.fontSize;
-}
-
-function collapsedEditorInset(text: ScaledTextRole): number {
- const shift = lowercaseMidline(text) - lowercaseMidline(MOBILE_TYPOGRAPHY.body);
- return Math.max(0, Math.round(COLLAPSED_EDITOR_DEFAULT_INSET - shift));
-}
-
-function useCompactComposerEditor(text: ScaledTextRole, value: string) {
- const [contentSize, setContentSize] = useState({ width: 0, height: 0 });
- const [compactWidth, setCompactWidth] = useState(0);
- const contentHeight = contentSize.height;
- const rowWidth = useSharedValue(0);
- const editorX = useSharedValue(0);
- const editorWidth = useSharedValue(0);
- const inset =
- Platform.OS === "android" ? COLLAPSED_EDITOR_DEFAULT_INSET : collapsedEditorInset(text);
- const minimum = Math.max(INLINE_EDITOR_HEIGHT, Math.ceil(text.lineHeight) + 2 * inset);
- const height = Math.max(
- minimum,
- Math.min(text.lineHeight * 4 + 2 * inset, contentHeight + 2 * inset),
- );
- // A line that fits the expanded editor may still overflow its compact slot.
- // Compare its measured width too so widening cannot trigger a collapse loop.
- const isExpanded =
- value.length > 0 &&
- (contentHeight > text.lineHeight * 1.5 ||
- (compactWidth > 0 && contentSize.width > compactWidth));
- const compactRowHeight = Math.max(44, minimum);
- const isRTL = I18nManager.isRTL;
- const editorStyle = useAnimatedStyle(() => ({
- position: "absolute",
- overflow: "hidden",
- height,
- ...(isExpanded
- ? { left: 14, right: 14 }
- : compactEditorInsets({
- slotX: editorX.value,
- slotWidth: editorWidth.value,
- rowWidth: rowWidth.value,
- isRTL,
- })),
- // The full-width editor must move above the controls in the same frame it widens.
- bottom: isExpanded ? 52 : (compactRowHeight - minimum) / 2,
- }));
- return {
- isExpanded,
- containerStyle: { height: isExpanded ? height + 52 : compactRowHeight },
- editorStyle,
- onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => {
- rowWidth.set(event.nativeEvent.layout.width);
- },
- onEditorLayout: (event: { nativeEvent: { layout: { x: number; width: number } } }) => {
- // The expanded toolbar gives this slot to the model control. Keep the
- // compact geometry for the return transition, rather than recording zero.
- if (!isExpanded) {
- editorX.set(event.nativeEvent.layout.x);
- editorWidth.set(event.nativeEvent.layout.width);
- setCompactWidth(Math.max(0, event.nativeEvent.layout.width - 8));
- }
- },
- editorProps: {
- singleLineCentered: !isExpanded,
- scrollEnabled: Platform.OS === "android" || contentHeight >= text.lineHeight * 4,
- contentInsetVertical: inset,
- onContentSizeChange: (size: { width: number; height: number }) => {
- setContentSize(size);
- },
- style: { flex: 1 },
- },
- };
-}
export interface ThreadComposerProps {
readonly isDraft?: boolean;
@@ -274,42 +187,11 @@ export interface ThreadComposerProps {
readonly onEditorFocusChange?: (focused: boolean) => void;
}
-// Android KeyboardStickyView tracks the IME; concurrent timed layout morphs cause jitter.
-const COMPOSER_TRANSITION_DURATION_MS = 220;
-// Side panes animate dock width; nested horizontal transitions would make the surface trail the toolbar.
-const composerHeightTransition: LayoutAnimationFunction = (values) => {
- "worklet";
- const timing = {
- duration: COMPOSER_TRANSITION_DURATION_MS,
- reduceMotion: ReduceMotion.System,
- };
- return {
- initialValues: {
- originX: values.targetOriginX,
- originY: values.currentOriginY,
- width: values.targetWidth,
- height: values.currentHeight,
- },
- animations: {
- originX: values.targetOriginX,
- originY: withTiming(values.targetOriginY, timing),
- width: values.targetWidth,
- height: withTiming(values.targetHeight, timing),
- },
- };
-};
-const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : composerHeightTransition;
-// Share of the one-row composer the draft keeps; a long model name truncates first.
-const INLINE_EDITOR_MIN_WIDTH = "40%";
-const INLINE_EDITOR_HEIGHT = 36;
-
const COMPOSER_ATTACHMENT_ENTERING =
Platform.OS === "android"
? FadeIn.duration(160)
: FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System);
-const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface);
-
const FOLLOW_UP_ACTION_LABEL = {
queue: "Queue",
steer: "Steer now",
@@ -365,70 +247,6 @@ function SendActionButton(props: {
);
}
-function ComposerSurface(props: {
- readonly children: ReactNode;
- readonly style: ViewStyle;
- readonly animateLayout?: boolean;
-}) {
- const colors = useUniwindTheme();
- const targetBorderRadius =
- typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;
- const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android";
- const animatedBorderRadius = useDerivedValue(() =>
- shouldAnimate
- ? withTiming(targetBorderRadius, {
- duration: COMPOSER_TRANSITION_DURATION_MS,
- reduceMotion: ReduceMotion.System,
- })
- : targetBorderRadius,
- );
- const borderCurve = props.style.borderCurve;
- const animatedShapeStyle = useAnimatedStyle(() => ({
- borderRadius: animatedBorderRadius.value,
- ...(borderCurve ? { borderCurve } : {}),
- }));
- const layoutTransition = shouldAnimate ? COMPOSER_LAYOUT_TRANSITION : undefined;
-
- // Animate every native frame or glass and content jump to final height inside the clip.
- return (
-
-
- {null}
-
-
- {props.children}
-
-
- );
-}
-
-/** Space below the composer pill, inside the bottom inset it is given. */
-export const THREAD_COMPOSER_BOTTOM_GUTTER = 6;
-
export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps) {
const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId));
const { themeVariables: materialTheme } = useAppearancePreferences();
@@ -612,12 +430,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
);
const isVoiceInputPresented = voicePresentation.statusKind === "active";
const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded;
- const { layout } = useAdaptiveWorkspaceLayout();
- const floatingSidebarComposer = useAndroidFloatingTabs() && layout.usesSplitView;
- const sidebarWorkspaceTabs = useSidebarWorkspaceTabs();
- const deviceType = useDeviceType();
- const usesLargeControls = sidebarWorkspaceTabs === "picker" || deviceType === "pad";
- const surfacePaddingVertical = usesLargeControls ? 9 : floatingSidebarComposer ? 6 : 2;
+ const { usesLargeControls, paddingVertical: surfacePaddingVertical } = useComposerChrome();
const [compactRowWidth, setCompactRowWidth] = useState(0);
const compactAttachmentLimit = compactRowWidth >= 500 ? 3 : 1;
const compactEditor = useCompactComposerEditor(bodyText, draftMessage);
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index 73b985fc20144..75b49e9ec5876 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -116,20 +116,13 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useBarOverlap } from "../../components/ui/useBarInset";
import {
useWorkspaceContentWidth,
WorkspaceSettledContentWidthContext,
} from "../layout/workspace-content-width";
-import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context";
import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
-import {
- useDeviceType,
- useDuoBottomBarInset,
- useNativeColumnLayoutMetrics,
- useNativeLayoutMetrics,
-} from "../../native/native-layout-metrics";
+import { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
import type { ComposerEditorHandle } from "../../components/ComposerEditor";
@@ -196,9 +189,9 @@ import {
ESTIMATED_KEYBOARD_HEIGHT,
USER_INPUT_TOGGLE_DURATION_MS,
} from "./pendingUserInputLayout";
+import { useComposerBottomInset } from "../../components/ComposerSurface";
import {
COMPOSER_COLLAPSED_CHROME,
- THREAD_COMPOSER_BOTTOM_GUTTER,
ThreadComposer,
type ThreadComposerProps,
} from "./ThreadComposer";
@@ -410,7 +403,6 @@ function ThreadDetailBody(
},
) {
const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
- const { layout } = useAdaptiveWorkspaceLayout();
const deviceType = useDeviceType();
const workspace = useWorkspaceTabs();
const sidebarSearchActive =
@@ -457,7 +449,6 @@ function ThreadDetailBody(
...(tabId === undefined ? {} : { tabId }),
});
};
- const insets = useSafeAreaInsets();
const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
const liveKeyboardHeight = useKeyboardState((state) => state.height);
// Android can lose IME hide callbacks on backgrounding. Quarantine translation on resume unless focused input retained its keyboard.
@@ -495,7 +486,6 @@ function ThreadDetailBody(
const navigationHeaderHeight = useBarOverlap();
const [screenMetrics, setScreenMetrics] = useState(null);
const columnMetrics = useNativeColumnLayoutMetrics();
- const windowMetrics = useNativeLayoutMetrics();
const nativeMetrics = usesNativeWorkspaceColumns ? columnMetrics : screenMetrics;
const controlInsets = deriveBottomControlInsets(nativeMetrics);
const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
@@ -545,27 +535,8 @@ function ThreadDetailBody(
const [isAtEnd, setIsAtEnd] = useState(true);
const [sendingWithKeyboard, setSendingWithKeyboard] = useState(false);
if (sendingWithKeyboard && !isKeyboardVisible) setSendingWithKeyboard(false);
- // Keep the bottom padding fixed while the keyboard animates. Changing it at
- // keyboardDidHide makes the composer land low, then jump back above the safe area.
- const bottomBarInset = useDuoBottomBarInset();
const hasSidebarTabBar = useSidebarWorkspaceTabs() === "picker";
- // A pushed screen temporarily inherits the outgoing tab bar's safe area.
- // Use the window edge so hiding that bar cannot move the composer mid-push.
- const bottomSafeArea =
- Platform.OS === "ios" ? (windowMetrics?.safeArea.bottom ?? insets.bottom) : insets.bottom;
- // The pill already adds its own gutter; iPad and Duo align its edge with the native bar.
- const alignedBottomInset =
- bottomBarInset ??
- (Platform.OS === "ios" && Platform.isPad
- ? Math.max(bottomSafeArea, 12) - (layout.usesSplitView ? 5 : 0)
- : undefined);
- // The Duo's floating sidebar pill ends three points below the composer's usual gutter.
- const composerBottomInset = Math.max(
- 0,
- (alignedBottomInset !== undefined
- ? alignedBottomInset - THREAD_COMPOSER_BOTTOM_GUTTER
- : Math.max(bottomSafeArea, 12)) - (hasSidebarTabBar ? 3 : 0),
- );
+ const composerBottomInset = useComposerBottomInset();
const composerKeyboardOffset = composerBottomInset;
const contentPresentationKind = props.contentPresentation.kind;
const realThreadSyncLabel = (() => {
diff --git a/apps/mobile/src/lib/android-workspace-navigation.ts b/apps/mobile/src/lib/android-workspace-navigation.ts
index 170fcc3acc664..92955b5fa1b82 100644
--- a/apps/mobile/src/lib/android-workspace-navigation.ts
+++ b/apps/mobile/src/lib/android-workspace-navigation.ts
@@ -76,7 +76,8 @@ export function androidWorkspaceDestinationAnimation(
usesSplitView: boolean,
) {
if (!usesSplitView) return undefined;
- if (route.name === "Thread" || route.name === "PullRequest") return "none" as const;
+ if (route.name === "Thread" || route.name === "PullRequest" || route.name === "Issue")
+ return "none" as const;
if (route.name === "Settings" || !isSettingsRoute(route.name)) return undefined;
const below = routes[routes.findIndex((candidate) => candidate.key === route.key) - 1];
return below?.name === "Settings" ? ("none" as const) : ("fade" as const);
diff --git a/apps/mobile/src/native/v5-workspace-context.tsx b/apps/mobile/src/native/v5-workspace-context.tsx
index 4c4dbf562f0e7..f1f75ef21cd31 100644
--- a/apps/mobile/src/native/v5-workspace-context.tsx
+++ b/apps/mobile/src/native/v5-workspace-context.tsx
@@ -33,6 +33,7 @@ export const NativePrimaryColumnContext = createContext<{
readonly presented: boolean;
readonly selectedThreadKey: string | null;
readonly selectedPullRequestKey: string | null;
+ readonly selectedIssueKey: string | null;
} | null>(null);
export const NativeWorkspaceInspectorContext = createContext<{
readonly content: InspectorContent | undefined;
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index f29fbc8c119b4..7611bcf8f987c 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -72,6 +72,17 @@ const WorkspaceSelections = Schema.Struct({
key: Schema.optional(Schema.String),
}),
),
+ issues: Schema.optional(
+ Schema.Struct({
+ environmentId: Schema.String,
+ projectId: Schema.String,
+ tracker: Schema.String,
+ host: Schema.optional(Schema.String),
+ scope: Schema.String,
+ issueKey: Schema.String,
+ key: Schema.optional(Schema.String),
+ }),
+ ),
settings: Schema.optional(Schema.String),
settingsKey: Schema.optional(Schema.String),
/** A draft the threads tab showed after its last chat; only meaningful in its session. */
diff --git a/apps/mobile/src/state/issues.ts b/apps/mobile/src/state/issues.ts
new file mode 100644
index 0000000000000..5125af2621cd6
--- /dev/null
+++ b/apps/mobile/src/state/issues.ts
@@ -0,0 +1,29 @@
+import {
+ createIssueEnvironmentAtoms,
+ createMergedIssueListAtoms,
+ type IssueListTarget,
+} from "@t3tools/client-runtime/state/issues";
+import { useAtomValue } from "@effect/atom-react";
+import type { EnvironmentId } from "@t3tools/contracts";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+import { appAtomRegistry } from "./atom-registry";
+import { formatEnvironmentQueryError } from "./query";
+
+export const issueEnvironment = createIssueEnvironmentAtoms(connectionAtomRuntime);
+
+const mergedIssueLists = createMergedIssueListAtoms(
+ issueEnvironment.list,
+ formatEnvironmentQueryError,
+);
+
+/** One listing read across every environment asked. */
+export function useIssueList(targets: ReadonlyArray) {
+ const view = useAtomValue(mergedIssueLists.atom(targets));
+ return { ...view, refresh: () => mergedIssueLists.refresh(appAtomRegistry, targets) };
+}
+
+/** After a write the list cannot see, such as a new issue filed from a sheet. */
+export function refreshIssueLists(environmentId: EnvironmentId) {
+ mergedIssueLists.refreshEnvironment(appAtomRegistry, environmentId);
+}
diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts
index 8c3a75fb3bf45..ca807e804780a 100644
--- a/apps/mobile/src/state/query.ts
+++ b/apps/mobile/src/state/query.ts
@@ -16,7 +16,11 @@ export interface EnvironmentQueryView {
readonly refresh: () => void;
}
-function formatError(cause: Cause.Cause): string {
+/** What a request cut off by a dropped connection says; it carries no error of its own. */
+export const ENVIRONMENT_REQUEST_INTERRUPTED = "The connection dropped before this finished.";
+
+export function formatEnvironmentQueryError(cause: Cause.Cause): string {
+ if (Cause.hasInterruptsOnly(cause)) return ENVIRONMENT_REQUEST_INTERRUPTED;
const error = Cause.squash(cause);
return error instanceof Error && error.message.trim().length > 0
? error.message
@@ -34,7 +38,7 @@ export function useEnvironmentQuery(
// Data may be structurally shared; only a settled success proves a new response.
successResult: result._tag === "Success" && !result.waiting ? result : null,
failure: result._tag === "Failure" ? Cause.squash(result.cause) : null,
- error: result._tag === "Failure" ? formatError(result.cause) : null,
+ error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null,
isPending: atom !== null && result.waiting,
isSuccess: result._tag === "Success",
refresh,
diff --git a/apps/web/src/components/issue/IssueCreateDialog.tsx b/apps/web/src/components/issue/IssueCreateDialog.tsx
index e85775bbb5643..39a1bd19d0e79 100644
--- a/apps/web/src/components/issue/IssueCreateDialog.tsx
+++ b/apps/web/src/components/issue/IssueCreateDialog.tsx
@@ -1,6 +1,7 @@
+import type { IssueCreateScope } from "@t3tools/client-runtime/issue-list";
import { useAtomValue } from "@effect/atom-react";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
-import type { EnvironmentId, IssueCreateResult, IssueScopeRef } from "@t3tools/contracts";
+import type { IssueCreateResult } from "@t3tools/contracts";
import { useState } from "react";
import { issueEnvironment } from "~/state/issues";
@@ -24,13 +25,6 @@ import { Textarea } from "../ui/textarea";
import { toastManager } from "../ui/toast";
import { readableFailure } from "../pullRequest/pullRequestDetail.logic";
-export interface IssueCreateScope {
- readonly environmentId: EnvironmentId;
- readonly projectTitle: string;
- readonly scope: IssueScopeRef;
- readonly templates: boolean;
-}
-
const BLANK = "__blank__";
/**
diff --git a/apps/web/src/components/issue/IssueDetailPanel.tsx b/apps/web/src/components/issue/IssueDetailPanel.tsx
index b790c1f5b71bc..991ade5b9f4c6 100644
--- a/apps/web/src/components/issue/IssueDetailPanel.tsx
+++ b/apps/web/src/components/issue/IssueDetailPanel.tsx
@@ -1,4 +1,9 @@
import { useAtomValue } from "@effect/atom-react";
+import {
+ issueCloseReasonLabel,
+ issueEventSentence,
+ issueWriteAccess,
+} from "@t3tools/client-runtime/issue-presentation";
import { prependIssueComments } from "@t3tools/client-runtime/state/issues";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { AuthOrchestrationOperateScope, threadIssueKeysEqual } from "@t3tools/contracts";
@@ -59,11 +64,7 @@ import {
import { IssueDetailGhost } from "./IssueDetailGhost";
import { ReactionBar } from "../pullRequest/PullRequestReactions";
import { PullRequestsUnavailableState } from "../pullRequest/PullRequestsUnavailableState";
-import {
- issueCloseReasonLabel,
- IssueStateGlyph,
- issueTrackerPresentation,
-} from "./issuePresentation";
+import { IssueStateGlyph, issueTrackerPresentation } from "./issuePresentation";
/**
* Links or unlinks the issue to the thread it is shown beside. Dispatches the orchestration
@@ -138,14 +139,6 @@ function IssueThreadLinkButton({
);
}
-/** Why a control is off, said once so each disabled affordance can point at it. */
-function writeBlockReason(detail: IssueDetail, canWrite: boolean): string | null {
- if (!canWrite) return "Writing to this tracker needs the source-control write scope.";
- if (!detail.writable)
- return "This issue is outside every project's own scope, so it is read-only here.";
- return null;
-}
-
function IssueReactionBar({
reactions,
canReact,
@@ -352,31 +345,6 @@ function IssueAssigneePicker({
);
}
-function eventSentence(event: IssueEvent): string {
- switch (event.kind) {
- case "closed":
- return event.detail
- ? `closed this as ${issueCloseReasonLabel(event.detail).toLowerCase()}`
- : "closed this";
- case "reopened":
- return "reopened this";
- case "labeled":
- return event.detail ? `added the ${event.detail} label` : "added a label";
- case "unlabeled":
- return event.detail ? `removed the ${event.detail} label` : "removed a label";
- case "assigned":
- return event.detail ? `assigned ${event.detail}` : "assigned someone";
- case "unassigned":
- return event.detail ? `unassigned ${event.detail}` : "unassigned someone";
- case "renamed":
- return event.detail ? `renamed this to “${event.detail}”` : "renamed this";
- case "referenced":
- return event.detail ? `referenced this from ${event.detail}` : "referenced this";
- case "state-changed":
- return event.detail ? `moved this to ${event.detail}` : "changed the state";
- }
-}
-
type TimelineItem =
| { kind: "comment"; at: string; comment: IssueComment }
| { kind: "event"; at: string; event: IssueEvent };
@@ -477,19 +445,16 @@ export function IssueDetailPanel({
...detail.events.map((e): TimelineItem => ({ kind: "event", at: e.createdAt, event: e })),
].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
- const blocked = writeBlockReason(detail, canWrite);
- const may = (capability: boolean, permission: boolean) =>
- blocked === null && capability && permission;
- const canComment = may(detail.capabilities.comment, detail.viewerPermissions.comment);
- const canEdit = may(detail.capabilities.edit, detail.viewerPermissions.edit);
- const canLabel = may(detail.capabilities.labels, detail.viewerPermissions.labels);
- const canAssign = may(detail.capabilities.assignees, detail.viewerPermissions.assignees);
- const canReact = blocked === null && detail.capabilities.reactions;
- const canSetState = may(detail.capabilities.states.length > 0, detail.viewerPermissions.setState);
+ const access = issueWriteAccess(detail, canWrite);
+ const { blocked } = access;
+ const canComment = access.comment;
+ const canEdit = access.edit;
+ const canLabel = access.labels;
+ const canAssign = access.assignees;
+ const canReact = access.react;
+ const canSetState = access.states.length > 0;
+ const otherStates = access.states;
const tracker = issueTrackerPresentation(detail.tracker);
- const otherStates = detail.capabilities.states.filter(
- (state) => state.category !== detail.state.category || state.name !== detail.state.name,
- );
const closingStates = otherStates.filter((state) => state.category === "closed");
const openingStates = otherStates.filter((state) => state.category === "open");
@@ -883,7 +848,7 @@ export function IssueDetailPanel({
className="px-1 text-xs text-muted-foreground"
>
- {eventSentence(item.event)}
+ {issueEventSentence(item.event)}
{formatRelativeTimeLabel(item.event.createdAt)}
),
diff --git a/apps/web/src/components/issue/IssueRow.tsx b/apps/web/src/components/issue/IssueRow.tsx
index 28c1b68517acc..245d50ac968ba 100644
--- a/apps/web/src/components/issue/IssueRow.tsx
+++ b/apps/web/src/components/issue/IssueRow.tsx
@@ -1,4 +1,4 @@
-import type { EnvironmentId, IssueListEntry } from "@t3tools/contracts";
+import type { EnvironmentIssueEntry } from "@t3tools/client-runtime/issue-list";
import { MessageSquareIcon } from "lucide-react";
import { memo } from "react";
@@ -16,10 +16,6 @@ import {
} from "../pullRequest/pullRequestPresentation";
import { IssueStateGlyph, issueTrackerPresentation } from "./issuePresentation";
-export interface EnvironmentIssueEntry extends IssueListEntry {
- readonly environmentId: EnvironmentId;
-}
-
export type IssueRowTarget = Pick<
EnvironmentIssueEntry,
"environmentId" | "projectId" | "tracker" | "host" | "scope" | "key" | "url"
diff --git a/apps/web/src/components/issue/IssuesPage.tsx b/apps/web/src/components/issue/IssuesPage.tsx
index c988ab12adc56..841f3b9c85e0a 100644
--- a/apps/web/src/components/issue/IssuesPage.tsx
+++ b/apps/web/src/components/issue/IssuesPage.tsx
@@ -1,23 +1,21 @@
import { useAtomValue } from "@effect/atom-react";
import {
- issueEntryKey,
- mergeIssueListErrors,
- mergeIssueListPage,
-} from "@t3tools/client-runtime/state/issues";
-import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
+ continueIssuePage,
+ groupIssues,
+ holdIssuePages,
+ issueCreateScopes,
+ issueTrackerKey,
+ type HeldIssuePage,
+} from "@t3tools/client-runtime/issue-list";
+import { issueEntryKey } from "@t3tools/client-runtime/state/issues";
import type {
EnvironmentId,
IssueInvolvement,
- IssueListCursors,
- IssueListEntry,
IssueListInput,
- IssueListProjectError,
- IssueListResult,
IssueListState,
IssueTrackerKind,
IssueTrackerSummary,
ProjectId,
- SourceControlProviderKind,
} from "@t3tools/contracts";
import { useNavigate, useSearch } from "@tanstack/react-router";
import {
@@ -33,13 +31,9 @@ import {
import { Fragment, useEffect, useState, type ReactNode } from "react";
import { RefreshIcon } from "~/components/ui/refresh-icon";
-import { IssueCreateDialog, type IssueCreateScope } from "~/components/issue/IssueCreateDialog";
+import { IssueCreateDialog } from "~/components/issue/IssueCreateDialog";
import { IssueDetailPanel } from "~/components/issue/IssueDetailPanel";
-import {
- IssueRow,
- type EnvironmentIssueEntry,
- type IssueRowTarget,
-} from "~/components/issue/IssueRow";
+import { IssueRow, type IssueRowTarget } from "~/components/issue/IssueRow";
import {
issueListPreferences,
writeIssueListPreferences,
@@ -86,7 +80,6 @@ import {
} from "~/rightPanelStore";
import { useAtomCommand } from "~/state/use-atom-command";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
-import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import { useProjects } from "~/state/entities";
import { useEnvironments, useIssuesSupported } from "~/state/environments";
import { issueEnvironment, useIssueList, type IssueListTarget } from "~/state/issues";
@@ -133,104 +126,6 @@ function getShortcutContext() {
};
}
-/** Rows from every environment that answered, with any continuation pages laid over them. */
-interface HeldPage {
- readonly answer: IssueListResult;
- readonly entries: ReadonlyArray;
- readonly errors: ReadonlyArray;
- readonly nextCursors: IssueListCursors;
- /** Why the last "Load more" failed, until a retry succeeds or a new first page arrives. */
- readonly moreFailure: string | null;
-}
-
-function trackerKey(tracker: Pick): string {
- return `${tracker.tracker} ${tracker.host.toLowerCase()}`;
-}
-
-/** Continuations are laid over the first page they continue; a new first page forgets them. */
-function holdPages(
- answers: ReadonlyArray,
- pages: ReadonlyMap,
-): ReadonlyMap {
- const next = new Map();
- for (const [environmentId, answer] of answers) {
- const previous = pages.get(environmentId);
- next.set(
- environmentId,
- previous?.answer === answer
- ? previous
- : {
- answer,
- entries: answer.entries,
- errors: answer.errors,
- nextCursors: answer.nextCursors,
- moreFailure: null,
- },
- );
- }
- return next;
-}
-
-function groupIssues(
- held: ReadonlyMap,
- query: string,
- projects: ReadonlyArray,
-) {
- const byTracker = new Map<
- string,
- {
- tracker: IssueTrackerSummary;
- rows: EnvironmentIssueEntry[];
- errors: Array<{ projectTitle: string; message: string; environmentId: EnvironmentId }>;
- more: EnvironmentId[];
- }
- >();
- const needle = query.toLowerCase();
- for (const [environmentId, page] of held) {
- for (const tracker of page.answer.trackers) {
- const key = trackerKey(tracker);
- if (!byTracker.has(key)) byTracker.set(key, { tracker, rows: [], errors: [], more: [] });
- const group = byTracker.get(key)!;
- if (!group.tracker.configured && tracker.configured) group.tracker = tracker;
- if (Object.keys(page.nextCursors).some((c) => c.startsWith(`${tracker.host} `)))
- group.more.push(environmentId);
- }
- for (const entry of page.entries) {
- const key = trackerKey(entry);
- const group = byTracker.get(key);
- if (!group) continue;
- // A tracker that cannot search is narrowed here instead.
- if (
- needle.length > 0 &&
- group.tracker.capabilities?.search !== true &&
- !entry.title.toLowerCase().includes(needle) &&
- !entry.displayKey.toLowerCase().includes(needle)
- )
- continue;
- group.rows.push({ ...entry, environmentId });
- }
- for (const error of page.errors) {
- const project = projects.find(
- (p) => p.environmentId === environmentId && p.id === error.projectId,
- );
- const kind = project?.repositoryIdentity?.provider as SourceControlProviderKind | undefined;
- const host = kind
- ? sourceControlClients.changeRequestHost(project?.repositoryIdentity, kind)
- : null;
- const group =
- (host ? byTracker.get(`${kind} ${host.toLowerCase()}`) : undefined) ??
- [...byTracker.values()][0];
- group?.errors.push({ ...error, environmentId });
- }
- }
- for (const group of byTracker.values()) {
- group.rows.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
- }
- return [...byTracker.values()].sort((a, b) =>
- trackerKey(a.tracker).localeCompare(trackerKey(b.tracker)),
- );
-}
-
function hostOptionsFor(groups: ReturnType) {
const hosts = new Map();
for (const group of groups) hosts.set(group.tracker.host, group.tracker);
@@ -248,40 +143,6 @@ function hostOptionsFor(groups: ReturnType) {
];
}
-function createScopesFor(
- held: ReadonlyMap,
- projects: ReadonlyArray,
-): IssueCreateScope[] {
- const scopes: IssueCreateScope[] = [];
- for (const [environmentId, page] of held) {
- for (const project of projects) {
- if (project.environmentId !== environmentId) continue;
- const identity = project.repositoryIdentity;
- const kind = identity?.provider as SourceControlProviderKind | undefined;
- const scope = sourceControlClients.issueScope(identity);
- if (!identity || !kind || scope === null) continue;
- const host = sourceControlClients.changeRequestHost(identity, kind);
- const tracker = page.answer.trackers.find(
- (t) => String(t.tracker) === kind && t.host.toLowerCase() === host.toLowerCase(),
- );
- if (!tracker?.configured || tracker.capabilities?.create !== true) continue;
- if (
- scopes.some(
- (s) => s.scope.scope.toLowerCase() === scope.toLowerCase() && s.scope.host === host,
- )
- )
- continue;
- scopes.push({
- environmentId,
- projectTitle: project.title,
- templates: tracker.capabilities.templates,
- scope: { projectId: project.id, tracker: tracker.tracker, host, scope },
- });
- }
- }
- return scopes;
-}
-
export function IssuesPage() {
const navigate = useNavigate({ from: "/issues" });
const search = useSearch({ from: "/_chat/issues" });
@@ -310,8 +171,8 @@ export function IssuesPage() {
const list = useIssueList(targets);
useLiveRefresh(list.refresh, { key: JSON.stringify(targets) });
- const [pages, setPages] = useState>(new Map());
- const held = holdPages(list.answers, pages);
+ const [pages, setPages] = useState>(new Map());
+ const held = holdIssuePages(list.answers, pages);
const loadPage = useAtomCommand(issueEnvironment.listPage, { reportFailure: false });
const [loadingMore, setLoadingMore] = useState(null);
const loadMore = async (environmentId: EnvironmentId) => {
@@ -328,18 +189,12 @@ export function IssuesPage() {
const next = new Map(current);
next.set(
environmentId,
- result._tag === "Failure"
- ? {
- ...page,
- moreFailure: failure instanceof Error ? failure.message : "The next page failed.",
- }
- : {
- answer: page.answer,
- entries: mergeIssueListPage(page.entries, result.value.entries),
- errors: mergeIssueListErrors(page.errors, result.value.errors),
- nextCursors: result.value.nextCursors,
- moreFailure: null,
- },
+ continueIssuePage(
+ page,
+ result._tag === "Failure"
+ ? { failure: failure instanceof Error ? failure.message : "The next page failed." }
+ : result.value,
+ ),
);
return next;
});
@@ -500,7 +355,7 @@ export function IssuesPage() {
};
// Every writable scope the workspace has, for the composer.
- const createScopes = createScopesFor(held, projects);
+ const createScopes = issueCreateScopes(held, projects);
const [createOpen, setCreateOpen] = useState(false);
const showTracker = groups.length > 1;
@@ -535,7 +390,7 @@ export function IssuesPage() {
const presentation = issueTrackerPresentation(group.tracker.tracker);
const showScope = new Set(group.rows.map((row) => row.scope.toLowerCase())).size > 1;
return (
-
+
{presentation.providerName}
diff --git a/apps/web/src/components/issue/issuePresentation.tsx b/apps/web/src/components/issue/issuePresentation.tsx
index 130637e52195d..422e348efeb78 100644
--- a/apps/web/src/components/issue/issuePresentation.tsx
+++ b/apps/web/src/components/issue/issuePresentation.tsx
@@ -1,3 +1,4 @@
+import { issueStateLabel } from "@t3tools/client-runtime/issue-presentation";
import type { IssueState, IssueTrackerKind } from "@t3tools/contracts";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import { CircleCheckIcon, CircleDotIcon, CircleSlashIcon, TicketIcon } from "lucide-react";
@@ -28,25 +29,20 @@ function resolveIssueStatePresentation(state: IssueState) {
if (state.category === "open") {
return {
...PULL_REQUEST_STATE_PRESENTATION.open,
- label: state.name === "open" ? "Open" : state.name,
+ label: issueStateLabel(state),
Icon: CircleDotIcon,
};
}
if (state.closeReason === "not-planned") {
return {
...PULL_REQUEST_STATE_PRESENTATION.draft,
- label: "Closed as not planned",
+ label: issueStateLabel(state),
Icon: CircleSlashIcon,
};
}
return {
...PULL_REQUEST_STATE_PRESENTATION.merged,
- label:
- state.name === "closed"
- ? state.closeReason === "completed"
- ? "Closed as completed"
- : "Closed"
- : state.name,
+ label: issueStateLabel(state),
Icon: CircleCheckIcon,
};
}
@@ -67,9 +63,3 @@ export function IssueStateGlyph({ state, className }: { state: IssueState; class
);
}
-
-/** "completed" → "Completed"; a reason the tracker spells with a dash reads as words. */
-export function issueCloseReasonLabel(reason: string): string {
- const words = reason.replace(/[-_]+/g, " ").trim();
- return words.charAt(0).toUpperCase() + words.slice(1);
-}
diff --git a/apps/web/src/state/issues.ts b/apps/web/src/state/issues.ts
index 20ab98bca1597..b4ec8e118517a 100644
--- a/apps/web/src/state/issues.ts
+++ b/apps/web/src/state/issues.ts
@@ -1,67 +1,28 @@
-import { createIssueEnvironmentAtoms } from "@t3tools/client-runtime/state/issues";
-import type { EnvironmentId, IssueListInput, IssueListResult } from "@t3tools/contracts";
+import {
+ createIssueEnvironmentAtoms,
+ createMergedIssueListAtoms,
+ type IssueListTarget,
+ type MergedIssueList,
+} from "@t3tools/client-runtime/state/issues";
import { useAtomValue } from "@effect/atom-react";
-import * as Option from "effect/Option";
-import { AsyncResult, Atom } from "effect/reactivity";
import { connectionAtomRuntime } from "../connection/runtime";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { formatEnvironmentQueryError } from "./query";
-export const issueEnvironment = createIssueEnvironmentAtoms(connectionAtomRuntime);
-
-export interface IssueListTarget {
- readonly environmentId: EnvironmentId;
- readonly input: IssueListInput;
-}
+export type { IssueListTarget };
-export interface MergedIssueListView {
- /** One answer per environment that has one, in target order. */
- readonly answers: ReadonlyArray;
- /** The first environment that failed outright; the others may still have answered. */
- readonly error: string | null;
- readonly isPending: boolean;
- readonly refresh: () => void;
-}
+export const issueEnvironment = createIssueEnvironmentAtoms(connectionAtomRuntime);
-/**
- * The same listing read across every connected environment at once, as the pull-request page
- * does: React cannot subscribe to a list of atoms whose length changes, so the fan-out lives in
- * one derived atom keyed by the targets. An environment that fails contributes nothing rather
- * than blanking the page.
- */
-const mergedIssueListFamily = Atom.family((key: string) =>
- Atom.make((get) => {
- const targets = JSON.parse(key) as ReadonlyArray;
- const answers: Array = [];
- let error: string | null = null;
- let isPending = false;
- for (const target of targets) {
- const result = get(issueEnvironment.list(target));
- isPending ||= result.waiting;
- if (result._tag === "Failure" && error === null) {
- error = formatEnvironmentQueryError(result.cause);
- }
- const value = Option.getOrNull(AsyncResult.value(result));
- if (value !== null) answers.push([target.environmentId, value]);
- }
- return { answers, error, isPending };
- }).pipe(Atom.withLabel(`web-issues:list:${key}`)),
+const mergedIssueLists = createMergedIssueListAtoms(
+ issueEnvironment.list,
+ formatEnvironmentQueryError,
);
-const EMPTY_ISSUE_LIST = Atom.make({
- answers: [] as ReadonlyArray,
- error: null as string | null,
- isPending: false,
-}).pipe(Atom.withLabel("web-issues:list:empty"));
-
-export function useIssueList(targets: ReadonlyArray): MergedIssueListView {
- const key = JSON.stringify(targets);
- const view = useAtomValue(targets.length === 0 ? EMPTY_ISSUE_LIST : mergedIssueListFamily(key));
- const refresh = () => {
- for (const target of JSON.parse(key) as ReadonlyArray) {
- appAtomRegistry.refresh(issueEnvironment.list(target));
- }
- };
- return { ...view, refresh };
+/** The same listing read across every connected environment, as the pull-request page does. */
+export function useIssueList(
+ targets: ReadonlyArray,
+): MergedIssueList & { readonly refresh: () => void } {
+ const view = useAtomValue(mergedIssueLists.atom(targets));
+ return { ...view, refresh: () => mergedIssueLists.refresh(appAtomRegistry, targets) };
}
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index ffe6e1f77ef1c..8ec638b9fb367 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -555,6 +555,14 @@
"types": "./src/pullRequestReply.ts",
"default": "./src/pullRequestReply.ts"
},
+ "./issue-presentation": {
+ "types": "./src/issuePresentation.ts",
+ "default": "./src/issuePresentation.ts"
+ },
+ "./issue-list": {
+ "types": "./src/issueList.ts",
+ "default": "./src/issueList.ts"
+ },
"./pull-request-query": {
"types": "./src/pullRequestQuery.ts",
"default": "./src/pullRequestQuery.ts"
diff --git a/packages/client-runtime/src/issueList.ts b/packages/client-runtime/src/issueList.ts
new file mode 100644
index 0000000000000..3f65ea300e652
--- /dev/null
+++ b/packages/client-runtime/src/issueList.ts
@@ -0,0 +1,174 @@
+import type {
+ EnvironmentId,
+ IssueListCursors,
+ IssueListEntry,
+ IssueListProjectError,
+ IssueListResult,
+ IssueScopeRef,
+ IssueTrackerSummary,
+ SourceControlProviderKind,
+} from "@t3tools/contracts";
+
+import { mergeIssueListErrors, mergeIssueListPage } from "./state/issues.ts";
+import { sourceControlClients } from "./sourceControlClients.ts";
+import type { EnvironmentProject } from "./state/shell.ts";
+
+export interface EnvironmentIssueEntry extends IssueListEntry {
+ readonly environmentId: EnvironmentId;
+}
+
+/** A place a new issue can be filed: one tracker scope, reached through one project. */
+export interface IssueCreateScope {
+ readonly environmentId: EnvironmentId;
+ readonly projectTitle: string;
+ readonly scope: IssueScopeRef;
+ readonly templates: boolean;
+}
+
+/** Rows from every environment that answered, with any continuation pages laid over them. */
+export interface HeldIssuePage {
+ readonly answer: IssueListResult;
+ readonly entries: ReadonlyArray;
+ readonly errors: ReadonlyArray;
+ readonly nextCursors: IssueListCursors;
+ /** Why the last "Load more" failed, until a retry succeeds or a new first page arrives. */
+ readonly moreFailure: string | null;
+}
+
+export function issueTrackerKey(tracker: Pick): string {
+ return `${tracker.tracker} ${tracker.host.toLowerCase()}`;
+}
+
+/** Continuations are laid over the first page they continue; a new first page forgets them. */
+export function holdIssuePages(
+ answers: ReadonlyArray,
+ pages: ReadonlyMap,
+): ReadonlyMap {
+ const next = new Map();
+ for (const [environmentId, answer] of answers) {
+ const previous = pages.get(environmentId);
+ next.set(
+ environmentId,
+ previous?.answer === answer
+ ? previous
+ : {
+ answer,
+ entries: answer.entries,
+ errors: answer.errors,
+ nextCursors: answer.nextCursors,
+ moreFailure: null,
+ },
+ );
+ }
+ return next;
+}
+
+/** Lays the next page over the rows held; a failure keeps them and says why. */
+export function continueIssuePage(
+ page: HeldIssuePage,
+ next: IssueListResult | { readonly failure: string },
+): HeldIssuePage {
+ return "failure" in next
+ ? { ...page, moreFailure: next.failure }
+ : {
+ answer: page.answer,
+ entries: mergeIssueListPage(page.entries, next.entries),
+ errors: mergeIssueListErrors(page.errors, next.errors),
+ nextCursors: next.nextCursors,
+ moreFailure: null,
+ };
+}
+
+export function groupIssues(
+ held: ReadonlyMap,
+ query: string,
+ projects: ReadonlyArray,
+) {
+ const byTracker = new Map<
+ string,
+ {
+ tracker: IssueTrackerSummary;
+ rows: EnvironmentIssueEntry[];
+ errors: Array<{ projectTitle: string; message: string; environmentId: EnvironmentId }>;
+ more: EnvironmentId[];
+ }
+ >();
+ const needle = query.toLowerCase();
+ for (const [environmentId, page] of held) {
+ for (const tracker of page.answer.trackers) {
+ const key = issueTrackerKey(tracker);
+ if (!byTracker.has(key)) byTracker.set(key, { tracker, rows: [], errors: [], more: [] });
+ const group = byTracker.get(key)!;
+ if (!group.tracker.configured && tracker.configured) group.tracker = tracker;
+ if (Object.keys(page.nextCursors).some((c) => c.startsWith(`${tracker.host} `)))
+ group.more.push(environmentId);
+ }
+ for (const entry of page.entries) {
+ const key = issueTrackerKey(entry);
+ const group = byTracker.get(key);
+ if (!group) continue;
+ // A tracker that cannot search is narrowed here instead.
+ if (
+ needle.length > 0 &&
+ group.tracker.capabilities?.search !== true &&
+ !entry.title.toLowerCase().includes(needle) &&
+ !entry.displayKey.toLowerCase().includes(needle)
+ )
+ continue;
+ group.rows.push({ ...entry, environmentId });
+ }
+ for (const error of page.errors) {
+ const project = projects.find(
+ (p) => p.environmentId === environmentId && p.id === error.projectId,
+ );
+ const kind = project?.repositoryIdentity?.provider as SourceControlProviderKind | undefined;
+ const host = kind
+ ? sourceControlClients.changeRequestHost(project?.repositoryIdentity, kind)
+ : null;
+ const group =
+ (host ? byTracker.get(`${kind} ${host.toLowerCase()}`) : undefined) ??
+ [...byTracker.values()][0];
+ group?.errors.push({ ...error, environmentId });
+ }
+ }
+ for (const group of byTracker.values()) {
+ group.rows.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
+ }
+ return [...byTracker.values()].sort((a, b) =>
+ issueTrackerKey(a.tracker).localeCompare(issueTrackerKey(b.tracker)),
+ );
+}
+
+export function issueCreateScopes(
+ held: ReadonlyMap,
+ projects: ReadonlyArray,
+): IssueCreateScope[] {
+ const scopes: IssueCreateScope[] = [];
+ for (const [environmentId, page] of held) {
+ for (const project of projects) {
+ if (project.environmentId !== environmentId) continue;
+ const identity = project.repositoryIdentity;
+ const kind = identity?.provider as SourceControlProviderKind | undefined;
+ const scope = sourceControlClients.issueScope(identity);
+ if (!identity || !kind || scope === null) continue;
+ const host = sourceControlClients.changeRequestHost(identity, kind);
+ const tracker = page.answer.trackers.find(
+ (t) => String(t.tracker) === kind && t.host.toLowerCase() === host.toLowerCase(),
+ );
+ if (!tracker?.configured || tracker.capabilities?.create !== true) continue;
+ if (
+ scopes.some(
+ (s) => s.scope.scope.toLowerCase() === scope.toLowerCase() && s.scope.host === host,
+ )
+ )
+ continue;
+ scopes.push({
+ environmentId,
+ projectTitle: project.title,
+ templates: tracker.capabilities.templates,
+ scope: { projectId: project.id, tracker: tracker.tracker, host, scope },
+ });
+ }
+ }
+ return scopes;
+}
diff --git a/packages/client-runtime/src/issuePresentation.ts b/packages/client-runtime/src/issuePresentation.ts
new file mode 100644
index 0000000000000..3946bdfd1556e
--- /dev/null
+++ b/packages/client-runtime/src/issuePresentation.ts
@@ -0,0 +1,69 @@
+import type { IssueDetail, IssueEvent, IssueState } from "@t3tools/contracts";
+
+/** "completed" → "Completed"; a reason the tracker spells with a dash reads as words. */
+export function issueCloseReasonLabel(reason: string): string {
+ const words = reason.replace(/[-_]+/g, " ").trim();
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+
+/** The tracker's own state name is the label, so a workflow tracker's "In Review" reads as itself. */
+export function issueStateLabel(state: IssueState): string {
+ if (state.category === "open") return state.name === "open" ? "Open" : state.name;
+ if (state.closeReason === "not-planned") return "Closed as not planned";
+ if (state.name !== "closed") return state.name;
+ return state.closeReason === "completed" ? "Closed as completed" : "Closed";
+}
+
+export function issueEventSentence(event: IssueEvent): string {
+ switch (event.kind) {
+ case "closed":
+ return event.detail
+ ? `closed this as ${issueCloseReasonLabel(event.detail).toLowerCase()}`
+ : "closed this";
+ case "reopened":
+ return "reopened this";
+ case "labeled":
+ return event.detail ? `added the ${event.detail} label` : "added a label";
+ case "unlabeled":
+ return event.detail ? `removed the ${event.detail} label` : "removed a label";
+ case "assigned":
+ return event.detail ? `assigned ${event.detail}` : "assigned someone";
+ case "unassigned":
+ return event.detail ? `unassigned ${event.detail}` : "unassigned someone";
+ case "renamed":
+ return event.detail ? `renamed this to “${event.detail}”` : "renamed this";
+ case "referenced":
+ return event.detail ? `referenced this from ${event.detail}` : "referenced this";
+ case "state-changed":
+ return event.detail ? `moved this to ${event.detail}` : "changed the state";
+ }
+}
+
+/**
+ * What the viewer may do to an issue here: the client's write scope, the issue being inside a
+ * project's scope, what the tracker can do at all, and what it grants this account.
+ */
+export function issueWriteAccess(detail: IssueDetail, canWrite: boolean) {
+ const blocked = !canWrite
+ ? "Writing to this tracker needs the source-control write scope."
+ : !detail.writable
+ ? "This issue is outside every project's own scope, so it is read-only here."
+ : null;
+ const may = (capability: boolean, permission: boolean) =>
+ blocked === null && capability && permission;
+ const others = detail.capabilities.states.filter(
+ (state) => state.category !== detail.state.category || state.name !== detail.state.name,
+ );
+ return {
+ blocked,
+ comment: may(detail.capabilities.comment, detail.viewerPermissions.comment),
+ edit: may(detail.capabilities.edit, detail.viewerPermissions.edit),
+ labels: may(detail.capabilities.labels, detail.viewerPermissions.labels),
+ assignees: may(detail.capabilities.assignees, detail.viewerPermissions.assignees),
+ react: blocked === null && detail.capabilities.reactions,
+ /** The states the issue can move to; empty when the viewer may not move it. */
+ states: may(detail.capabilities.states.length > 0, detail.viewerPermissions.setState)
+ ? others
+ : [],
+ };
+}
diff --git a/packages/client-runtime/src/state/issues.ts b/packages/client-runtime/src/state/issues.ts
index b91d707b9d29e..2523df2756e4c 100644
--- a/packages/client-runtime/src/state/issues.ts
+++ b/packages/client-runtime/src/state/issues.ts
@@ -3,12 +3,15 @@ import {
type EnvironmentId,
type IssueComment,
type IssueListEntry,
+ type IssueListInput,
type IssueListProjectError,
type IssueListResult,
type IssueRef,
} from "@t3tools/contracts";
+import type * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
-import { Atom, type AtomRegistry } from "effect/reactivity";
+import * as Option from "effect/Option";
+import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity";
import {
createAtomCommandScheduler,
@@ -172,4 +175,72 @@ export function createIssueEnvironmentAtoms(
};
}
-export type IssueListAnswer = IssueListResult;
+export interface IssueListTarget {
+ readonly environmentId: EnvironmentId;
+ readonly input: IssueListInput;
+}
+
+export interface MergedIssueList {
+ /** One answer per environment that has one, in target order. */
+ readonly answers: ReadonlyArray;
+ /** The first environment that failed outright; the others may still have answered. */
+ readonly error: string | null;
+ readonly isPending: boolean;
+}
+
+const NO_ISSUE_LISTS: MergedIssueList = { answers: [], error: null, isPending: false };
+
+/**
+ * One listing read across every environment at once: React cannot subscribe to a list of atoms
+ * whose length changes, so the fan-out lives in one derived atom keyed by the targets. An
+ * environment that fails contributes nothing rather than blanking the list.
+ */
+export function createMergedIssueListAtoms(
+ list: (target: IssueListTarget) => Atom.Atom>,
+ formatError: (cause: Cause.Cause) => string,
+) {
+ const empty = Atom.make(NO_ISSUE_LISTS).pipe(Atom.withLabel("issues:merged-list:empty"));
+ const family = Atom.family((key: string) =>
+ Atom.make((get): MergedIssueList => {
+ const answers: Array = [];
+ let error: string | null = null;
+ let isPending = false;
+ for (const target of JSON.parse(key) as ReadonlyArray) {
+ const result = get(list(target));
+ isPending ||= result.waiting;
+ if (result._tag === "Failure" && error === null) error = formatError(result.cause);
+ const value = Option.getOrNull(AsyncResult.value(result));
+ if (value !== null) answers.push([target.environmentId, value]);
+ }
+ return { answers, error, isPending };
+ }).pipe(Atom.withLabel(`issues:merged-list:${key}`)),
+ );
+ const refresh = (
+ registry: AtomRegistry.AtomRegistry,
+ targets: ReadonlyArray,
+ ) => {
+ for (const target of targets) registry.refresh(list(target));
+ };
+ // Every listing asked for, so a write made elsewhere can refresh the ones still shown.
+ const asked = new Map>();
+ return {
+ atom: (targets: ReadonlyArray) => {
+ if (targets.length === 0) return empty;
+ const key = JSON.stringify(targets);
+ asked.set(key, targets);
+ return family(key);
+ },
+ refresh,
+ /** Refreshes each listing of `environmentId` that something still reads. */
+ refreshEnvironment: (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => {
+ const live = new Set(registry.getNodes().keys());
+ for (const [key, targets] of asked) {
+ if (!live.has(family(key))) continue;
+ refresh(
+ registry,
+ targets.filter((target) => target.environmentId === environmentId),
+ );
+ }
+ },
+ };
+}
diff --git a/vite.config.ts b/vite.config.ts
index c7a92ccbc9ad1..bba37967336f4 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -647,6 +647,7 @@ export default defineConfig({
"apps/mobile/src/features/review/useNativeReviewDiffBridge.ts",
"apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx",
"apps/mobile/src/features/threads/ThreadComposer.tsx",
+ "apps/mobile/src/components/ComposerSurface.tsx",
"apps/mobile/src/features/threads/ThreadFeed.tsx",
"apps/mobile/src/features/threads/ThreadQueueControl.tsx",
"apps/mobile/src/features/review/ReviewCommentCard.tsx",