Skip to content

Commit de91799

Browse files
Merge remote-tracking branch 'origin/main' into t3/leading-space-file-selection
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2 parents eb4cb94 + 28f1786 commit de91799

17 files changed

Lines changed: 238 additions & 65 deletions

‎apps/mobile/src/lib/threadActivity.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -761,7 +761,9 @@ function toWorkLogEntry(
761761
case "file_change":
762762
return {
763763
...common,
764-
changedFiles: item.changes?.map((change) => change.path) ?? [item.fileName],
764+
changedFiles: item.changes?.length
765+
? item.changes.map((change) => change.path)
766+
: [item.fileName],
765767
toolTitle: title ?? "File change",
766768
toolData: item,
767769
};

‎apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ describe("handoff budget", () => {
151151
})?.text,
152152
"File change: src/a.ts, src/b.ts, src/new.ts",
153153
);
154+
assert.equal(
155+
historicalMessage({ ...base, type: "file_change", fileName: "src/a.ts", changes: [] })?.text,
156+
"File change: src/a.ts",
157+
);
154158
assert.isNull(
155159
historicalMessage({
156160
...base,

‎apps/server/src/orchestration-v2/ContextHandoffService.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null {
215215
case "command_execution":
216216
return `- Command: ${compactText(item.input)}`;
217217
case "file_change":
218-
return `- File change: ${compactText(item.changes?.map((change) => change.path).join(", ") ?? item.fileName)}`;
218+
return `- File change: ${compactText(item.changes?.length ? item.changes.map((change) => change.path).join(", ") : item.fileName)}`;
219219
case "checkpoint":
220220
return `- Checkpoint: ${item.files.length} files`;
221221
case "handoff":

‎apps/server/src/workspace/WorkspaceEntries.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -688,17 +688,28 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
688688
yield* git(cwd, ["commit", "-m", "init"], env);
689689
yield* git(cwd, ["worktree", "add", "-b", "x", ".worktrees/x"]);
690690
yield* writeTextFile(cwd, ".worktrees/x/src/foo.py", "print(1)");
691+
const expected = [".worktrees/x/src/foo.py"];
692+
// Windows file names cannot contain a newline.
693+
if ((yield* HostProcess.Platform) !== "win32") {
694+
yield* git(cwd, ["worktree", "add", "--detach", ".worktrees/line\nname"]);
695+
yield* writeTextFile(cwd, ".worktrees/line\nname/src/foo.py", "print(2)");
696+
expected.unshift(".worktrees/line\nname/src/foo.py");
697+
}
691698

692699
const exact = yield* searchWorkspaceEntries({
693700
cwd,
694701
query: "src/foo.py",
695702
limit: 25,
696703
kind: "file",
697704
});
698-
expect(exact.entries).toEqual([{ path: ".worktrees/x/src/foo.py", kind: "file" }]);
705+
expect(exact.entries.toSorted((a, b) => (a.path < b.path ? -1 : 1))).toEqual(
706+
expected.map((path) => ({ path, kind: "file" })),
707+
);
699708

700709
const partial = yield* searchWorkspaceEntries({ cwd, query: "src/fo", limit: 25 });
701-
expect(partial.entries.map((entry) => entry.path)).not.toContain(".worktrees/x/src/foo.py");
710+
for (const path of expected) {
711+
expect(partial.entries.map((entry) => entry.path)).not.toContain(path);
712+
}
702713
}),
703714
);
704715
});

‎apps/server/src/workspace/WorkspaceEntries.ts‎

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -307,23 +307,30 @@ export const make = Effect.gen(function* () {
307307
) {
308308
return [];
309309
}
310-
const listed = yield* vcsProcess
311-
.run({
312-
operation: "WorkspaceEntries.listWorktrees",
313-
command: "git",
314-
args: ["worktree", "list", "--porcelain"],
315-
cwd,
316-
allowNonZeroExit: true,
317-
timeoutMs: 5_000,
318-
})
319-
.pipe(Effect.orElseSucceed(() => undefined));
320-
if (!listed || listed.exitCode !== 0) return [];
310+
const listWorktrees = (args: ReadonlyArray<string>) =>
311+
vcsProcess
312+
.run({
313+
operation: "WorkspaceEntries.listWorktrees",
314+
command: "git",
315+
args: ["worktree", "list", "--porcelain", ...args],
316+
cwd,
317+
allowNonZeroExit: true,
318+
timeoutMs: 5_000,
319+
})
320+
.pipe(
321+
Effect.map((result) => (result.exitCode === 0 ? result.stdout : undefined)),
322+
Effect.orElseSucceed(() => undefined),
323+
);
324+
// Only NUL framing keeps a newline in a worktree path. Pre-2.36 Git rejects `-z`.
325+
const listed =
326+
(yield* listWorktrees(["-z"])) ?? (yield* listWorktrees([]))?.replaceAll("\n", "\0");
327+
if (listed === undefined) return [];
321328
const realCwd = yield* Effect.promise(() => NodeFSP.realpath(cwd).catch(() => cwd));
322-
const worktreeRoots = listed.stdout.split(/\n\s*\n/).flatMap((record) => {
323-
const lines = record.split("\n");
324-
const worktree = lines.find((line) => line.startsWith("worktree "));
329+
const worktreeRoots = listed.split("\0\0").flatMap((record) => {
330+
const fields = record.split("\0");
331+
const worktree = fields.find((field) => field.startsWith("worktree "));
325332
// A `prunable` record's directory is gone; another checkout may now sit there.
326-
if (worktree === undefined || lines.some((line) => line.startsWith("prunable"))) return [];
333+
if (worktree === undefined || fields.some((field) => field.startsWith("prunable"))) return [];
327334
const root = worktree.slice("worktree ".length);
328335
const relativeRoot = path.relative(realCwd, root);
329336
return relativeRoot === "" ||

‎apps/web/src/components/files/useDirectoryEntries.browser.test.tsx‎

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const { listEntries, mocks } = vi.hoisted(() => ({
1212
listEntries: vi.fn(),
1313
mocks: {
1414
reconnect: false,
15+
generation: 0,
1516
reachable: null as unknown as Atom.Writable<boolean>,
1617
connectedSession: null as unknown as Atom.Writable<number>,
1718
},
@@ -21,26 +22,39 @@ vi.mock("~/connection/catalog", () => ({
2122
sessionGenerationAtom: () => Atom.make((get) => get(mocks.connectedSession)),
2223
},
2324
}));
24-
vi.mock("~/state/projects", () => ({
25-
projectEnvironment: {
26-
listEntries: (request: unknown) =>
27-
mocks.reconnect
28-
? Atom.make((get) =>
29-
get(mocks.reachable)
30-
? AsyncResult.success({
31-
entries: [{ path: "README.md", kind: "file" }],
32-
truncated: false,
33-
})
34-
: AsyncResult.failure(Cause.fail(new Error("Environment is reconnecting."))),
35-
)
36-
: Atom.make(Effect.promise(() => listEntries(request))),
37-
},
38-
}));
25+
vi.mock("~/state/projects", () => {
26+
const queries = new Map<string, ReturnType<typeof makeQuery>>();
27+
const makeQuery = (request: unknown) => Atom.make(Effect.promise(() => listEntries(request)));
28+
return {
29+
projectEnvironment: {
30+
listEntries: (request: unknown) =>
31+
mocks.reconnect
32+
? Atom.make((get) =>
33+
get(mocks.reachable)
34+
? AsyncResult.success({
35+
entries: [{ path: "README.md", kind: "file" }],
36+
truncated: false,
37+
})
38+
: AsyncResult.failure(Cause.fail(new Error("Environment is reconnecting."))),
39+
)
40+
: (() => {
41+
const key = JSON.stringify([mocks.generation, request]);
42+
let query = queries.get(key);
43+
if (!query) {
44+
query = makeQuery(request);
45+
queries.set(key, query);
46+
}
47+
return query;
48+
})(),
49+
},
50+
};
51+
});
3952

4053
import { useDirectoryEntries } from "./useDirectoryEntries";
4154

4255
function setup() {
4356
mocks.reconnect = false;
57+
mocks.generation++;
4458
mocks.reachable = Atom.make(false);
4559
mocks.connectedSession = Atom.make(1);
4660
let view!: ReturnType<typeof useDirectoryEntries>;
@@ -125,6 +139,18 @@ it("rescans mutations during both the initial listing and the follow-up", async
125139
await state.settle(2, [entry]);
126140
expect(state.view.entries).toEqual([entry]);
127141
expect(state.view.isPending).toBe(false);
142+
143+
const folder = { path: "src", kind: "directory" } as const;
144+
const child = { path: "src/new.ts", kind: "file" } as const;
145+
await act(async () => {
146+
void state.view.load("src");
147+
});
148+
await state.settle(3, [child]);
149+
await act(async () => {
150+
void state.view.load("", true);
151+
});
152+
await state.settle(4, [folder]);
153+
expect(state.view.entries).toEqual([folder, child]);
128154
});
129155

130156
it("shares the follow-up promise and does not resolve callers before the final listing", async () => {
@@ -214,3 +240,38 @@ it("reads a folder that failed while unreachable again once a new session is up"
214240
});
215241
await expect.element(screen.getByText("README.md")).toBeInTheDocument();
216242
});
243+
244+
it("reloads a removed folder when it reappears under the same name", async () => {
245+
const state = setup();
246+
// The hook probe does not navigate.
247+
await using _screen = await render(<state.Probe />, { router: false });
248+
const folder = { path: "src", kind: "directory" } as const;
249+
await state.settle(0, [folder]);
250+
await act(async () => {
251+
void state.view.load("src");
252+
});
253+
await state.settle(1, [{ path: "src/old.ts", kind: "file" }]);
254+
255+
await act(async () => {
256+
state.view.refresh();
257+
void state.view.load("src", true);
258+
});
259+
await state.settle(2, []);
260+
expect(state.view.entries).toEqual([]);
261+
262+
await act(async () => state.view.refresh());
263+
await state.settle(4, [folder]);
264+
await act(async () => {
265+
void state.view.load("src");
266+
});
267+
const replacementRefresh = state.view.load("src", true);
268+
await state.settle(3, [{ path: "src/old.ts", kind: "file" }]);
269+
expect(state.view.load("src", true)).toBe(replacementRefresh);
270+
expect(state.view.entries).toEqual([folder]);
271+
expect(state.view.loadingDirectories.has("src")).toBe(true);
272+
await state.settle(5, [{ path: "src/new.ts", kind: "file" }]);
273+
await state.settle(6, [{ path: "src/new.ts", kind: "file" }]);
274+
await replacementRefresh;
275+
expect(state.view.entries).toEqual([folder, { path: "src/new.ts", kind: "file" }]);
276+
expect(state.view.isPending).toBe(false);
277+
});

‎apps/web/src/components/files/useDirectoryEntries.ts‎

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
1515
const [loadingDirectories, setLoadingDirectories] = useState<ReadonlySet<string>>(
1616
() => new Set([""]),
1717
);
18+
// Source of truth for `directories`, so a listing can purge folders it no longer holds.
19+
const listings = useRef(new Map<string, readonly ProjectEntry[]>());
1820
const requests = useRef(new Map<string, Promise<void>>());
21+
const detachedRequests = useRef(new Map<string, Promise<void>>());
1922
const followUpRefreshes = useRef(new Map<string, Promise<void>>());
2023
const loaded = useRef(new Set<string>());
2124
const requested = useRef(new Set<string>());
@@ -27,7 +30,10 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
2730
loaded.current.add(directoryPath);
2831
requested.current.add(directoryPath);
2932
const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
33+
const detached = detachedRequests.current.get(directoryPath);
3034
const request = (async () => {
35+
// A shared query still waiting would join the removed folder's read.
36+
if (detached) await detached;
3137
if (running.current >= 4) await new Promise<void>((resolve) => waiting.current.push(resolve));
3238
else running.current++;
3339
const result = active.current
@@ -43,21 +49,44 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
4349
return result;
4450
})()
4551
.then((result) => {
46-
if (!active.current || !result) return;
52+
// A request is dropped from `requests` when its folder disappears, so its read is stale.
53+
if (!active.current || !result || requests.current.get(directoryPath) !== request) return;
4754
if (result._tag === "Success") {
48-
setDirectories((previous) =>
49-
new Map(previous).set(
50-
directoryPath,
51-
result.value.entries.filter(
52-
(entry) =>
53-
entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath,
54-
),
55-
),
55+
const children = result.value.entries.filter(
56+
(entry) =>
57+
entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath,
58+
);
59+
const next = new Map(listings.current).set(directoryPath, children);
60+
const childDirectories = new Set(
61+
children.filter((entry) => entry.kind === "directory").map((entry) => entry.path),
5662
);
63+
const prefix = directoryPath === "" ? "" : `${directoryPath}/`;
64+
const removed = [...requested.current].filter((path) => {
65+
if (path === directoryPath || !path.startsWith(prefix)) return false;
66+
const slash = path.indexOf("/", prefix.length);
67+
return !childDirectories.has(slash === -1 ? path : path.slice(0, slash));
68+
});
69+
for (const path of removed) {
70+
next.delete(path);
71+
loaded.current.delete(path);
72+
requested.current.delete(path);
73+
const detachedRequest = requests.current.get(path);
74+
if (detachedRequest) detachedRequests.current.set(path, detachedRequest);
75+
requests.current.delete(path);
76+
followUpRefreshes.current.delete(path);
77+
}
78+
listings.current = next;
79+
setDirectories(next);
80+
if (removed.length > 0)
81+
setLoadingDirectories((previous) => {
82+
const remaining = new Set(previous);
83+
for (const path of removed) remaining.delete(path);
84+
return remaining;
85+
});
5786
setErrors((previous) => {
58-
const next = new Map(previous);
59-
next.delete(directoryPath);
60-
return next;
87+
const remaining = new Map(previous);
88+
for (const path of [directoryPath, ...removed]) remaining.delete(path);
89+
return remaining;
6190
});
6291
} else {
6392
loaded.current.delete(directoryPath);
@@ -71,10 +100,13 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
71100
}
72101
})
73102
.finally(() => {
74-
requests.current.delete(directoryPath);
103+
if (detachedRequests.current.get(directoryPath) === request)
104+
detachedRequests.current.delete(directoryPath);
105+
const current = requests.current.get(directoryPath) === request;
106+
if (current) requests.current.delete(directoryPath);
75107
if (!active.current) return;
76108
setPending((count) => count - 1);
77-
if (!refresh)
109+
if (!refresh && current)
78110
setLoadingDirectories((previous) => {
79111
const next = new Set(previous);
80112
next.delete(directoryPath);
@@ -92,8 +124,10 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
92124
let followUp = followUpRefreshes.current.get(directoryPath);
93125
if (!followUp) {
94126
followUp = existing.then(() => {
127+
if (followUpRefreshes.current.get(directoryPath) !== followUp) return;
95128
followUpRefreshes.current.delete(directoryPath);
96-
if (active.current) return loadDirectory(directoryPath, true);
129+
if (active.current && requested.current.has(directoryPath))
130+
return loadDirectory(directoryPath, true);
97131
});
98132
followUpRefreshes.current.set(directoryPath, followUp);
99133
}

‎apps/web/src/components/settings/ProjectDefaultsSettings.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type WorktreeSubmodules,
55
managedRuntimeModeDenial,
66
} from "@t3tools/contracts";
7-
import { createModelSelection } from "@t3tools/shared/model";
7+
import { createModelSelection, pickModelSelection } from "@t3tools/shared/model";
88
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
99
import { useNavigate } from "@tanstack/react-router";
1010

@@ -172,7 +172,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
172172
});
173173
}}
174174
onInstanceModelChange={(instanceId, model) =>
175-
setModel(createModelSelection(instanceId, model))
175+
setModel(pickModelSelection(mixedModel ? null : selection, instanceId, model))
176176
}
177177
/>
178178
{!mixedModel && !presentation.providerNote && !effortDisabledReason && activeEntry ? (
@@ -288,7 +288,13 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
288288
});
289289
return;
290290
}
291-
updateSettings({ fallbackModelSelection: createModelSelection(instanceId, model) });
291+
updateSettings({
292+
fallbackModelSelection: pickModelSelection(
293+
mixedFallback ? null : settings.fallbackModelSelection,
294+
instanceId,
295+
model,
296+
),
297+
});
292298
}}
293299
/>
294300
{settings.fallbackModelSelection !== null && fallbackEntry && !mixedFallback ? (

‎apps/web/src/components/settings/SettingsPanels.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
ToolCallVisibility,
2828
} from "@t3tools/contracts/settings";
2929
import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease";
30-
import { createModelSelection } from "@t3tools/shared/model";
30+
import { createModelSelection, pickModelSelection } from "@t3tools/shared/model";
3131
import { resolvePastedPath } from "@t3tools/shared/path";
3232
import * as Equal from "effect/Equal";
3333
import * as Schema from "effect/Schema";
@@ -2046,7 +2046,11 @@ export function ThreadsSettingsPanel() {
20462046
textGenerationModelSelection: resolveAppModelSelectionState(
20472047
{
20482048
...settings,
2049-
textGenerationModelSelection: createModelSelection(instanceId, model),
2049+
textGenerationModelSelection: pickModelSelection(
2050+
textGenerationModelSelection,
2051+
instanceId,
2052+
model,
2053+
),
20502054
},
20512055
textGenerationProviders,
20522056
),

0 commit comments

Comments
 (0)