Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { useFocusEffect } from "@react-navigation/native";
import { use, useEffect, useRef, useState, type ComponentProps } from "react";
import { use, useEffect, useRef, useState, useSyncExternalStore, type ComponentProps } from "react";
import {
Platform,
ScrollView,
Expand Down Expand Up @@ -488,6 +488,10 @@ export function HomeScreen(props: HomeScreenProps) {
...(workingShelfEnabled ? [] : sectionAvailability("active")),
]);
})();
const inboxReturnsVersion = useSyncExternalStore(
threadListInboxReturns.subscribe,
threadListInboxReturns.getSnapshot,
);
const threadListV2Layout = (() => {
threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null);
return buildThreadListV2Items({
Expand All @@ -505,6 +509,8 @@ export function HomeScreen(props: HomeScreenProps) {
workingShelfEnabled,
workingShelfExpanded,
inboxReturnAt: threadListInboxReturns.returnedAt,
isWorking: threadListInboxReturns.isWorking,
inboxReturnsVersion,
snoozedShelfExpanded,
settledShelfExpanded,
selectedThreadKey,
Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { AnimatedLegendList } from "@legendapp/list/reanimated";
import { THREAD_SIDEBAR_ROW_LAYOUT } from "./threadListAnimations";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { LayoutChangeEvent, TextInputInstance } from "react-native";
import { Platform, StyleSheet, TextInput, View } from "react-native";
import { GestureDetector, useNativeGesture } from "react-native-gesture-handler";
Expand Down Expand Up @@ -320,6 +320,10 @@ function ThreadNavigationSidebarPane(
...(workingShelfEnabled ? [] : sectionAvailability("active")),
]);
})();
const inboxReturnsVersion = useSyncExternalStore(
threadListInboxReturns.subscribe,
threadListInboxReturns.getSnapshot,
);
const threadListV2Layout = (() => {
threadListInboxReturns.observe(workingShelfEnabled ? threads : null);
return buildThreadListV2Items({
Expand All @@ -337,6 +341,8 @@ function ThreadNavigationSidebarPane(
workingShelfEnabled,
workingShelfExpanded,
inboxReturnAt: threadListInboxReturns.returnedAt,
isWorking: threadListInboxReturns.isWorking,
inboxReturnsVersion,
snoozedShelfExpanded,
settledShelfExpanded,
selectedThreadKey: props.selectedThreadKey ?? null,
Expand Down
31 changes: 30 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox";
import { presentThreadShell } from "@t3tools/client-runtime/state/models";
import * as DateTime from "effect/DateTime";
import * as Schema from "effect/Schema";
Expand All @@ -24,7 +25,7 @@ import {
RunId,
ThreadId,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, vi } from "vite-plus/test";

import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures";
Expand Down Expand Up @@ -1549,6 +1550,34 @@ describe("Working section beta", () => {
expect(off.workingCount).toBe(0);
});

it("keeps an inactive row in Working during the shared grace and returns it on expiry", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(NOW));
const tracker = createInboxReturnTracker();
const working = makeThread({ id: ThreadId.make("grace"), title: "grace", runtime: running });
const inactive = { ...working, runtime: null };
const render = () =>
build({
threads: [inactive],
workingShelfExpanded: true,
isWorking: tracker.isWorking,
inboxReturnAt: tracker.returnedAt,
});
try {
tracker.observe([working]);
tracker.observe([inactive]);
expect(render().workingCount).toBe(1);
expect(render().items[0]?.thread.runtime).toBeNull();
vi.advanceTimersByTime(2_000);
expect(render().workingCount).toBe(0);
expect(ids(render())).toEqual(["grace"]);
expect(tracker.returnedAt(inactive)).toBe(Date.parse(NOW) + 2_000);
} finally {
tracker.observe(null);
vi.useRealTimers();
}
});

it("orders the inbox by the latest return this device observed", () => {
const layout = build({
inboxReturnAt: (thread) =>
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,11 @@ export function buildThreadListV2Items(input: {
readonly now: string;
readonly workingShelfEnabled?: boolean;
readonly workingShelfExpanded?: boolean;
readonly isWorking?: (thread: EnvironmentThreadShell) => boolean;
readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined;
// isWorking and inboxReturnAt change when the exit grace expires, so the
// tracker's version is an input: a new version rebuilds the layout.
readonly inboxReturnsVersion?: number;
readonly snoozedShelfExpanded?: boolean;
readonly settledShelfExpanded?: boolean;
readonly selectedThreadKey?: string | null;
Expand Down Expand Up @@ -671,7 +675,7 @@ export function buildThreadListV2Items(input: {
settled.push(thread);
} else if (thread.pinnedAt != null) {
pinned.push(thread);
} else if (workingShelfEnabled && isThreadWorking(thread)) {
} else if (workingShelfEnabled && (input.isWorking ?? isThreadWorking)(thread)) {
working.push(thread);
} else {
active.push(thread);
Expand Down
27 changes: 19 additions & 8 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import {
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox";
import {
createInboxReturnTracker,
isThreadWorking,
} from "@t3tools/client-runtime/state/thread-inbox";
import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort";
import {
nextShownOrder,
Expand Down Expand Up @@ -61,6 +64,7 @@ import {
useReducer,
useRef,
useState,
useSyncExternalStore,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
Expand Down Expand Up @@ -151,7 +155,6 @@ import {
filterSidebarProjectScopeItems,
firstValidTimestampMs,
isSidebarNestedLinkClick,
isSidebarThreadWorking,
isTrailingDoubleClick,
orderItemsByPreferredIds,
planSidebarThreadDrop,
Expand Down Expand Up @@ -303,8 +306,9 @@ interface SidebarThreadPartitionInput {
readonly serverConfigs: ReadonlyMap<EnvironmentId, EnvironmentServerConfig>;
readonly optimisticDrop: SidebarOptimisticDrop | null;
readonly workingShelfEnabled: boolean;
// Snooze wakes and the queued-turn grace depend on the clock, so the minute
// tick and the precise snooze-wake tick are inputs: a new key repartitions.
// Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend
// on the clock, so the minute tick, the precise snooze-wake tick and the inbox
// tracker's expiry version are inputs: a new key repartitions.
readonly clockKey: string;
}

Expand Down Expand Up @@ -383,6 +387,10 @@ function useSidebarThreads(input: {
),
);
const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled);
const inboxReturnsVersion = useSyncExternalStore(
inboxReturns.subscribe,
inboxReturns.getSnapshot,
);
const nowMinute = useNowMinute();
// Snooze wake times are second-precise, so classifying with the quantized
// minute would hold a woken thread on the shelf for up to a minute.
Expand Down Expand Up @@ -424,7 +432,7 @@ function useSidebarThreads(input: {
serverConfigs,
optimisticDrop,
workingShelfEnabled,
clockKey: `${nowMinute}:${snoozeWakeTick}`,
clockKey: `${nowMinute}:${snoozeWakeTick}:${inboxReturnsVersion}`,
});
const partitionNextWakeAt = partition.snoozedThreads[0]?.snoozedUntil ?? null;
if (partitionNextWakeAt !== nextSnoozeWakeAt) setNextSnoozeWakeAt(partitionNextWakeAt);
Expand Down Expand Up @@ -520,8 +528,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) {
const pinned: EnvironmentThreadShell[] = [];
const active: EnvironmentThreadShell[] = [];
const working: EnvironmentThreadShell[] = [];
const inbox = (thread: EnvironmentThreadShell) =>
workingShelfEnabled && isSidebarThreadWorking(thread) ? working : active;
const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) =>
workingShelfEnabled &&
(manuallyMoved ? isThreadWorking(thread) : inboxReturns.isWorking(thread))
? working
: active;
const snoozed: EnvironmentThreadShell[] = [];
const settled: EnvironmentThreadShell[] = [];
const draggable = new Set<string>();
Expand All @@ -546,7 +557,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) {
? pinned
: optimisticDrop.section === "settled"
? settled
: inbox(projected)
: inbox(projected, true)
).push(
optimisticDrop.clearsSnooze
? projected
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { RegistryContext } from "@effect/atom-react";
import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox";
import { EnvironmentId } from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Option from "effect/Option";
import { Atom, AtomRegistry } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";

import { render, stubGlobal, type Screen } from "../../test/render";
import { fakeTimers, render, stubGlobal, type Screen } from "../../test/render";

const state = vi.hoisted(() => ({
mode: "notifications",
Expand Down Expand Up @@ -215,3 +216,26 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)(
expect(notification.close).toHaveBeenCalled();
},
);

it("holds a completion through a continuation gap and notifies once it settles", async () => {
await using app = setup();
await app.render();
using _timers = fakeTimers();
const finished = (turnId: string, completedAt: string) => ({
latestTurn: { turnId, state: "completed", completedAt },
});
state.shells.set("one", shell(finished("turn-1", "2026-09-13T07:00:01.000Z")));
await app.render();
state.shells.set(
"one",
shell({ latestTurn: { turnId: "turn-2", state: "running", completedAt: null } }),
);
await app.render();
state.shells.set("one", shell(finished("turn-2", "2026-09-13T07:00:02.000Z")));
await app.render();
expect(TestNotification.sent).toEqual([]);
await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS - 1);
expect(TestNotification.sent).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]);
});
Loading
Loading