Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
f31c53e
feat(server): threads can be filed into environment-wide sidebar folders
Bil0000 Oct 28, 2026
682907d
feat(mcp): agents can create and file threads into sidebar folders
Bil0000 Oct 28, 2026
9cef563
feat(web): sidebar folders with Maria's tinted cards, menus and mod+g
Bil0000 Oct 28, 2026
d3650cb
feat(mobile): thread list shows folders with native folder menus
Bil0000 Oct 28, 2026
09c636a
Merge remote-tracking branch 'origin/main' into t3/folders-merge
Bil0000 Oct 29, 2026
6e11a9e
fix(server,web): replayed folder creates and draft mod+g no longer mi…
Bil0000 Oct 29, 2026
028db0a
fix(mcp): folder storage failures report orchestration_error, not inv…
Bil0000 Oct 29, 2026
f059d18
fix(web,mobile): folder header long-press, live scope, and selection …
Bil0000 Oct 29, 2026
487d94f
fix(web): holding mod+g no longer creates a folder per key repeat
Bil0000 Oct 29, 2026
4cd96bb
fix(server): a retried folder create succeeds even at the limit or af…
Bil0000 Oct 29, 2026
28e1438
fix(web): mod+g waits for the folder list before choosing create or r…
Bil0000 Oct 29, 2026
da80b94
Merge remote-tracking branch 'origin/main' into t3/folders-round2
Bil0000 Oct 29, 2026
eb9f0c3
feat(contracts): folder rules, auto-file, and reorder; shared rule ma…
Bil0000 Oct 29, 2026
f0ea6c2
feat(contracts): folder rule fixes, applyRule, and suggestRule RPC
Bil0000 Oct 29, 2026
51aba54
feat(server): auto-file threads into folders by rule
Bil0000 Oct 29, 2026
e799000
feat(web): folder drag and drop, customize dialog, and settled folders
Bil0000 Oct 29, 2026
1cb8d50
feat(mobile): settled folders, auto-file menu, and settle folder
Bil0000 Oct 29, 2026
606f224
Merge remote-tracking branch 'origin/main' into t3/folders-round2
Bil0000 Oct 29, 2026
8115858
feat(contracts): smart folder rules filed by the title model
Bil0000 Oct 29, 2026
88ff835
feat(server): opt-in smart auto-file and safer folder writes
Bil0000 Oct 29, 2026
6399e75
feat(web): settle folders in one click, simpler folder dialog
Bil0000 Oct 29, 2026
d236986
feat(mobile): smart auto-file opt-in and section-aware folder moves
Bil0000 Oct 29, 2026
11e3c4c
Merge remote-tracking branch 'origin/main' into t3/folders-round2
Bil0000 Oct 29, 2026
9f9ac36
Merge remote-tracking branch 'origin/main' into t3/folders-round2
Bil0000 Oct 29, 2026
7cbd904
fix(server,web): smart filing reaches every thread and skips ineligib…
Bil0000 Oct 29, 2026
6bfeb64
fix(server): drop smart folder matches when the folder changed mid-sort
Bil0000 Oct 29, 2026
d20866e
feat(web): "Collect new threads" folder menu replaces the Auto-file tab
Bil0000 Oct 29, 2026
fccbadd
feat(mobile): "Collect new threads" folder menu
Bil0000 Oct 29, 2026
5703e16
fix(server): a rule switch mid-sort also drops stale smart matches
Bil0000 Oct 29, 2026
545820f
feat(server,web,mobile): "Add related threads" and folders that colle…
Bil0000 Oct 30, 2026
d437470
fix(server): folders respect autoFile:false, guard linked callers, an…
Bil0000 Oct 30, 2026
2e680f4
feat(web,mobile): "Adding related threads…" state and a cleaner folde…
Bil0000 Oct 30, 2026
924e868
fix(web): folder header actions hide after a mouse click
Bil0000 Oct 30, 2026
4f5c6e8
feat(server,web,mobile): delete a folder with its threads, collect by…
Bil0000 Oct 30, 2026
8ae3b05
feat(web): "Tab groups" folder style setting
Bil0000 Oct 30, 2026
f402d98
Merge remote-tracking branch 'origin/main' into t3/folders-round2
Bil0000 Oct 30, 2026
48985cb
fix(web): tab group line joins across folder rows
Bil0000 Oct 30, 2026
dec1bc6
Merge remote-tracking branch 'origin/main' into t3/folders-round2
Bil0000 Oct 30, 2026
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const clientSettings: ClientSettings = {
threadDetailsItems: {},
legacySidebarEnabled: false,
sidebarWorkingShelfEnabled: false,
sidebarThreadFolderStyle: "tabs",
loadBalancingEnabled: false,
loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
pullRequestMergeMethodOverrides: {},
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/components/ui/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ import IconWifi from "@tabler/icons-react-native/IconWifi";
import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";
import IconWorld from "@tabler/icons-react-native/IconWorld";
import IconX from "@tabler/icons-react-native/IconX";
import IconBug from "@tabler/icons-react-native/IconBug";
import IconFlask from "@tabler/icons-react-native/IconFlask";
import IconRocket from "@tabler/icons-react-native/IconRocket";
import IconBulb from "@tabler/icons-react-native/IconBulb";
import IconShieldCheck from "@tabler/icons-react-native/IconShieldCheck";
import IconGauge from "@tabler/icons-react-native/IconGauge";
import type { AndroidSymbol, SFSymbol, SymbolViewProps } from "expo-symbols";
import { withUniwind } from "uniwind";

Expand Down Expand Up @@ -250,6 +256,13 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
"point.topleft.down.curvedto.point.bottomright.up": IconGitMerge,
safari: IconExternalLink,
"server.rack": IconServer,
ladybug: IconBug,
flask: IconFlask,
paperplane: IconRocket,
lightbulb: IconBulb,
"checkmark.shield": IconShieldCheck,
cylinder: IconDatabase,
"gauge.with.dots.needle.33percent": IconGauge,
stethoscope: IconStethoscope,
"sidebar.left": IconLayoutSidebar,
"sidebar.right": IconLayoutSidebarRight,
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ export function HomeRouteScreen() {
};

const {
moveThreadToFolder,
createFolder,
renameFolder,
deleteFolder,
moveFolder,
setFolderCollect,
applyFolderRule,
settleFolder,
archiveThread,
confirmDeleteThread,
settleThread,
Expand Down Expand Up @@ -242,6 +250,14 @@ export function HomeRouteScreen() {
onPinThread={pinThread}
onUnpinThread={unpinThread}
onSetThreadAutoSettle={setThreadAutoSettle}
onMoveThreadToFolder={moveThreadToFolder}
onCreateFolder={createFolder}
onMoveFolder={moveFolder}
onFolderCollect={setFolderCollect}
onApplyFolderRule={applyFolderRule}
onSettleFolder={settleFolder}
onRenameFolder={renameFolder}
onDeleteFolder={deleteFolder}
onMoveThread={moveThread}
onRenameThread={renameThread}
onRegenerateThreadTitle={regenerateThreadTitle}
Expand Down
45 changes: 43 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { useThreadListActions } from "./useThreadListActions";
import { useActiveNewTask } from "../../state/active-new-task";
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
Expand Down Expand Up @@ -63,6 +64,7 @@ import {
ThreadListV2ShowMoreRow,
ThreadListV2SnoozedShelfHeader,
ThreadListV2WorkingShelfHeader,
ThreadListV2FolderHeader,
} from "../threads/thread-list-v2-items";
import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";
import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests";
Expand Down Expand Up @@ -122,6 +124,14 @@ interface HomeScreenProps {
thread: EnvironmentThreadShell,
enabled: boolean,
) => Promise<boolean>;
readonly onMoveThreadToFolder: ReturnType<typeof useThreadListActions>["moveThreadToFolder"];
readonly onCreateFolder: ReturnType<typeof useThreadListActions>["createFolder"];
readonly onRenameFolder: ReturnType<typeof useThreadListActions>["renameFolder"];
readonly onDeleteFolder: ReturnType<typeof useThreadListActions>["deleteFolder"];
readonly onMoveFolder: ReturnType<typeof useThreadListActions>["moveFolder"];
readonly onFolderCollect: ReturnType<typeof useThreadListActions>["setFolderCollect"];
readonly onApplyFolderRule: ReturnType<typeof useThreadListActions>["applyFolderRule"];
readonly onSettleFolder: ReturnType<typeof useThreadListActions>["settleFolder"];
readonly onMoveThread: (
thread: EnvironmentThreadShell,
direction: ThreadMoveDestination,
Expand Down Expand Up @@ -416,6 +426,8 @@ export function HomeScreen(props: HomeScreenProps) {
const showMoreSettled = () =>
setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT);
const {
collapsedFolders,
toggleFolder,
loaded: shelfPreferencesLoaded,
settledShelfExpanded,
snoozedShelfExpanded,
Expand All @@ -437,16 +449,19 @@ export function HomeScreen(props: HomeScreenProps) {
autoSettleOptOutEnvironmentIds,
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
threadFolderEnvironmentIds,
titleRegenerationEnvironmentIds,
} = listEnvironments;
const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
const pendingOrder = usePendingThreadOrder();
const {
folders,
layout: threadListV2Layout,
items: threadListV2Items,
contentOnlyThreadKeys,
olderSettledShelf,
} = useThreadListV2({
collapsedFolders,
threads: props.threads,
pendingTasks: props.pendingTasks,
environmentId: props.selectedEnvironmentId,
Expand Down Expand Up @@ -526,6 +541,25 @@ export function HomeScreen(props: HomeScreenProps) {
/>
);
}
if (item.type === "v2-folder-header") {
return (
<ThreadListV2FolderHeader
item={item}
onToggle={toggleFolder}
onMove={props.onMoveFolder}
onCollect={props.onFolderCollect}
onApplyRule={props.onApplyFolderRule}
onSettle={props.onSettleFolder}
onRename={props.onRenameFolder}
onDelete={props.onDeleteFolder}
environmentLabel={
Object.keys(props.savedConnectionsById).length > 1
? props.savedConnectionsById[item.environmentId]?.environmentLabel
: null
}
/>
);
}
if (item.type === "v2-working-shelf") {
return (
<ThreadListV2WorkingShelfHeader
Expand Down Expand Up @@ -606,10 +640,15 @@ export function HomeScreen(props: HomeScreenProps) {
snoozePresets={snoozePresetsByEnvironmentId.get(thread.environmentId)}
pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
folders={folders}
foldersSupported={threadFolderEnvironmentIds.has(thread.environmentId)}
onMoveThreadToFolder={props.onMoveThreadToFolder}
onCreateFolder={props.onCreateFolder}
reorderSupported={
item.item.pinned
!item.item.folderKey &&
(item.item.pinned
? pinReorderEnvironmentIds.has(thread.environmentId)
: !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId)
: !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId))
}
canMoveUp={item.canMoveUp}
canMoveDown={item.canMoveDown}
Expand All @@ -631,6 +670,7 @@ export function HomeScreen(props: HomeScreenProps) {

// extraData changes invalidate every visible row; put the minute clock on items to limit updates.
const v2ExtraData = {
folders,
activeNewTask,
projectByKey,
projectTitleByProjectKey: v2ProjectTitleByProjectKey,
Expand All @@ -643,6 +683,7 @@ export function HomeScreen(props: HomeScreenProps) {
};

const hasAnyThreads =
threadListV2Layout.folderHeaders.length > 0 ||
props.threads.some((thread) => thread.archivedAt === null) ||
props.pendingTasks.length > 0 ||
threadListV2Layout.settledCount > 0;
Expand Down
113 changes: 93 additions & 20 deletions apps/mobile/src/features/home/useThreadListActions.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { EnvironmentThreadFolder } from "@t3tools/client-runtime/state/thread-folders";
import { makeThreadShellFixture } from "../../test-fixtures";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import {
Expand All @@ -7,6 +8,8 @@ import {
ProviderInstanceId,
RunId,
ThreadId,
ThreadFolderId,
type ThreadFolderMutation,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
Expand All @@ -19,6 +22,8 @@ const state = vi.hoisted(() => ({
archivedRefreshes: [] as string[],
scopes: new Map<string, Set<string>>(),
shells: [] as EnvironmentThreadShell[],
folders: [] as EnvironmentThreadFolder[],
folderRequests: [] as { environmentId: string; input: ThreadFolderMutation }[],
requests: [] as {
action: string;
environmentId: string;
Expand All @@ -42,6 +47,7 @@ vi.mock("react-native", () => ({
state.alerts.push({ title, buttons }),
},
}));
vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" }));
vi.mock("expo-haptics", () => ({
impactAsync: async () => {},
ImpactFeedbackStyle: { Light: "light" },
Expand All @@ -62,35 +68,48 @@ vi.mock("../../state/session", () => ({
}));
vi.mock("../../state/server", () => ({
environmentServerConfigsAtom: "server-configs",
serverEnvironment: {
mutateThreadFolders: async (request: {
environmentId: string;
input: ThreadFolderMutation;
}) => {
state.folderRequests.push(request);
return AsyncResult.success(undefined);
},
},
threadFoldersAtom: "thread-folders",
}));
vi.mock("../../state/atom-registry", () => ({
appAtomRegistry: {
set: (_atom: string, value: boolean) => {
state.dropBusy = value;
},
get: (atom: string) =>
atom === "thread-drop-busy"
? state.dropBusy
: atom === "thread-shells"
? state.shells
: atom === "queued-thread-keys"
? new Set<string>()
: new Map(
[...state.scopes.keys()].map((environmentId) => [
environmentId,
{
environment: {
capabilities: {
threadSettlement: true,
threadSnooze: true,
threadPinning: true,
threadPinReorder: true,
threadTitleRegeneration: true,
atom === "thread-folders"
? state.folders
: atom === "thread-drop-busy"
? state.dropBusy
: atom === "thread-shells"
? state.shells
: atom === "queued-thread-keys"
? new Set<string>()
: new Map(
[...state.scopes.keys()].map((environmentId) => [
environmentId,
{
environment: {
capabilities: {
threadFolders: true,
threadSettlement: true,
threadSnooze: true,
threadPinning: true,
threadPinReorder: true,
threadTitleRegeneration: true,
},
},
},
},
]),
),
]),
),
},
}));
vi.mock("../../state/use-atom-command", () => ({
Expand Down Expand Up @@ -189,6 +208,8 @@ beforeEach(() => {
]);
state.requests = [];
state.shells = [];
state.folders = [];
state.folderRequests = [];
state.dialogs = [];
state.alerts = [];
state.afterRequest = undefined;
Expand Down Expand Up @@ -481,3 +502,55 @@ describe("thread archive guard", () => {
expect(state.alerts.at(-1)?.title).toBe("Could not archive thread");
});
});

describe("folder actions", () => {
it("reorders only within the folder's environment and rejects moves at the ends", async () => {
const folder: EnvironmentThreadFolder = {
environmentId: primaryEnvironmentId,
id: ThreadFolderId.make("first"),
name: "First",
icon: null,
threadIds: [],
rule: { titleKeywords: ["test"] },
autoFile: false,
ruleSetAt: null,
createdAt: "2026-09-02T00:00:00.000Z",
updatedAt: "2026-09-02T00:00:00.000Z",
};
const second = { ...folder, id: ThreadFolderId.make("second") };
const third = { ...folder, id: ThreadFolderId.make("third") };
state.folders = [folder, { ...folder, environmentId: otherEnvironmentId }, second, third];
const actions = useThreadListActions();
const shown = [folder, second, third];
expect(await actions.moveFolder(folder, "up", shown)).toBe(false);
expect(await actions.moveFolder(third, "down", shown)).toBe(false);
expect(await actions.moveFolder(second, "up", shown)).toBe(true);
expect(await actions.moveFolder(folder, "down", shown)).toBe(true);
expect(await actions.moveFolder(second, "down", shown)).toBe(true);
expect(state.folderRequests).toEqual([
{
environmentId: primaryEnvironmentId,
input: { type: "folder.reorder", folderId: second.id, beforeFolderId: folder.id },
},
{
environmentId: primaryEnvironmentId,
input: { type: "folder.reorder", folderId: folder.id, beforeFolderId: third.id },
},
{
environmentId: primaryEnvironmentId,
input: { type: "folder.reorder", folderId: second.id, beforeFolderId: null },
},
]);
expect(await actions.setFolderCollect(folder, { by: "topic" })).toBe(true);
expect(state.folderRequests.at(-1)?.input).toEqual({
type: "folder.update",
folderId: folder.id,
rule: { smart: true },
autoFile: true,
});
state.scopes.get(primaryEnvironmentId)!.clear();
expect(await actions.moveFolder(second, "up", shown)).toBe(false);
expect(await actions.setFolderCollect(folder, { by: "topic" })).toBe(false);
expect(state.folderRequests).toHaveLength(4);
});
});
Loading
Loading