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
21 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;21 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
22 const checkpointStore = yield* CheckpointStore;22 const checkpointStore = yield* CheckpointStore;
23 23
24 const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = (input) =>24 const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")(
25 Effect.gen(function* () {25 function* (input) {
26 const operation = "CheckpointDiffQuery.getTurnDiff";26 const operation = "CheckpointDiffQuery.getTurnDiff";
27 27
28 if (input.fromTurnCount === input.toTurnCount) {28 if (input.fromTurnCount === input.toTurnCount) {
@@ -149,7 +149,8 @@ const make = Effect.gen(function* () { · 120 unmodified lines
149 }149 }
150 150
151 return turnDiff;151 return turnDiff;
152 });152 },
153 );
153 154
154 const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = (155 const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = (
155 input: OrchestrationGetFullThreadDiffInput,156 input: OrchestrationGetFullThreadDiffInput,
apps/server/src/checkpointing/Layers/CheckpointStore.ts+138−135
@@ -86,139 +86,140 @@ const makeCheckpointStore = Effect.gen(function* () { · 85 unmodified lines
86 Effect.catch(() => Effect.succeed(false)),86 Effect.catch(() => Effect.succeed(false)),
87 );87 );
88 88
89 const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) =>89 const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn(
90 Effect.gen(function* () {90 "captureCheckpoint",
91 const operation = "CheckpointStore.captureCheckpoint";91 )(function* (input) {
92 92 const operation = "CheckpointStore.captureCheckpoint";
93 yield* Effect.acquireUseRelease(93
94 fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }),94 yield* Effect.acquireUseRelease(
95 (tempDir) =>95 fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }),
96 Effect.gen(function* () {96 Effect.fn("captureCheckpoint.withTempDirectory")(function* (tempDir) {
97 const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);97 const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);
98 const commitEnv: NodeJS.ProcessEnv = {98 const commitEnv: NodeJS.ProcessEnv = {
99 ...process.env,99 ...process.env,
100 GIT_INDEX_FILE: tempIndexPath,100 GIT_INDEX_FILE: tempIndexPath,
101 GIT_AUTHOR_NAME: "T3 Code",101 GIT_AUTHOR_NAME: "T3 Code",
102 GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com",102 GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com",
103 GIT_COMMITTER_NAME: "T3 Code",103 GIT_COMMITTER_NAME: "T3 Code",
104 GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",104 GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",
105 };105 };
106 106
107 const headExists = yield* hasHeadCommit(input.cwd);107 const headExists = yield* hasHeadCommit(input.cwd);
108 if (headExists) {108 if (headExists) {
109 yield* git.execute({109 yield* git.execute({
110 operation,110 operation,
111 cwd: input.cwd,111 cwd: input.cwd,
112 args: ["read-tree", "HEAD"],112 args: ["read-tree", "HEAD"],
113 env: commitEnv,113 env: commitEnv,
114 });114 });
115 }115 }
116 116
117 yield* git.execute({117 yield* git.execute({
118 operation,118 operation,
119 cwd: input.cwd,119 cwd: input.cwd,
120 args: ["add", "-A", "--", "."],120 args: ["add", "-A", "--", "."],
121 env: commitEnv,121 env: commitEnv,
122 });122 });
123 123
124 const writeTreeResult = yield* git.execute({124 const writeTreeResult = yield* git.execute({
125 operation,125 operation,
126 cwd: input.cwd,126 cwd: input.cwd,
127 args: ["write-tree"],127 args: ["write-tree"],
128 env: commitEnv,128 env: commitEnv,
129 });129 });
130 const treeOid = writeTreeResult.stdout.trim();130 const treeOid = writeTreeResult.stdout.trim();
131 if (treeOid.length === 0) {131 if (treeOid.length === 0) {
132 return yield* new GitCommandError({132 return yield* new GitCommandError({
133 operation,133 operation,
134 command: "git write-tree",134 command: "git write-tree",
135 cwd: input.cwd,135 cwd: input.cwd,
136 detail: "git write-tree returned an empty tree oid.",136 detail: "git write-tree returned an empty tree oid.",
137 });137 });
138 }138 }
139 139
140 const message = `t3 checkpoint ref=${input.checkpointRef}`;140 const message = `t3 checkpoint ref=${input.checkpointRef}`;
141 const commitTreeResult = yield* git.execute({141 const commitTreeResult = yield* git.execute({
142 operation,142 operation,
143 cwd: input.cwd,143 cwd: input.cwd,
144 args: ["commit-tree", treeOid, "-m", message],144 args: ["commit-tree", treeOid, "-m", message],
145 env: commitEnv,145 env: commitEnv,
146 });146 });
147 const commitOid = commitTreeResult.stdout.trim();147 const commitOid = commitTreeResult.stdout.trim();
148 if (commitOid.length === 0) {148 if (commitOid.length === 0) {
149 return yield* new GitCommandError({149 return yield* new GitCommandError({
150 operation,150 operation,
151 command: "git commit-tree",151 command: "git commit-tree",
152 cwd: input.cwd,152 cwd: input.cwd,
153 detail: "git commit-tree returned an empty commit oid.",153 detail: "git commit-tree returned an empty commit oid.",
154 });154 });
155 }155 }
156 156
157 yield* git.execute({157 yield* git.execute({
158 operation,158 operation,
159 cwd: input.cwd,159 cwd: input.cwd,
160 args: ["update-ref", input.checkpointRef, commitOid],160 args: ["update-ref", input.checkpointRef, commitOid],
161 });161 });
162 }),162 }),
163 (tempDir) => fs.remove(tempDir, { recursive: true }),163 (tempDir) => fs.remove(tempDir, { recursive: true }),
164 ).pipe(164 ).pipe(
165 Effect.catchTags({165 Effect.catchTags({
166 PlatformError: (error) =>166 PlatformError: (error) =>
167 Effect.fail(167 Effect.fail(
168 new CheckpointInvariantError({168 new CheckpointInvariantError({
169 operation: "CheckpointStore.captureCheckpoint",169 operation: "CheckpointStore.captureCheckpoint",
170 detail: "Failed to capture checkpoint.",170 detail: "Failed to capture checkpoint.",
171 cause: error,171 cause: error,
172 }),172 }),
173 ),173 ),
174 }),174 }),
175 );175 );
176 });176 });
177 177
178 const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) =>178 const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) =>
179 resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe(179 resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe(
180 Effect.map((commit) => commit !== null),180 Effect.map((commit) => commit !== null),
181 );181 );
182 182
183 const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) =>183 const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = Effect.fn(
184 Effect.gen(function* () {184 "restoreCheckpoint",
185 const operation = "CheckpointStore.restoreCheckpoint";185 )(function* (input) {
186 const operation = "CheckpointStore.restoreCheckpoint";
186 187
187 let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef);188 let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef);
188 189
189 if (!commitOid && input.fallbackToHead === true) {190 if (!commitOid && input.fallbackToHead === true) {
190 commitOid = yield* resolveHeadCommit(input.cwd);191 commitOid = yield* resolveHeadCommit(input.cwd);
191 }192 }
192 193
193 if (!commitOid) {194 if (!commitOid) {
194 return false;195 return false;
195 }196 }
196 197
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) {
197 yield* git.execute({211 yield* git.execute({
198 operation,212 operation,
199 cwd: input.cwd,213 cwd: input.cwd,
200 args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],214 args: ["reset", "--quiet", "--", "."],
201 });
202 yield* git.execute({
203 operation,
204 cwd: input.cwd,
205 args: ["clean", "-fd", "--", "."],
206 });215 });
216 }
207 217
208 const headExists = yield* hasHeadCommit(input.cwd);218 return true;
209 if (headExists) {219 });
210 yield* git.execute({
211 operation,
212 cwd: input.cwd,
213 args: ["reset", "--quiet", "--", "."],
214 });
215 }
216
217 return true;
218 });
219 220
220 const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) =>221 const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")(
221 Effect.gen(function* () {222 function* (input) {
222 const operation = "CheckpointStore.diffCheckpoints";223 const operation = "CheckpointStore.diffCheckpoints";
223 224
224 let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef);225 let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef);
@@ -247,24 +248,26 @@ const makeCheckpointStore = Effect.gen(function* () { · 22 unmodified lines
247 });248 });
248 249
249 return result.stdout;250 return result.stdout;
250 });251 },
251 252 );
252 const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) =>253
253 Effect.gen(function* () {254 const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn(
254 const operation = "CheckpointStore.deleteCheckpointRefs";255 "deleteCheckpointRefs",
255 256 )(function* (input) {
256 yield* Effect.forEach(257 const operation = "CheckpointStore.deleteCheckpointRefs";
257 input.checkpointRefs,258
258 (checkpointRef) =>259 yield* Effect.forEach(
259 git.execute({260 input.checkpointRefs,
260 operation,261 (checkpointRef) =>
261 cwd: input.cwd,262 git.execute({
262 args: ["update-ref", "-d", checkpointRef],263 operation,
263 allowNonZeroExit: true,264 cwd: input.cwd,
264 }),265 args: ["update-ref", "-d", checkpointRef],
265 { discard: true },266 allowNonZeroExit: true,
266 );267 }),
267 });268 { discard: true },
269 );
270 });
268 271
269 return {272 return {
270 isGitRepository,273 isGitRepository,
apps/server/src/git/Layers/ClaudeTextGeneration.ts+108−114
@@ -66,7 +66,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 65 unmodified lines
66 * Spawn the Claude CLI with structured JSON output and return the parsed,66 * Spawn the Claude CLI with structured JSON output and return the parsed,
67 * schema-validated result.67 * schema-validated result.
68 */68 */
69 const runClaudeJson = <S extends Schema.Top>({69 const runClaudeJson = Effect.fn("runClaudeJson")(function* <S extends Schema.Top>({
70 operation,70 operation,
71 cwd,71 cwd,
72 prompt,72 prompt,
@@ -82,131 +82,125 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 9 unmodified lines
82 prompt: string;82 prompt: string;
83 outputSchemaJson: S;83 outputSchemaJson: S;
84 modelSelection: ClaudeModelSelection;84 modelSelection: ClaudeModelSelection;
85 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>85 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
86 Effect.gen(function* () {86 const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
87 const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));87 const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities(
88 const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities(88 getClaudeModelCapabilities(modelSelection.model),
89 getClaudeModelCapabilities(modelSelection.model),89 modelSelection.options,
90 modelSelection.options,90 );
91 );91 const settings = {
92 const settings = {92 ...(typeof normalizedOptions?.thinking === "boolean"
93 ...(typeof normalizedOptions?.thinking === "boolean"93 ? { alwaysThinkingEnabled: normalizedOptions.thinking }
94 ? { alwaysThinkingEnabled: normalizedOptions.thinking }94 : {}),
95 : {}),95 ...(normalizedOptions?.fastMode ? { fastMode: true } : {}),
96 ...(normalizedOptions?.fastMode ? { fastMode: true } : {}),96 };
97 };97
98 98 const claudeSettings = yield* Effect.map(
99 const claudeSettings = yield* Effect.map(99 serverSettingsService.getSettings,
100 serverSettingsService.getSettings,100 (settings) => settings.providers.claudeAgent,
101 (settings) => settings.providers.claudeAgent,101 ).pipe(Effect.catch(() => Effect.undefined));
102 ).pipe(Effect.catch(() => Effect.undefined));102
103 103 const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {
104 const runClaudeCommand = Effect.gen(function* () {104 const command = ChildProcess.make(
105 const command = ChildProcess.make(105 claudeSettings?.binaryPath || "claude",
106 claudeSettings?.binaryPath || "claude",106 [
107 [107 "-p",
108 "-p",108 "--output-format",
109 "--output-format",109 "json",
110 "json",110 "--json-schema",
111 "--json-schema",111 jsonSchemaStr,
112 jsonSchemaStr,112 "--model",
113 "--model",113 resolveApiModelId(modelSelection),
114 resolveApiModelId(modelSelection),114 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
115 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),115 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
116 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),116 "--dangerously-skip-permissions",
117 "--dangerously-skip-permissions",117 ],
118 ],118 {
119 {119 cwd,
120 cwd,120 shell: process.platform === "win32",
121 shell: process.platform === "win32",121 stdin: {
122 stdin: {122 stream: Stream.encodeText(Stream.make(prompt)),
123 stream: Stream.encodeText(Stream.make(prompt)),
124 },
125 },123 },
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 ),
126 );133 );
127 134
128 const child = yield* commandSpawner135 const [stdout, stderr, exitCode] = yield* Effect.all(
129 .spawn(command)136 [
130 .pipe(137 readStreamAsString(operation, child.stdout),
138 readStreamAsString(operation, child.stderr),
139 child.exitCode.pipe(
131 Effect.mapError((cause) =>140 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"),
133 ),142 ),
134 );143 ),
135 144 ],
136 const [stdout, stderr, exitCode] = yield* Effect.all(145 { concurrency: "unbounded" },
137 [146 );
138 readStreamAsString(operation, child.stdout),147
139 readStreamAsString(operation, child.stderr),148 if (exitCode !== 0) {
140 child.exitCode.pipe(149 const stderrDetail = stderr.trim();
141 Effect.mapError((cause) =>150 const stdoutDetail = stdout.trim();
142 normalizeCliError(151 const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;
143 "claude",152 return yield* new TextGenerationError({
144 operation,153 operation,
145 cause,154 detail:
146 "Failed to read Claude CLI exit code",155 detail.length > 0
147 ),156 ? `Claude CLI command failed: ${detail}`
148 ),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." }),
149 ),172 ),
150 ],173 onSome: (value) => Effect.succeed(value),
151 { concurrency: "unbounded" },174 }),
152 );175 ),
176 );
153 177
154 if (exitCode !== 0) {178 const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))(
155 const stderrDetail = stderr.trim();179 rawStdout,
156 const stdoutDetail = stdout.trim();180 ).pipe(
157 const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;181 Effect.catchTag("SchemaError", (cause) =>
158 return yield* new TextGenerationError({182 Effect.fail(
183 new TextGenerationError({
159 operation,184 operation,
160 detail:185 detail: "Claude CLI returned unexpected output format.",
161 detail.length > 0186 cause,
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),
180 }),187 }),
181 ),188 ),
182 );189 ),
183 190 );
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 );
197 191
198 return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe(192 return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe(
199 Effect.catchTag("SchemaError", (cause) =>193 Effect.catchTag("SchemaError", (cause) =>
200 Effect.fail(194 Effect.fail(
201 new TextGenerationError({195 new TextGenerationError({
202 operation,196 operation,
203 detail: "Claude returned invalid structured output.",197 detail: "Claude returned invalid structured output.",
204 cause,198 cause,
205 }),199 }),
206 ),
207 ),200 ),
208 );201 ),
209 });202 );
203 });
210 204
211 // ---------------------------------------------------------------------------205 // ---------------------------------------------------------------------------
212 // TextGenerationShape methods206 // TextGenerationShape methods
apps/server/src/open.test.ts+20−1
@@ -31,6 +31,15 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 30 unmodified lines
31 args: ["/tmp/workspace"],31 args: ["/tmp/workspace"],
32 });32 });
33 33
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
34 const vscodeLaunch = yield* resolveEditorLaunch(43 const vscodeLaunch = yield* resolveEditorLaunch(
35 { cwd: "/tmp/workspace", editor: "vscode" },44 { cwd: "/tmp/workspace", editor: "vscode" },
36 "darwin",45 "darwin",
@@ -89,6 +98,15 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 52 unmodified lines
89 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],98 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
90 });99 });
91 100
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
92 const vscodeLineAndColumn = yield* resolveEditorLaunch(110 const vscodeLineAndColumn = yield* resolveEditorLaunch(
93 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },111 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },
94 "darwin",112 "darwin",
@@ -256,6 +274,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 161 unmodified lines
256 const path = yield* Path.Path;274 const path = yield* Path.Path;
257 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });275 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
258 276
277 yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n");
259 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");278 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");
260 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");279 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");
261 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");280 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");
@@ -263,7 +282,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 1 unmodified line
263 PATH: dir,282 PATH: dir,
264 PATHEXT: ".COM;.EXE;.BAT;.CMD",283 PATHEXT: ".COM;.EXE;.BAT;.CMD",
265 });284 });
266 assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]);285 assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]);
267 }),286 }),
268 );287 );
269});288});
apps/server/src/persistence/Migrations.ts+14−13
@@ -92,19 +92,20 @@ export interface RunMigrationsOptions { · 91 unmodified lines
92 *92 *
93 * @returns Effect containing array of executed migrations93 * @returns Effect containing array of executed migrations
94 */94 */
95export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) =>95export const runMigrations = Effect.fn("runMigrations")(function* ({
96 Effect.gen(function* () {96 toMigrationInclusive,
97 yield* Effect.log(97}: RunMigrationsOptions = {}) {
98 toMigrationInclusive === undefined98 yield* Effect.log(
99 ? "Running all migrations..."99 toMigrationInclusive === undefined
100 : `Running migrations 1 through ${toMigrationInclusive}...`,100 ? "Running all migrations..."
101 );101 : `Running migrations 1 through ${toMigrationInclusive}...`,
102 const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) });102 );
103 yield* Effect.log("Migrations ran successfully").pipe(103 const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) });
104 Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }),104 yield* Effect.log("Migrations ran successfully").pipe(
105 );105 Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }),
106 return executedMigrations;106 );
107 });107 return executedMigrations;
108});
108 109
109/**110/**
110 * Layer that runs migrations when the layer is built.111 * 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
1407 }1407 }
1408 });1408 });
1409 1409
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
1410 it("filters the open picker menu and opens VSCodium from the menu", async () => {1456 it("filters the open picker menu and opens VSCodium from the menu", async () => {
1411 setDraftThreadWithoutWorktree();1457 setDraftThreadWithoutWorktree();
1412 1458
apps/web/src/components/Icons.tsx+14−0
@@ -20,6 +20,20 @@ export const CursorIcon: Icon = (props) => ( · 19 unmodified lines
20 </svg>20 </svg>
21);21);
22 22
23export 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
23export const VisualStudioCode: Icon = (props) => {37export const VisualStudioCode: Icon = (props) => {
24 const id = useId();38 const id = useId();
25 const maskId = `${id}-vscode-a`;39 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
6import { Button } from "../ui/button";6import { Button } from "../ui/button";
7import { Group, GroupSeparator } from "../ui/group";7import { Group, GroupSeparator } from "../ui/group";
8import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu";8import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu";
9import { AntigravityIcon, CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons";9import { AntigravityIcon, CursorIcon, Icon, TraeIcon, VisualStudioCode, Zed } from "../Icons";
10import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";10import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";
11import { readNativeApi } from "~/nativeApi";11import { readNativeApi } from "~/nativeApi";
12 12
@@ -17,6 +17,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor · 4 unmodified lines
17 Icon: CursorIcon,17 Icon: CursorIcon,
18 value: "cursor",18 value: "cursor",
19 },19 },
20 {
21 label: "Trae",
22 Icon: TraeIcon,
23 value: "trae",
24 },
20 {25 {
21 label: "VS Code",26 label: "VS Code",
22 Icon: VisualStudioCode,27 Icon: VisualStudioCode,
packages/contracts/src/editor.ts+1−0
@@ -3,6 +3,7 @@ import { TrimmedNonEmptyString } from "./baseSchemas"; · 2 unmodified lines
3 3
4export const EDITORS = [4export const EDITORS = [
5 { id: "cursor", label: "Cursor", command: "cursor", supportsGoto: true },5 { id: "cursor", label: "Cursor", command: "cursor", supportsGoto: true },
6 { id: "trae", label: "Trae", command: "trae", supportsGoto: true },
6 { id: "vscode", label: "VS Code", command: "code", supportsGoto: true },7 { id: "vscode", label: "VS Code", command: "code", supportsGoto: true },
7 {8 {
8 id: "vscode-insiders",9 id: "vscode-insiders",