Unified · Split
| @@ -21,8 +21,8 @@ const make = Effect.gen(function* () { · 20 unmodified lines | ||
| 21 | 21 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 22 | 22 | const checkpointStore = yield* CheckpointStore; |
| 23 | 23 | |
| 24 | − const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = (input) => | |
| 25 | − Effect.gen(function* () { | |
| 24 | + const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")( | |
| 25 | + function* (input) { | |
| 26 | 26 | const operation = "CheckpointDiffQuery.getTurnDiff"; |
| 27 | 27 | |
| 28 | 28 | if (input.fromTurnCount === input.toTurnCount) { |
| @@ -149,7 +149,8 @@ const make = Effect.gen(function* () { · 120 unmodified lines | ||
| 149 | 149 | } |
| 150 | 150 | |
| 151 | 151 | return turnDiff; |
| 152 | − }); | |
| 152 | + }, | |
| 153 | + ); | |
| 153 | 154 | |
| 154 | 155 | const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = ( |
| 155 | 156 | input: OrchestrationGetFullThreadDiffInput, |
| @@ -86,139 +86,140 @@ const makeCheckpointStore = Effect.gen(function* () { · 85 unmodified lines | ||
| 86 | 86 | Effect.catch(() => Effect.succeed(false)), |
| 87 | 87 | ); |
| 88 | 88 | |
| 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 | + }); | |
| 177 | 177 | |
| 178 | 178 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => |
| 179 | 179 | resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( |
| 180 | 180 | Effect.map((commit) => commit !== null), |
| 181 | 181 | ); |
| 182 | 182 | |
| 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"; | |
| 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 | − commitOid = yield* resolveHeadCommit(input.cwd); | |
| 191 | − } | |
| 190 | + if (!commitOid && input.fallbackToHead === true) { | |
| 191 | + commitOid = yield* resolveHeadCommit(input.cwd); | |
| 192 | + } | |
| 192 | 193 | |
| 193 | − if (!commitOid) { | |
| 194 | − return false; | |
| 195 | − } | |
| 194 | + if (!commitOid) { | |
| 195 | + return false; | |
| 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 | 211 | yield* git.execute({ |
| 198 | 212 | operation, |
| 199 | 213 | 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", "--", "."], | |
| 206 | 215 | }); |
| 216 | + } | |
| 207 | 217 | |
| 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 | + }); | |
| 219 | 220 | |
| 220 | − const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) => | |
| 221 | − Effect.gen(function* () { | |
| 221 | + const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")( | |
| 222 | + function* (input) { | |
| 222 | 223 | const operation = "CheckpointStore.diffCheckpoints"; |
| 223 | 224 | |
| 224 | 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 | 250 | 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 | + }); | |
| 268 | 271 | |
| 269 | 272 | return { |
| 270 | 273 | isGitRepository, |
| @@ -66,7 +66,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 65 unmodified lines | ||
| 66 | 66 | * Spawn the Claude CLI with structured JSON output and return the parsed, |
| 67 | 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 | 70 | operation, |
| 71 | 71 | cwd, |
| 72 | 72 | prompt, |
| @@ -82,131 +82,125 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 9 unmodified lines | ||
| 82 | 82 | prompt: string; |
| 83 | 83 | outputSchemaJson: S; |
| 84 | 84 | 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)), | |
| 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 | |
| 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( | |
| 131 | 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 | − ); | |
| 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." }), | |
| 149 | 172 | ), |
| 150 | − ], | |
| 151 | − { concurrency: "unbounded" }, | |
| 152 | − ); | |
| 173 | + onSome: (value) => Effect.succeed(value), | |
| 174 | + }), | |
| 175 | + ), | |
| 176 | + ); | |
| 153 | 177 | |
| 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({ | |
| 159 | 184 | 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, | |
| 180 | 187 | }), |
| 181 | 188 | ), |
| 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 | + ); | |
| 197 | 191 | |
| 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 | + }), | |
| 207 | 200 | ), |
| 208 | − ); | |
| 209 | − }); | |
| 201 | + ), | |
| 202 | + ); | |
| 203 | + }); | |
| 210 | 204 | |
| 211 | 205 | // --------------------------------------------------------------------------- |
| 212 | 206 | // TextGenerationShape methods |
| @@ -31,6 +31,15 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 30 unmodified lines | ||
| 31 | 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 | 43 | const vscodeLaunch = yield* resolveEditorLaunch( |
| 35 | 44 | { cwd: "/tmp/workspace", editor: "vscode" }, |
| 36 | 45 | "darwin", |
| @@ -89,6 +98,15 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 52 unmodified lines | ||
| 89 | 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 | 110 | const vscodeLineAndColumn = yield* resolveEditorLaunch( |
| 93 | 111 | { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, |
| 94 | 112 | "darwin", |
| @@ -256,6 +274,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 161 unmodified lines | ||
| 256 | 274 | const path = yield* Path.Path; |
| 257 | 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 | 278 | yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); |
| 260 | 279 | yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); |
| 261 | 280 | yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); |
| @@ -263,7 +282,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 1 unmodified line | ||
| 263 | 282 | PATH: dir, |
| 264 | 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 | 93 | * @returns Effect containing array of executed migrations |
| 94 | 94 | */ |
| 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 | +}); | |
| 108 | 109 | |
| 109 | 110 | /** |
| 110 | 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 | 1456 | it("filters the open picker menu and opens VSCodium from the menu", async () => { |
| 1411 | 1457 | setDraftThreadWithoutWorktree(); |
| 1412 | 1458 | |
| @@ -20,6 +20,20 @@ export const CursorIcon: Icon = (props) => ( · 19 unmodified lines | ||
| 20 | 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 | 37 | export const VisualStudioCode: Icon = (props) => { |
| 24 | 38 | const id = useId(); |
| 25 | 39 | const maskId = `${id}-vscode-a`; |
| @@ -6,7 +6,7 @@ import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; · 5 unmodified lines | ||
| 6 | 6 | import { Button } from "../ui/button"; |
| 7 | 7 | import { Group, GroupSeparator } from "../ui/group"; |
| 8 | 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 | 10 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 11 | 11 | import { readNativeApi } from "~/nativeApi"; |
| 12 | 12 | |
| @@ -17,6 +17,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor · 4 unmodified lines | ||
| 17 | 17 | Icon: CursorIcon, |
| 18 | 18 | value: "cursor", |
| 19 | 19 | }, |
| 20 | + { | |
| 21 | + label: "Trae", | |
| 22 | + Icon: TraeIcon, | |
| 23 | + value: "trae", | |
| 24 | + }, | |
| 20 | 25 | { |
| 21 | 26 | label: "VS Code", |
| 22 | 27 | Icon: VisualStudioCode, |
| @@ -3,6 +3,7 @@ import { TrimmedNonEmptyString } from "./baseSchemas"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | export const EDITORS = [ |
| 5 | 5 | { id: "cursor", label: "Cursor", command: "cursor", supportsGoto: true }, |
| 6 | + { id: "trae", label: "Trae", command: "trae", supportsGoto: true }, | |
| 6 | 7 | { id: "vscode", label: "VS Code", command: "code", supportsGoto: true }, |
| 7 | 8 | { |
| 8 | 9 | id: "vscode-insiders", |