History

Merge branch 'main' into codex/effect-fn-apps-server-scripts-cli

Julius Marminge committed 7 months ago

Commit 47e5f062 parents 026007a 3488ca39 files · +351 −267Browse files

Unified · Split

9 files
  1. apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +4 −3
  2. apps/server/src/checkpointing/Layers/CheckpointStore.ts +138 −135
  3. apps/server/src/git/Layers/ClaudeTextGeneration.ts +108 −114
  4. apps/server/src/open.test.ts +20 −1
  5. apps/server/src/persistence/Migrations.ts +14 −13
  6. apps/web/src/components/ChatView.browser.tsx +46 −0
  7. apps/web/src/components/Icons.tsx +14 −0
  8. apps/web/src/components/chat/OpenInPicker.tsx +6 −1
  9. packages/contracts/src/editor.ts +1 −0
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts+4−3
@@ -21,8 +21,8 @@ const make = Effect.gen(function* () { · 20 unmodified lines
2121 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
2222 const checkpointStore = yield* CheckpointStore;
2323
24− const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = (input) =>
25− Effect.gen(function* () {
24+ const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")(
25+ function* (input) {
2626 const operation = "CheckpointDiffQuery.getTurnDiff";
2727
2828 if (input.fromTurnCount === input.toTurnCount) {
@@ -149,7 +149,8 @@ const make = Effect.gen(function* () { · 120 unmodified lines
149149 }
150150
151151 return turnDiff;
152− });
152+ },
153+ );
153154
154155 const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = (
155156 input: OrchestrationGetFullThreadDiffInput,
apps/server/src/checkpointing/Layers/CheckpointStore.ts+138−135
@@ -86,139 +86,140 @@ const makeCheckpointStore = Effect.gen(function* () { · 85 unmodified lines
8686 Effect.catch(() => Effect.succeed(false)),
8787 );
8888
89− const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) =>
90− Effect.gen(function* () {
91− const operation = "CheckpointStore.captureCheckpoint";
92−
93− yield* Effect.acquireUseRelease(
94− fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }),
95− (tempDir) =>
96− Effect.gen(function* () {
97− const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);
98− const commitEnv: NodeJS.ProcessEnv = {
99− ...process.env,
100− GIT_INDEX_FILE: tempIndexPath,
101− GIT_AUTHOR_NAME: "T3 Code",
102− GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com",
103− GIT_COMMITTER_NAME: "T3 Code",
104− GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",
105− };
106−
107− const headExists = yield* hasHeadCommit(input.cwd);
108− if (headExists) {
109− yield* git.execute({
110− operation,
111− cwd: input.cwd,
112− args: ["read-tree", "HEAD"],
113− env: commitEnv,
114− });
115− }
116−
117− yield* git.execute({
118− operation,
119− cwd: input.cwd,
120− args: ["add", "-A", "--", "."],
121− env: commitEnv,
122− });
123−
124− const writeTreeResult = yield* git.execute({
125− operation,
126− cwd: input.cwd,
127− args: ["write-tree"],
128− env: commitEnv,
129− });
130− const treeOid = writeTreeResult.stdout.trim();
131− if (treeOid.length === 0) {
132− return yield* new GitCommandError({
133− operation,
134− command: "git write-tree",
135− cwd: input.cwd,
136− detail: "git write-tree returned an empty tree oid.",
137− });
138− }
139−
140− const message = `t3 checkpoint ref=${input.checkpointRef}`;
141− const commitTreeResult = yield* git.execute({
142− operation,
143− cwd: input.cwd,
144− args: ["commit-tree", treeOid, "-m", message],
145− env: commitEnv,
146− });
147− const commitOid = commitTreeResult.stdout.trim();
148− if (commitOid.length === 0) {
149− return yield* new GitCommandError({
150− operation,
151− command: "git commit-tree",
152− cwd: input.cwd,
153− detail: "git commit-tree returned an empty commit oid.",
154− });
155− }
156−
157− yield* git.execute({
158− operation,
159− cwd: input.cwd,
160− args: ["update-ref", input.checkpointRef, commitOid],
161− });
162− }),
163− (tempDir) => fs.remove(tempDir, { recursive: true }),
164− ).pipe(
165− Effect.catchTags({
166− PlatformError: (error) =>
167− Effect.fail(
168− new CheckpointInvariantError({
169− operation: "CheckpointStore.captureCheckpoint",
170− detail: "Failed to capture checkpoint.",
171− cause: error,
172− }),
173− ),
174− }),
175− );
176− });
89+ const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn(
90+ "captureCheckpoint",
91+ )(function* (input) {
92+ const operation = "CheckpointStore.captureCheckpoint";
93+
94+ yield* Effect.acquireUseRelease(
95+ fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }),
96+ Effect.fn("captureCheckpoint.withTempDirectory")(function* (tempDir) {
97+ const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);
98+ const commitEnv: NodeJS.ProcessEnv = {
99+ ...process.env,
100+ GIT_INDEX_FILE: tempIndexPath,
101+ GIT_AUTHOR_NAME: "T3 Code",
102+ GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com",
103+ GIT_COMMITTER_NAME: "T3 Code",
104+ GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",
105+ };
106+
107+ const headExists = yield* hasHeadCommit(input.cwd);
108+ if (headExists) {
109+ yield* git.execute({
110+ operation,
111+ cwd: input.cwd,
112+ args: ["read-tree", "HEAD"],
113+ env: commitEnv,
114+ });
115+ }
116+
117+ yield* git.execute({
118+ operation,
119+ cwd: input.cwd,
120+ args: ["add", "-A", "--", "."],
121+ env: commitEnv,
122+ });
123+
124+ const writeTreeResult = yield* git.execute({
125+ operation,
126+ cwd: input.cwd,
127+ args: ["write-tree"],
128+ env: commitEnv,
129+ });
130+ const treeOid = writeTreeResult.stdout.trim();
131+ if (treeOid.length === 0) {
132+ return yield* new GitCommandError({
133+ operation,
134+ command: "git write-tree",
135+ cwd: input.cwd,
136+ detail: "git write-tree returned an empty tree oid.",
137+ });
138+ }
139+
140+ const message = `t3 checkpoint ref=${input.checkpointRef}`;
141+ const commitTreeResult = yield* git.execute({
142+ operation,
143+ cwd: input.cwd,
144+ args: ["commit-tree", treeOid, "-m", message],
145+ env: commitEnv,
146+ });
147+ const commitOid = commitTreeResult.stdout.trim();
148+ if (commitOid.length === 0) {
149+ return yield* new GitCommandError({
150+ operation,
151+ command: "git commit-tree",
152+ cwd: input.cwd,
153+ detail: "git commit-tree returned an empty commit oid.",
154+ });
155+ }
156+
157+ yield* git.execute({
158+ operation,
159+ cwd: input.cwd,
160+ args: ["update-ref", input.checkpointRef, commitOid],
161+ });
162+ }),
163+ (tempDir) => fs.remove(tempDir, { recursive: true }),
164+ ).pipe(
165+ Effect.catchTags({
166+ PlatformError: (error) =>
167+ Effect.fail(
168+ new CheckpointInvariantError({
169+ operation: "CheckpointStore.captureCheckpoint",
170+ detail: "Failed to capture checkpoint.",
171+ cause: error,
172+ }),
173+ ),
174+ }),
175+ );
176+ });
177177
178178 const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) =>
179179 resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe(
180180 Effect.map((commit) => commit !== null),
181181 );
182182
183− const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) =>
184− Effect.gen(function* () {
185− const operation = "CheckpointStore.restoreCheckpoint";
183+ const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = Effect.fn(
184+ "restoreCheckpoint",
185+ )(function* (input) {
186+ const operation = "CheckpointStore.restoreCheckpoint";
186187
187− let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef);
188+ let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef);
188189
189− if (!commitOid && input.fallbackToHead === true) {
190− commitOid = yield* resolveHeadCommit(input.cwd);
191− }
190+ if (!commitOid && input.fallbackToHead === true) {
191+ commitOid = yield* resolveHeadCommit(input.cwd);
192+ }
192193
193− if (!commitOid) {
194− return false;
195− }
194+ if (!commitOid) {
195+ return false;
196+ }
196197
198+ yield* git.execute({
199+ operation,
200+ cwd: input.cwd,
201+ args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],
202+ });
203+ yield* git.execute({
204+ operation,
205+ cwd: input.cwd,
206+ args: ["clean", "-fd", "--", "."],
207+ });
208+
209+ const headExists = yield* hasHeadCommit(input.cwd);
210+ if (headExists) {
197211 yield* git.execute({
198212 operation,
199213 cwd: input.cwd,
200− args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],
201− });
202− yield* git.execute({
203− operation,
204− cwd: input.cwd,
205− args: ["clean", "-fd", "--", "."],
214+ args: ["reset", "--quiet", "--", "."],
206215 });
216+ }
207217
208− const headExists = yield* hasHeadCommit(input.cwd);
209− if (headExists) {
210− yield* git.execute({
211− operation,
212− cwd: input.cwd,
213− args: ["reset", "--quiet", "--", "."],
214− });
215− }
216−
217− return true;
218− });
218+ return true;
219+ });
219220
220− const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) =>
221− Effect.gen(function* () {
221+ const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")(
222+ function* (input) {
222223 const operation = "CheckpointStore.diffCheckpoints";
223224
224225 let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef);
@@ -247,24 +248,26 @@ const makeCheckpointStore = Effect.gen(function* () { · 22 unmodified lines
247248 });
248249
249250 return result.stdout;
250− });
251−
252− const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) =>
253− Effect.gen(function* () {
254− const operation = "CheckpointStore.deleteCheckpointRefs";
255−
256− yield* Effect.forEach(
257− input.checkpointRefs,
258− (checkpointRef) =>
259− git.execute({
260− operation,
261− cwd: input.cwd,
262− args: ["update-ref", "-d", checkpointRef],
263− allowNonZeroExit: true,
264− }),
265− { discard: true },
266− );
267− });
251+ },
252+ );
253+
254+ const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn(
255+ "deleteCheckpointRefs",
256+ )(function* (input) {
257+ const operation = "CheckpointStore.deleteCheckpointRefs";
258+
259+ yield* Effect.forEach(
260+ input.checkpointRefs,
261+ (checkpointRef) =>
262+ git.execute({
263+ operation,
264+ cwd: input.cwd,
265+ args: ["update-ref", "-d", checkpointRef],
266+ allowNonZeroExit: true,
267+ }),
268+ { discard: true },
269+ );
270+ });
268271
269272 return {
270273 isGitRepository,
apps/server/src/git/Layers/ClaudeTextGeneration.ts+108−114
@@ -66,7 +66,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 65 unmodified lines
6666 * Spawn the Claude CLI with structured JSON output and return the parsed,
6767 * schema-validated result.
6868 */
69− const runClaudeJson = <S extends Schema.Top>({
69+ const runClaudeJson = Effect.fn("runClaudeJson")(function* <S extends Schema.Top>({
7070 operation,
7171 cwd,
7272 prompt,
@@ -82,131 +82,125 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 9 unmodified lines
8282 prompt: string;
8383 outputSchemaJson: S;
8484 modelSelection: ClaudeModelSelection;
85− }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
86− Effect.gen(function* () {
87− const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
88− const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities(
89− getClaudeModelCapabilities(modelSelection.model),
90− modelSelection.options,
91− );
92− const settings = {
93− ...(typeof normalizedOptions?.thinking === "boolean"
94− ? { alwaysThinkingEnabled: normalizedOptions.thinking }
95− : {}),
96− ...(normalizedOptions?.fastMode ? { fastMode: true } : {}),
97− };
98−
99− const claudeSettings = yield* Effect.map(
100− serverSettingsService.getSettings,
101− (settings) => settings.providers.claudeAgent,
102− ).pipe(Effect.catch(() => Effect.undefined));
103−
104− const runClaudeCommand = Effect.gen(function* () {
105− const command = ChildProcess.make(
106− claudeSettings?.binaryPath || "claude",
107− [
108− "-p",
109− "--output-format",
110− "json",
111− "--json-schema",
112− jsonSchemaStr,
113− "--model",
114− resolveApiModelId(modelSelection),
115− ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
116− ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
117− "--dangerously-skip-permissions",
118− ],
119− {
120− cwd,
121− shell: process.platform === "win32",
122− stdin: {
123− stream: Stream.encodeText(Stream.make(prompt)),
124− },
85+ }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
86+ const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
87+ const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities(
88+ getClaudeModelCapabilities(modelSelection.model),
89+ modelSelection.options,
90+ );
91+ const settings = {
92+ ...(typeof normalizedOptions?.thinking === "boolean"
93+ ? { alwaysThinkingEnabled: normalizedOptions.thinking }
94+ : {}),
95+ ...(normalizedOptions?.fastMode ? { fastMode: true } : {}),
96+ };
97+
98+ const claudeSettings = yield* Effect.map(
99+ serverSettingsService.getSettings,
100+ (settings) => settings.providers.claudeAgent,
101+ ).pipe(Effect.catch(() => Effect.undefined));
102+
103+ const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {
104+ const command = ChildProcess.make(
105+ claudeSettings?.binaryPath || "claude",
106+ [
107+ "-p",
108+ "--output-format",
109+ "json",
110+ "--json-schema",
111+ jsonSchemaStr,
112+ "--model",
113+ resolveApiModelId(modelSelection),
114+ ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
115+ ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
116+ "--dangerously-skip-permissions",
117+ ],
118+ {
119+ cwd,
120+ shell: process.platform === "win32",
121+ stdin: {
122+ stream: Stream.encodeText(Stream.make(prompt)),
125123 },
124+ },
125+ );
126+
127+ const child = yield* commandSpawner
128+ .spawn(command)
129+ .pipe(
130+ Effect.mapError((cause) =>
131+ normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"),
132+ ),
126133 );
127134
128− const child = yield* commandSpawner
129− .spawn(command)
130− .pipe(
135+ const [stdout, stderr, exitCode] = yield* Effect.all(
136+ [
137+ readStreamAsString(operation, child.stdout),
138+ readStreamAsString(operation, child.stderr),
139+ child.exitCode.pipe(
131140 Effect.mapError((cause) =>
132− normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"),
141+ normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"),
133142 ),
134− );
135−
136− const [stdout, stderr, exitCode] = yield* Effect.all(
137− [
138− readStreamAsString(operation, child.stdout),
139− readStreamAsString(operation, child.stderr),
140− child.exitCode.pipe(
141− Effect.mapError((cause) =>
142− normalizeCliError(
143− "claude",
144− operation,
145− cause,
146− "Failed to read Claude CLI exit code",
147− ),
148− ),
143+ ),
144+ ],
145+ { concurrency: "unbounded" },
146+ );
147+
148+ if (exitCode !== 0) {
149+ const stderrDetail = stderr.trim();
150+ const stdoutDetail = stdout.trim();
151+ const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;
152+ return yield* new TextGenerationError({
153+ operation,
154+ detail:
155+ detail.length > 0
156+ ? `Claude CLI command failed: ${detail}`
157+ : `Claude CLI command failed with code ${exitCode}.`,
158+ });
159+ }
160+
161+ return stdout;
162+ });
163+
164+ const rawStdout = yield* runClaudeCommand().pipe(
165+ Effect.scoped,
166+ Effect.timeoutOption(CLAUDE_TIMEOUT_MS),
167+ Effect.flatMap(
168+ Option.match({
169+ onNone: () =>
170+ Effect.fail(
171+ new TextGenerationError({ operation, detail: "Claude CLI request timed out." }),
149172 ),
150− ],
151− { concurrency: "unbounded" },
152− );
173+ onSome: (value) => Effect.succeed(value),
174+ }),
175+ ),
176+ );
153177
154− if (exitCode !== 0) {
155− const stderrDetail = stderr.trim();
156− const stdoutDetail = stdout.trim();
157− const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;
158− return yield* new TextGenerationError({
178+ const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))(
179+ rawStdout,
180+ ).pipe(
181+ Effect.catchTag("SchemaError", (cause) =>
182+ Effect.fail(
183+ new TextGenerationError({
159184 operation,
160− detail:
161− detail.length > 0
162− ? `Claude CLI command failed: ${detail}`
163− : `Claude CLI command failed with code ${exitCode}.`,
164− });
165− }
166−
167− return stdout;
168− });
169−
170− const rawStdout = yield* runClaudeCommand.pipe(
171− Effect.scoped,
172− Effect.timeoutOption(CLAUDE_TIMEOUT_MS),
173− Effect.flatMap(
174− Option.match({
175− onNone: () =>
176− Effect.fail(
177− new TextGenerationError({ operation, detail: "Claude CLI request timed out." }),
178− ),
179− onSome: (value) => Effect.succeed(value),
185+ detail: "Claude CLI returned unexpected output format.",
186+ cause,
180187 }),
181188 ),
182− );
183−
184− const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))(
185− rawStdout,
186− ).pipe(
187− Effect.catchTag("SchemaError", (cause) =>
188− Effect.fail(
189− new TextGenerationError({
190− operation,
191− detail: "Claude CLI returned unexpected output format.",
192− cause,
193− }),
194− ),
195− ),
196− );
189+ ),
190+ );
197191
198− return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe(
199− Effect.catchTag("SchemaError", (cause) =>
200− Effect.fail(
201− new TextGenerationError({
202− operation,
203− detail: "Claude returned invalid structured output.",
204− cause,
205− }),
206− ),
192+ return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe(
193+ Effect.catchTag("SchemaError", (cause) =>
194+ Effect.fail(
195+ new TextGenerationError({
196+ operation,
197+ detail: "Claude returned invalid structured output.",
198+ cause,
199+ }),
207200 ),
208− );
209− });
201+ ),
202+ );
203+ });
210204
211205 // ---------------------------------------------------------------------------
212206 // TextGenerationShape methods
apps/server/src/open.test.ts+20−1
@@ -31,6 +31,15 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 30 unmodified lines
3131 args: ["/tmp/workspace"],
3232 });
3333
34+ const traeLaunch = yield* resolveEditorLaunch(
35+ { cwd: "/tmp/workspace", editor: "trae" },
36+ "darwin",
37+ );
38+ assert.deepEqual(traeLaunch, {
39+ command: "trae",
40+ args: ["/tmp/workspace"],
41+ });
42+
3443 const vscodeLaunch = yield* resolveEditorLaunch(
3544 { cwd: "/tmp/workspace", editor: "vscode" },
3645 "darwin",
@@ -89,6 +98,15 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 52 unmodified lines
8998 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
9099 });
91100
101+ const traeLineAndColumn = yield* resolveEditorLaunch(
102+ { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "trae" },
103+ "darwin",
104+ );
105+ assert.deepEqual(traeLineAndColumn, {
106+ command: "trae",
107+ args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
108+ });
109+
92110 const vscodeLineAndColumn = yield* resolveEditorLaunch(
93111 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },
94112 "darwin",
@@ -256,6 +274,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 161 unmodified lines
256274 const path = yield* Path.Path;
257275 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
258276
277+ yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n");
259278 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");
260279 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");
261280 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");
@@ -263,7 +282,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 1 unmodified line
263282 PATH: dir,
264283 PATHEXT: ".COM;.EXE;.BAT;.CMD",
265284 });
266− assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]);
285+ assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]);
267286 }),
268287 );
269288 });
apps/server/src/persistence/Migrations.ts+14−13
@@ -92,19 +92,20 @@ export interface RunMigrationsOptions { · 91 unmodified lines
9292 *
9393 * @returns Effect containing array of executed migrations
9494 */
95−export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) =>
96− Effect.gen(function* () {
97− yield* Effect.log(
98− toMigrationInclusive === undefined
99− ? "Running all migrations..."
100− : `Running migrations 1 through ${toMigrationInclusive}...`,
101− );
102− const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) });
103− yield* Effect.log("Migrations ran successfully").pipe(
104− Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }),
105− );
106− return executedMigrations;
107− });
95+export const runMigrations = Effect.fn("runMigrations")(function* ({
96+ toMigrationInclusive,
97+}: RunMigrationsOptions = {}) {
98+ yield* Effect.log(
99+ toMigrationInclusive === undefined
100+ ? "Running all migrations..."
101+ : `Running migrations 1 through ${toMigrationInclusive}...`,
102+ );
103+ const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) });
104+ yield* Effect.log("Migrations ran successfully").pipe(
105+ Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }),
106+ );
107+ return executedMigrations;
108+});
108109
109110 /**
110111 * Layer that runs migrations when the layer is built.
apps/web/src/components/ChatView.browser.tsx+46−0
@@ -1407,6 +1407,52 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1406 unmodified lines
14071407 }
14081408 });
14091409
1410+ it("opens the project cwd with Trae when it is the only available editor", async () => {
1411+ setDraftThreadWithoutWorktree();
1412+
1413+ const mounted = await mountChatView({
1414+ viewport: DEFAULT_VIEWPORT,
1415+ snapshot: createDraftOnlySnapshot(),
1416+ configureFixture: (nextFixture) => {
1417+ nextFixture.serverConfig = {
1418+ ...nextFixture.serverConfig,
1419+ availableEditors: ["trae"],
1420+ };
1421+ },
1422+ });
1423+
1424+ try {
1425+ await waitForServerConfigToApply();
1426+ const openButton = await waitForElement(
1427+ () =>
1428+ Array.from(document.querySelectorAll("button")).find(
1429+ (button) => button.textContent?.trim() === "Open",
1430+ ) as HTMLButtonElement | null,
1431+ "Unable to find Open button.",
1432+ );
1433+ await vi.waitFor(() => {
1434+ expect(openButton.disabled).toBe(false);
1435+ });
1436+ openButton.click();
1437+
1438+ await vi.waitFor(
1439+ () => {
1440+ const openRequest = wsRequests.find(
1441+ (request) => request._tag === WS_METHODS.shellOpenInEditor,
1442+ );
1443+ expect(openRequest).toMatchObject({
1444+ _tag: WS_METHODS.shellOpenInEditor,
1445+ cwd: "/repo/project",
1446+ editor: "trae",
1447+ });
1448+ },
1449+ { timeout: 8_000, interval: 16 },
1450+ );
1451+ } finally {
1452+ await mounted.cleanup();
1453+ }
1454+ });
1455+
14101456 it("filters the open picker menu and opens VSCodium from the menu", async () => {
14111457 setDraftThreadWithoutWorktree();
14121458
apps/web/src/components/Icons.tsx+14−0
@@ -20,6 +20,20 @@ export const CursorIcon: Icon = (props) => ( · 19 unmodified lines
2020 </svg>
2121 );
2222
23+export const TraeIcon: Icon = (props) => (
24+ <svg {...props} viewBox="0 0 24 24" fill="currentColor">
25+ {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */}
26+ <rect x="1" y="4" width="3" height="14" />
27+ <rect x="4" y="18" width="18" height="3" />
28+ {/* Front frame: top bar + right bar only — left and bottom are replaced by the back strips above */}
29+ <rect x="4" y="4" width="18" height="3" />
30+ <rect x="19" y="7" width="3" height="11" />
31+ {/* Two diamonds, offset slightly to the right within the open area */}
32+ <path d="M11 10L13 12L11 14L9 12Z" />
33+ <path d="M16 10L18 12L16 14L14 12Z" />
34+ </svg>
35+);
36+
2337 export const VisualStudioCode: Icon = (props) => {
2438 const id = useId();
2539 const maskId = `${id}-vscode-a`;
apps/web/src/components/chat/OpenInPicker.tsx+6−1
@@ -6,7 +6,7 @@ import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; · 5 unmodified lines
66 import { Button } from "../ui/button";
77 import { Group, GroupSeparator } from "../ui/group";
88 import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu";
9−import { AntigravityIcon, CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons";
9+import { AntigravityIcon, CursorIcon, Icon, TraeIcon, VisualStudioCode, Zed } from "../Icons";
1010 import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";
1111 import { readNativeApi } from "~/nativeApi";
1212
@@ -17,6 +17,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor · 4 unmodified lines
1717 Icon: CursorIcon,
1818 value: "cursor",
1919 },
20+ {
21+ label: "Trae",
22+ Icon: TraeIcon,
23+ value: "trae",
24+ },
2025 {
2126 label: "VS Code",
2227 Icon: VisualStudioCode,
packages/contracts/src/editor.ts+1−0
@@ -3,6 +3,7 @@ import { TrimmedNonEmptyString } from "./baseSchemas"; · 2 unmodified lines
33
44 export const EDITORS = [
55 { id: "cursor", label: "Cursor", command: "cursor", supportsGoto: true },
6+ { id: "trae", label: "Trae", command: "trae", supportsGoto: true },
67 { id: "vscode", label: "VS Code", command: "code", supportsGoto: true },
78 {
89 id: "vscode-insiders",