Unified · Split
| @@ -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, |
| @@ -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, |
| @@ -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* commandSpawner | 135 | 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 > 0 | 186 | 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 methods | 206 | // TextGenerationShape methods |
| @@ -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 | }); |
| @@ -92,19 +92,20 @@ export interface RunMigrationsOptions { · 91 unmodified lines | |||
| 92 | * | 92 | * |
| 93 | * @returns Effect containing array of executed migrations | 93 | * @returns Effect containing array of executed migrations |
| 94 | */ | 94 | */ |
| 95 | export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) => | 95 | export const runMigrations = Effect.fn("runMigrations")(function* ({ |
| 96 | Effect.gen(function* () { | 96 | toMigrationInclusive, |
| 97 | yield* Effect.log( | 97 | }: RunMigrationsOptions = {}) { |
| 98 | toMigrationInclusive === undefined | 98 | 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. |
| @@ -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 | ||
| @@ -20,6 +20,20 @@ export const CursorIcon: Icon = (props) => ( · 19 unmodified lines | |||
| 20 | </svg> | 20 | </svg> |
| 21 | ); | 21 | ); |
| 22 | 22 | ||
| 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 | |||
| 23 | export const VisualStudioCode: Icon = (props) => { | 37 | export const VisualStudioCode: Icon = (props) => { |
| 24 | const id = useId(); | 38 | const id = useId(); |
| 25 | const maskId = `${id}-vscode-a`; | 39 | const maskId = `${id}-vscode-a`; |
| @@ -6,7 +6,7 @@ import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; · 5 unmodified lines | |||
| 6 | import { Button } from "../ui/button"; | 6 | import { Button } from "../ui/button"; |
| 7 | import { Group, GroupSeparator } from "../ui/group"; | 7 | import { Group, GroupSeparator } from "../ui/group"; |
| 8 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; | 8 | 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"; |
| 10 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | 10 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 11 | import { readNativeApi } from "~/nativeApi"; | 11 | import { 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, |
| @@ -3,6 +3,7 @@ import { TrimmedNonEmptyString } from "./baseSchemas"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | export const EDITORS = [ | 4 | export 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", |