Skip to content

Commit 745930b

Browse files
Merge remote-tracking branch 'origin/main' into fix/issue-11476
2 parents a59ccc6 + defb7e6 commit 745930b

19 files changed

Lines changed: 1152 additions & 113 deletions

‎apps/mobile/src/features/home/HomeScreen.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,11 @@ export function HomeScreen(props: HomeScreenProps) {
593593
showTrailingDivider={item.showTrailingDivider}
594594
onSelectPendingTask={props.onSelectPendingTask}
595595
onDeletePendingTask={props.onDeletePendingTask}
596+
fullSwipeWidth={fullSwipeWidth}
597+
onSwipeableClose={handleSwipeableClose}
598+
onSwipeableWillOpen={handleSwipeableWillOpen}
599+
simultaneousSwipeGesture={homeScrollGesture}
600+
activationKey={item.key}
596601
/>
597602
);
598603
}

‎apps/mobile/src/features/home/thread-swipe-actions.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ interface ThreadSwipeAction {
6262
readonly accessibilityLabel: string;
6363
readonly icon: ComponentProps<typeof SymbolView>["name"];
6464
readonly label: string;
65+
readonly destructive?: boolean;
6566
readonly menu?: {
6667
readonly actions: MenuAction[];
6768
readonly onPressAction: NonNullable<ComponentProps<typeof Menu>["onPressAction"]>;
@@ -726,7 +727,7 @@ export function ThreadSwipeActions(props: {
726727
<SwipeActionButton
727728
accessibilityLabel={props.primaryAction.accessibilityLabel}
728729
actionsWidth={actionsWidth}
729-
tone="primary"
730+
tone={props.primaryAction.destructive === true ? "danger" : "primary"}
730731
compact={props.compact}
731732
entryRange={
732733
secondaryAction === null

‎apps/mobile/src/features/home/usePendingTaskListActions.ts‎

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import { useCallback } from "react";
55
import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
66
import { clearComposerDraftContent } from "../../state/use-composer-drafts";
77
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
8-
import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";
8+
import { appAtomRegistry } from "../../state/atom-registry";
9+
import {
10+
editingQueuedMessageIdsAtom,
11+
holdEditingQueuedMessage,
12+
releaseEditingQueuedMessage,
13+
} from "../../state/use-thread-outbox";
14+
import { withThreadDismissal } from "./thread-dismissal";
915

1016
export function usePendingTaskListActions(): {
1117
readonly openPendingTask: (pendingTask: PendingNewTask) => void;
@@ -37,10 +43,15 @@ export function usePendingTaskListActions(): {
3743
text: "Discard",
3844
style: "destructive",
3945
onPress: () => {
40-
clearComposerDraftContent(pendingTask.draftKey, {
41-
clearModelSelection: true,
42-
clearWorkspaceSelection: true,
43-
});
46+
void withThreadDismissal(
47+
pendingTask.key,
48+
async () =>
49+
clearComposerDraftContent(pendingTask.draftKey, {
50+
clearModelSelection: true,
51+
clearWorkspaceSelection: true,
52+
}),
53+
() => true,
54+
);
4455
},
4556
},
4657
]);
@@ -55,12 +66,28 @@ export function usePendingTaskListActions(): {
5566
text: "Delete",
5667
style: "destructive",
5768
onPress: () => {
58-
// Release the edit lock only after removal succeeds, and only if
59-
// it is held for THIS task — clearing it up front (or for another
60-
// task) would let the drain deliver a mid-edit payload.
61-
void removeThreadOutboxMessage(pendingTask.message)
62-
.then(() => releaseEditingQueuedMessage(pendingTask.message.messageId))
69+
// The row animates out before removal runs, so hold delivery from
70+
// confirmation on: a reconnect must not send a task being deleted.
71+
// The hold is shared with the editor, which could release it
72+
// mid-animation, so a task open in the editor is removed at once
73+
// and keeps its hold unless removal succeeds.
74+
const messageId = pendingTask.message.messageId;
75+
const heldByEditor = appAtomRegistry.get(editingQueuedMessageIdsAtom)[messageId];
76+
holdEditingQueuedMessage(messageId);
77+
const releaseUnlessEditing = () => {
78+
if (!heldByEditor) releaseEditingQueuedMessage(messageId);
79+
};
80+
const remove = () => removeThreadOutboxMessage(pendingTask.message);
81+
void (
82+
heldByEditor
83+
? remove()
84+
: withThreadDismissal(pendingTask.key, remove, (removed) => removed)
85+
)
86+
.then((removed) =>
87+
removed ? releaseEditingQueuedMessage(messageId) : releaseUnlessEditing(),
88+
)
6389
.catch((error) => {
90+
releaseUnlessEditing();
6491
Dialog.alert(
6592
"Could not delete pending task",
6693
error instanceof Error ? error.message : "The pending task could not be removed.",

‎apps/mobile/src/features/home/useThreadListActions.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -456,10 +456,15 @@ export function useThreadListActions(): {
456456
// Pinning clears snooze, so older Settled or Snoozed undos would fight this pin.
457457
ThreadUndo.invalidate("settle", scopedThreadKey(thread.environmentId, thread.id));
458458
ThreadUndo.invalidate("snooze", scopedThreadKey(thread.environmentId, thread.id));
459-
const result = await pinMutation({
460-
environmentId: thread.environmentId,
461-
input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) },
462-
});
459+
const result = await withThreadDismissal(
460+
scopedThreadKey(thread.environmentId, thread.id),
461+
() =>
462+
pinMutation({
463+
environmentId: thread.environmentId,
464+
input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) },
465+
}),
466+
(result) => result._tag === "Success",
467+
);
463468
if (result._tag === "Failure") {
464469
const error = Cause.squash(result.cause);
465470
Dialog.alert(
@@ -484,10 +489,15 @@ export function useThreadListActions(): {
484489
selectionHaptic();
485490
const plan = ThreadUndo.planThreadUndo("unpin", thread);
486491
const claim = ThreadUndo.begin("pin", scopedThreadKey(thread.environmentId, thread.id));
487-
const result = await unpinMutation({
488-
environmentId: thread.environmentId,
489-
input: { threadId: thread.id },
490-
});
492+
const result = await withThreadDismissal(
493+
scopedThreadKey(thread.environmentId, thread.id),
494+
() =>
495+
unpinMutation({
496+
environmentId: thread.environmentId,
497+
input: { threadId: thread.id },
498+
}),
499+
(result) => result._tag === "Success",
500+
);
491501
if (result._tag === "Failure") {
492502
claim.finish();
493503
const error = Cause.squash(result.cause);

‎apps/mobile/src/features/threads/NewTaskDraftScreen.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import {
2121
usePreventRemove,
2222
type NavigationAction,
2323
} from "@react-navigation/native";
24-
import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
24+
import { HeaderHeightContext } from "@react-navigation/elements";
25+
import { useContext, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
2526
import { Platform, Pressable, ScrollView, View } from "react-native";
2627
import {
2728
KeyboardController,
@@ -214,6 +215,9 @@ export function NewTaskDraftScreen(props: {
214215
reserveShare,
215216
} = useIncomingShare();
216217
const insets = useSafeAreaInsets();
218+
// The iOS glass header floats over the hero, and its height differs between an
219+
// iPhone sheet and an iPad column that also carries the status bar.
220+
const navigationHeaderHeight = useContext(HeaderHeightContext) ?? insets.top + 56;
217221
const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
218222
const controlsBottomPadding = Math.max(insets.bottom, 10);
219223
const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8);
@@ -1550,7 +1554,10 @@ export function NewTaskDraftScreen(props: {
15501554
alwaysBounceVertical={isKeyboardVisible}
15511555
className="flex-1"
15521556
contentInsetAdjustmentBehavior="never"
1553-
contentContainerClassName="grow items-center pb-[236px] pt-12 ios:pt-[72px]"
1557+
contentContainerClassName="grow items-center pb-[236px] pt-12"
1558+
contentContainerStyle={
1559+
Platform.OS === "ios" ? { paddingTop: navigationHeaderHeight + 16 } : undefined
1560+
}
15541561
keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
15551562
keyboardShouldPersistTaps="handled"
15561563
showsVerticalScrollIndicator={false}

‎apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,10 @@ function ThreadNavigationSidebarPane(
585585
showPendingDivider={item.showPendingDivider}
586586
onSelectPendingTask={props.onOpenPendingTask}
587587
onDeletePendingTask={confirmDeletePendingTask}
588+
fullSwipeWidth={props.width - 20}
589+
onSwipeableClose={handleSwipeableClose}
590+
onSwipeableWillOpen={handleSwipeableWillOpen}
591+
simultaneousSwipeGesture={sidebarScrollGesture}
588592
/>
589593
);
590594
}

‎apps/mobile/src/features/threads/thread-list-v2-items.tsx‎

Lines changed: 83 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -325,10 +325,34 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
325325
readonly showTrailingDivider?: boolean;
326326
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
327327
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
328+
readonly fullSwipeWidth?: number;
329+
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
330+
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
331+
readonly activationKey?: string;
332+
readonly simultaneousSwipeGesture?: ComponentProps<typeof ThreadSwipeable>["simultaneousWith"];
328333
}) {
329334
const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
335+
const { width: windowWidth } = useWindowDimensions();
336+
const dormant = useSwipeRowDormant(props.activationKey);
337+
const theme = useUniwindTheme();
330338
const sidebarPane = props.pane === "sidebar";
339+
const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, false);
331340
const isDraft = pendingTask.kind === "draft";
341+
const deleteLabel = isDraft ? "Discard" : "Delete";
342+
const handleDelete = useCallback(
343+
() => onDeletePendingTask(pendingTask),
344+
[onDeletePendingTask, pendingTask],
345+
);
346+
const deleteAction = useMemo(
347+
() => ({
348+
accessibilityLabel: `${deleteLabel} ${pendingTask.title}`,
349+
destructive: true,
350+
icon: "trash" as const,
351+
label: deleteLabel,
352+
onPress: handleDelete,
353+
}),
354+
[deleteLabel, handleDelete, pendingTask.title],
355+
);
332356
const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? "";
333357
const branch = pendingTask.branch;
334358

@@ -442,45 +466,65 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
442466
{props.showPendingDivider ? (
443467
<ThreadListV2SectionDivider label="Unsent" pane={props.pane} />
444468
) : null}
445-
<Menu
446-
actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}
447-
onPressAction={handleMenuAction}
448-
shouldOpenOnLongPress
469+
<ThreadSwipeable
470+
dormant={dormant}
471+
threadKey={pendingTask.key}
472+
backgroundColor={rowAppearance.swipeBackgroundColor}
473+
containerStyle={rowAppearance.swipeContainerStyle}
474+
enableTrackpadSwipe
475+
fullSwipeAction="primary"
476+
fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32}
477+
onDelete={handleDelete}
478+
onSwipeableClose={props.onSwipeableClose}
479+
onSwipeableWillOpen={props.onSwipeableWillOpen}
480+
primaryAction={deleteAction}
481+
secondaryAction={null}
482+
resetKey={pendingTask.key}
483+
simultaneousWith={props.simultaneousSwipeGesture}
484+
threadTitle={pendingTask.title}
449485
>
450-
<RowPressable
451-
accessibilityHint={
452-
isDraft
453-
? "Opens the draft in the new task composer"
454-
: "Sends when the environment reconnects. Opens the task for editing"
455-
}
456-
accessibilityLabel={pendingTask.title}
457-
accessibilityRole="button"
458-
key={pendingTask.key}
459-
className={sidebarPane ? "bg-drawer" : "bg-screen"}
460-
interactionClassName={sidebarPane ? "bg-thread-hover" : "bg-row-hover"}
461-
onPress={() => onSelectPendingTask(pendingTask)}
462-
style={
463-
sidebarPane
464-
? {
465-
borderRadius: SIDEBAR_V2_ROW_RADIUS,
466-
paddingHorizontal: 12,
467-
paddingVertical: 10,
468-
}
469-
: undefined
470-
}
471-
>
472-
{sidebarPane ? (
473-
rowContent
474-
) : (
475-
<View>
476-
<View className="px-5 py-2.5">{rowContent}</View>
477-
{props.showTrailingDivider !== false ? (
478-
<View className="ml-5 h-px bg-border-subtle" />
479-
) : null}
480-
</View>
481-
)}
482-
</RowPressable>
483-
</Menu>
486+
{() => (
487+
<Menu
488+
actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}
489+
onPressAction={handleMenuAction}
490+
shouldOpenOnLongPress
491+
>
492+
<RowPressable
493+
accessibilityHint={
494+
isDraft
495+
? `Opens the draft in the new task composer. Swipe left to ${deleteLabel.toLowerCase()}.`
496+
: `Sends when the environment reconnects. Opens the task for editing. Swipe left to ${deleteLabel.toLowerCase()}.`
497+
}
498+
accessibilityLabel={pendingTask.title}
499+
accessibilityRole="button"
500+
key={pendingTask.key}
501+
className={sidebarPane ? "bg-drawer" : "bg-screen"}
502+
interactionClassName={sidebarPane ? "bg-thread-hover" : "bg-row-hover"}
503+
onPress={() => onSelectPendingTask(pendingTask)}
504+
style={
505+
sidebarPane
506+
? {
507+
borderRadius: SIDEBAR_V2_ROW_RADIUS,
508+
paddingHorizontal: 12,
509+
paddingVertical: 10,
510+
}
511+
: undefined
512+
}
513+
>
514+
{sidebarPane ? (
515+
rowContent
516+
) : (
517+
<View>
518+
<View className="px-5 py-2.5">{rowContent}</View>
519+
{props.showTrailingDivider !== false ? (
520+
<View className="ml-5 h-px bg-border-subtle" />
521+
) : null}
522+
</View>
523+
)}
524+
</RowPressable>
525+
</Menu>
526+
)}
527+
</ThreadSwipeable>
484528
</>
485529
);
486530
});
@@ -1334,7 +1378,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
13341378
onSwipeableWillOpen={props.onSwipeableWillOpen}
13351379
primaryAction={primaryAction}
13361380
secondaryAction={secondaryAction}
1337-
resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}`}
1381+
resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}:${thread.pinnedAt}`}
13381382
simultaneousWith={props.simultaneousSwipeGesture}
13391383
threadTitle={thread.title}
13401384
>

‎apps/mobile/src/features/threads/worktree-setup-card.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export function WorktreeSetupCard(props: WorktreeSetupCardProps) {
8484
accessibilityRole="button"
8585
accessibilityLabel={
8686
backgroundSetup
87-
? `${scriptName} is still running. Show setup progress.`
87+
? `Background setup is still running: ${scriptName}. Show setup progress.`
8888
: "Worktree setup details"
8989
}
9090
accessibilityState={{ expanded: detailsOpen }}
@@ -116,7 +116,7 @@ export function WorktreeSetupCard(props: WorktreeSetupCardProps) {
116116
/>
117117
) : null}
118118
<Text numberOfLines={1} className="shrink text-2xs text-foreground-secondary">
119-
{backgroundSetup ? scriptName : "Details"}
119+
{backgroundSetup ? "Background setup" : "Details"}
120120
</Text>
121121
{!backgroundSetup ? (
122122
<SymbolView name="chevron.right" size={10} tintColorClassName="accent-icon-muted" />

‎apps/server/src/observability/Metrics.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ export const serverEventLoopDelayMax = Metric.gauge("t3_server_event_loop_delay_
6363
attributes: { unit: "ms" },
6464
});
6565

66+
export const runsTotal = Metric.counter("t3_runs_total", {
67+
description: "Runs that finished, by outcome. A later rollback does not count again.",
68+
});
69+
70+
export const runDuration = Metric.timer("t3_run_duration", {
71+
description: "Wall time of a run, from when it started to its terminal status.",
72+
});
73+
74+
export const runQueueWait = Metric.timer("t3_run_queue_wait", {
75+
description: "Time a run waited between being requested and starting.",
76+
});
77+
6678
export const gitCommandsTotal = Metric.counter("t3_git_commands_total", {
6779
description: "Total git commands executed by the server runtime.",
6880
});

0 commit comments

Comments
 (0)