diff --git a/.claude/commands/audit.md b/.claude/commands/audit.md new file mode 100644 index 000000000..3305f8a0f --- /dev/null +++ b/.claude/commands/audit.md @@ -0,0 +1,19 @@ +--- +description: Audit recent work — trace error paths, probe edge cases, fix any bugs or gaps found +--- + +Audit the work you just did in this session. Don't stop at "it compiles" or "it looks right" — actively go hunting for what's wrong. + +1. **Retrace the changes.** List every file you touched. For each, re-read the final state (not just the diff you remember) so you see what actually lives there now. + +2. **Trace every error path.** For each changed code path: what happens on empty / nil / malformed input? When an upstream caller passes something unexpected? When a dependency throws or times out? Walk the failure branches, not just the happy path. + +3. **Hunt edge cases.** Off-by-ones, empty collections, unicode, concurrency, first-run vs. repeat-run, reduce-motion / accessibility, different device/viewport sizes, streaming vs. terminal states, cancellation, partial failure. Pick the categories that actually apply to what you changed and work through them. + +4. **Check the surrounding contract.** Did the change break any callers, tests, types, styling, or invariants elsewhere? Grep for references to anything you removed or renamed and confirm. + +5. **Fix what you find.** For each real bug or gap, make the fix directly. For anything genuinely ambiguous, call it out rather than guessing. + +6. **Report.** End with a short list: what you checked, what you fixed, and anything you deliberately left alone (and why). + +Be honest — if the work was already solid, say so in one line. Don't manufacture busywork. diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 6ce36ddf7..b6f98b93a 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -21,6 +21,7 @@ Drive a full ADE release end-to-end: figure out what needs to ship (desktop, iOS Mostly autonomous, but **pause for explicit user input** on: - The new version number (if not passed in `$ARGUMENTS`). - The iOS build number (if iOS is in scope and not passed in `$ARGUMENTS`). +- **The iOS target TestFlight group(s)** (always — enumerate groups + tester counts first; never assume a default). See Phase 7a. - Any step that would force-push `main`, bypass a ruleset in a surprising way, or publish a release that is still in `draft=false`. Do NOT publish the GitHub draft release automatically. Leave it as a draft for a human to flip. @@ -257,7 +258,7 @@ RELEASE_SHA=$(git rev-parse origin/main) 3. Once the draft release appears (the workflow creates it), make sure the release body links to the Mintlify changelog page: ```bash - gh release view "v" --json body,isDraft,url + gh release view "v" --json body,isDraft,url,assets gh release edit "v" --notes "$(cat < @@ -270,6 +271,10 @@ RELEASE_SHA=$(git rev-parse origin/main) Leave `isDraft=true`. Do not publish. + Expect the draft to carry both macOS and Windows assets once `publish-release` runs: + - macOS: `ADE--universal.dmg`, `ADE--universal-mac.zip`, `ADE--universal-mac.zip.blockmap`, `latest-mac.yml` + - Windows: `ADE--win-x64.exe`, `ADE--win-x64.exe.blockmap`, `latest.yml` + --- ## Phase 6 — Poll the release workflow @@ -321,13 +326,35 @@ Do not loop in-turn. One poll per wake-up. Skip entirely if `scope.ios=false`. -If `scope.ios=true` and you do not have a build number: +### 7a. Ask the user: build number + target group(s) + +Always pause for these two inputs (even if build number came in via `$ARGUMENTS`, confirm the group choice). Ask together so the user answers once: + +> 1. **Build number.** The last one uploaded for `` was ``. New build will be ``. Override if you want a different number. +> 2. **Target TestFlight group(s).** The workspace has the groups below. Which should receive this build? (comma-separated names or IDs; default = `Internal Testers` if only you will be testing.) -> What build number should this TestFlight build use? The last one uploaded was `` (run `asc builds list --app --limit 5` to confirm). +Before asking, enumerate the groups and their tester counts so the user can pick knowingly: -Validate: must be a positive integer strictly greater than the last build number on record. +```bash +# List all groups (note isInternal) +asc testflight groups list --app "$APP_ID" + +# For each group ID, count actual testers +for gid in ; do + count=$(asc testflight groups links view --group-id "$gid" --type betaTesters \ + | jq -r '.meta.paging.total') + echo "$gid testers=$count" +done +``` -### Pre-flight +**Rules of thumb:** +- **Internal** groups (`isInternalGroup=true`): builds appear for testers as soon as the build is `VALID` and added to the group. No beta app review needed. Use this for dev-only testing. +- **External** groups (`isInternalGroup=false` or `None`): need beta app review (usually auto-approved for subsequent builds of the same marketing version). Use for wider-audience betas. +- **A group with zero testers is invisible.** If you add a build only to an empty group, nobody sees it and no emails go out. Verify tester counts before choosing. + +Validate build number: strictly greater than the last recorded for that marketing version — confirm with `asc builds next-build-number --app "$APP_ID" --version "$MARKETING_VERSION" --platform IOS`. + +### 7b. Pre-flight `AGENTS.md` and the `asc-*` skills are the source of truth. Re-read before every release; the gotchas below are stable but the skill contents may change: @@ -345,9 +372,9 @@ asc doctor Fail fast if keychain auth is broken. -### iOS signing gotchas (mirrored from AGENTS.md — keep in sync) +### 7c. iOS signing gotchas (mirrored from AGENTS.md — keep in sync) -- Project uses **automatic** signing (`CODE_SIGN_STYLE = Automatic`, `DEVELOPMENT_TEAM = VQ372F39G6`). `apps/ios/ExportOptions.plist` ships with `signingStyle = manual` + named profiles for CI determinism. Local ad-hoc exports need `signingStyle = automatic` instead (drop the per-bundle profile map). +- Project uses **automatic** signing (`CODE_SIGN_STYLE = Automatic`, `DEVELOPMENT_TEAM = VQ372F39G6`). `apps/ios/ExportOptions.plist` ships with `signingStyle = manual` + named profiles for CI determinism. Local ad-hoc exports need `signingStyle = automatic` instead (drop the per-bundle profile map). `apps/ios/ExportOptions.auto.plist` is the ready-to-use auto-signing variant. - `asc signing fetch` only downloads provisioning profiles and the `.cer` — it does **not** include the private key. Don't expect it to make local signing work on its own. - Local exports need the ASC API key passed to `xcodebuild`. In addition to `-allowProvisioningUpdates`: ``` @@ -356,54 +383,108 @@ Fail fast if keychain auth is broken. -authenticationKeyIssuerID 4d523a6c-e68c-49b2-8560-34e59786d8e3 ``` Pull current values from `~/.asc/config.json`; do not hard-code. -- After upload, `processingState = VALID` is not enough for TestFlight distribution. Also set `usesNonExemptEncryption` and assign to a group: - ```bash - asc builds update --build-id --uses-non-exempt-encryption=false - asc publish testflight --build --group "" - ``` +- Override the build number at archive time via `--archive-xcodebuild-flag "CURRENT_PROJECT_VERSION="` so you do not need to commit a `pbxproj` bump just to ship a build. -### One-shot publish +### 7d. Do NOT use the one-shot `asc publish testflight --group ... --wait` in local-build mode -Full flow (archive + export + upload + distribute): +That form races: `--wait` returns as soon as `processingState=VALID`, but the build's `usesNonExemptEncryption` is still `None` at that instant. The subsequent `add-groups` then fails with `Build is not in an externally assignable state` because Apple blocks group attachment until the encryption question is answered. Seen on asc 1.2.x. + +### 7e. Safe sequenced flow + +Split the publish into explicit steps so encryption is answered before any group attachment: ```bash +# 1) Archive + export + upload + wait for VALID (no --group here) asc publish testflight \ - --app \ + --app "$APP_ID" \ --project apps/ios/ADE.xcodeproj \ --scheme ADE \ - --version \ - --build-number \ - --export-options \ - --group "" \ - --wait + --version "$MARKETING_VERSION" \ + --export-options apps/ios/ExportOptions.auto.plist \ + --initial-build-number "$BUILD_NUMBER" \ + --archive-path "/tmp/ade-ios-build${BUILD_NUMBER}/ADE.xcarchive" \ + --ipa-path "/tmp/ade-ios-build${BUILD_NUMBER}/ADE.ipa" \ + --wait \ + --timeout 60m \ + --pretty \ + --archive-xcodebuild-flag "-allowProvisioningUpdates" \ + --archive-xcodebuild-flag "-authenticationKeyPath" \ + --archive-xcodebuild-flag "$ASC_KEY_PATH" \ + --archive-xcodebuild-flag "-authenticationKeyID" \ + --archive-xcodebuild-flag "$ASC_KEY_ID" \ + --archive-xcodebuild-flag "-authenticationKeyIssuerID" \ + --archive-xcodebuild-flag "$ASC_ISSUER_ID" \ + --archive-xcodebuild-flag "CURRENT_PROJECT_VERSION=$BUILD_NUMBER" \ + --archive-xcodebuild-flag "MARKETING_VERSION=$MARKETING_VERSION" + +# 2) Resolve the build ID by build number +BUILD_ID=$(asc builds list --app "$APP_ID" --limit 5 \ + | jq -r --arg v "$BUILD_NUMBER" '.data[] | select(.attributes.version == $v) | .id' \ + | head -n 1) + +# 3) Answer encryption BEFORE any group attachment +asc builds update --build-id "$BUILD_ID" --uses-non-exempt-encryption=false + +# 4) Distribute to chosen group(s). --submit --confirm is a no-op if the group is internal +# or if Apple already auto-approved the marketing version. +for gid in "${GROUP_IDS[@]}"; do + asc builds add-groups --build-id "$BUILD_ID" --group "$gid" --submit --confirm +done ``` -If `--wait` times out, fall back to polling via `asc builds get` every 5 minutes using the same `ScheduleWakeup` pattern as Phase 6. Update `.ade/release/v.json` with `status=ios-running` so a re-invocation resumes here. +If `--wait` times out, fall back to polling via `asc builds info --build-id "$BUILD_ID"` every 5 minutes using the `ScheduleWakeup` pattern from Phase 6. Update `.ade/release/v.json` with `status=ios-running`. + +### 7f. Post-upload verification (always run this) -### Post-upload checks +Do not declare iOS done based on `BETA_APPROVED` alone. Verify the build is in a **non-empty** group: ```bash -asc builds get --build-id --json +asc builds info --build-id "$BUILD_ID" # processingState=VALID, usesNonExemptEncryption=false +asc builds build-beta-detail view --build-id "$BUILD_ID" # externalBuildState=BETA_APPROVED, internalBuildState=READY_FOR_BETA_TESTING +for gid in "${GROUP_IDS[@]}"; do + count=$(asc testflight groups links view --group-id "$gid" --type betaTesters | jq -r '.meta.paging.total') + members=$(asc testflight groups links view --group-id "$gid" --type builds | jq -r '.data[].id' | grep -Fx "$BUILD_ID" || true) + echo "group=$gid testers=$count build_present=$([ -n "$members" ] && echo yes || echo no)" +done ``` -Confirm: -- `processingState = VALID` -- `usesNonExemptEncryption` is answered -- Build is in the intended beta group +All three must be true for a given group: +- `testers > 0` (otherwise no humans see the build) +- build appears in the group's builds list +- internal → `READY_FOR_BETA_TESTING`; external → `BETA_APPROVED` -If any check fails, run it explicitly (see gotchas above) and re-verify. +If any fails, fix it explicitly and re-verify. Do not trust `autoNotifyEnabled=true` alone — it only controls push notifications, not distribution. --- ## Phase 8 — Summary -Print a single final block and stop. Example: +Before printing the summary, verify the draft release carries every expected asset. Do not flip the draft and do not report `done` if anything is missing — surface the gap. + +```bash +gh release view "v" --json assets --jq '.assets[].name' | sort +``` + +Expected asset set when `scope.desktop=true`: +- `ADE--universal.dmg` +- `ADE--universal-mac.zip` +- `ADE--universal-mac.zip.blockmap` +- `latest-mac.yml` +- `ADE--win-x64.exe` +- `ADE--win-x64.exe.blockmap` +- `latest.yml` + +If any macOS asset is missing → mac build or upload broke; re-inspect the `build-mac-release` job. +If any Windows asset is missing → `build-win-release` broke or its artifact upload failed; re-inspect that job. Do not flip the draft if Windows artifacts are missing — shipping an asymmetric desktop release will confuse electron-updater consumers on the missing platform. + +Then print a single final block and stop: ``` Release v — summary - Changelog: https://www.ade-app.dev/docs/changelog/v - Draft release: (still draft — flip manually) +- Desktop assets: mac=, windows= - Workflow run: (conclusion: success) - iOS TestFlight build : - Beta group: diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock index bc1d26542..11cff863c 100644 --- a/.claude/scheduled_tasks.lock +++ b/.claude/scheduled_tasks.lock @@ -1 +1 @@ -{"sessionId":"bab81aa2-1bdb-495e-9bf6-3d87ede93f1f","pid":85962,"procStart":"Thu Apr 23 05:29:47 2026","acquiredAt":1776922287064} \ No newline at end of file +{"sessionId":"1676c542-49ae-4b07-80db-808ac138cb4b","pid":24538,"procStart":"Fri Apr 24 04:52:14 2026","acquiredAt":1777006545124} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46020a846..c79ab7f51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,6 +191,41 @@ jobs: key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: node scripts/validate-docs.mjs + # ── Windows build smoke (self-contained — no shared cache) ──────────── + # Runs the same dist:win pipeline that release-core.yml uses, so a PR + # that would break Windows release is caught here instead of at release + # time. Self-contained because windows-latest node_modules contain + # platform-specific native binaries that can't share a Linux cache. + build-win: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + apps/desktop/package-lock.json + apps/ade-cli/package-lock.json + + - name: Install desktop dependencies + run: cd apps/desktop && npm ci + + - name: Install ADE CLI dependencies + run: cd apps/ade-cli && npm ci + + - name: Reset release output + shell: pwsh + run: | + Remove-Item -Recurse -Force apps/desktop/release, apps/desktop/.cache -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path apps/desktop/.cache | Out-Null + + - name: Build and validate Windows release + env: + ELECTRON_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron + ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron-builder + run: cd apps/desktop && npm run dist:win + # ── Gate: all jobs must pass ────────────────────────────────────────── ci-pass: if: always() @@ -205,6 +240,7 @@ jobs: - test-ade-cli - build - validate-docs + - build-win runs-on: ubuntu-latest steps: - name: Check all jobs passed diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 4eab38410..e6f73a22e 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -58,11 +58,11 @@ ade lanes create "fix-checkout-flow" --parent main ade git commit --lane lane-id ade git push --lane lane-id ade prs create --lane lane-id --base main --title "Fix checkout flow" -ade prs path-to-merge --pr pr-id --model gpt-5.4 --max-rounds 3 --no-auto-merge +ade prs path-to-merge --pr pr-id --model gpt-5.5 --max-rounds 3 --no-auto-merge ade run defs --text ade run start web --lane lane-id ade shell start --lane lane-id -- npm test -ade chat create --lane lane-id --model gpt-5.4 +ade chat create --lane lane-id --model gpt-5.5 ade tests run --lane lane-id --suite unit --wait ade proof list --arg ownerKind=chat --arg ownerId=session-id ade actions list diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 049a745f3..503dcd069 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -419,8 +419,30 @@ describe("headlessLinearServices", () => { laneId: "lane-1", }); expect(session.title).toBe("CTO Headless Session"); - expect(session.model).toBe("gpt-5.4-codex"); - expect(session.modelId).toBe("openai/gpt-5.4-codex"); + expect(session.model).toBe("gpt-5.5"); + expect(session.modelId).toBe("openai/gpt-5.5-codex"); + + services.dispose(); + }); + + it("resolves explicit model IDs to their native runtime model refs in headless sessions", async () => { + const services = createHeadlessLinearServices(createDeps()); + + const codex = await services.agentChatService.ensureIdentitySession({ + identityKey: "agent:codex-model", + laneId: "lane-1", + modelId: "openai/gpt-5.5-codex", + }); + const claude = await services.agentChatService.ensureIdentitySession({ + identityKey: "agent:claude-model", + laneId: "lane-1", + modelId: "anthropic/claude-opus-4-7-1m", + }); + + expect(codex.model).toBe("gpt-5.5"); + expect(codex.modelId).toBe("openai/gpt-5.5-codex"); + expect(claude.model).toBe("opus-1m"); + expect(claude.modelId).toBe("anthropic/claude-opus-4-7-1m"); services.dispose(); }); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 017aa4273..e28098233 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -30,7 +30,7 @@ import type { createWorkerTaskSessionService } from "../../desktop/src/main/serv import type { createWorkerHeartbeatService } from "../../desktop/src/main/services/cto/workerHeartbeatService"; import type { createAutomationSecretService } from "../../desktop/src/main/services/automations/automationSecretService"; import type { ComputerUseArtifactBrokerService } from "../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; -import { getModelById, resolveModelAlias } from "../../desktop/src/shared/modelRegistry"; +import { getModelById, getRuntimeModelRefForDescriptor, resolveModelAlias } from "../../desktop/src/shared/modelRegistry"; import type { AdeRuntimePaths } from "./bootstrap"; import { createLinearClient as createLinearClientImpl } from "../../desktop/src/main/services/cto/linearClient"; import { createLinearIssueTracker as createLinearIssueTrackerImpl } from "../../desktop/src/main/services/cto/linearIssueTracker"; @@ -396,7 +396,7 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ const identitySessionIds = new Map(); const transcripts = new Map(); - const HEADLESS_MODEL_ID = "openai/gpt-5.4-codex"; + const HEADLESS_MODEL_ID = "openai/gpt-5.5-codex"; const clipText = (value: string, maxChars: number): string => { const trimmed = value.trim(); @@ -421,7 +421,7 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ if (descriptor) { return { modelId: descriptor.id, - model: descriptor.shortId, + model: getRuntimeModelRefForDescriptor(descriptor), }; } return { diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index c70b6e854..4a2fbffca 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -180,9 +180,12 @@ function createCommandError(command, args, status, stdout, stderr) { ); } +const DEFAULT_RUN_COMMAND_TIMEOUT_MS = 3 * 60 * 1000; + function runCommand(command, args, options = {}) { return new Promise((resolve, reject) => { const useShell = process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command); + const timeoutMs = options.timeoutMs ?? DEFAULT_RUN_COMMAND_TIMEOUT_MS; const child = spawn(command, args, { cwd: options.cwd, env: options.env, @@ -192,6 +195,22 @@ function runCommand(command, args, options = {}) { let stdout = ""; let stderr = ""; + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + try { + child.kill("SIGKILL"); + } catch { + // ignore best-effort kill + } + const rendered = [command, ...args].join(" "); + const details = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n"); + reject(new Error( + `[validate-win-artifacts] Command timed out after ${timeoutMs}ms: ${rendered}` + + (details ? `\n${details}` : ""), + )); + }, timeoutMs); child.stdout?.on("data", (chunk) => { stdout += chunk.toString("utf8"); @@ -200,8 +219,13 @@ function runCommand(command, args, options = {}) { stderr += chunk.toString("utf8"); }); - child.on("error", reject); + child.on("error", (err) => { + clearTimeout(timer); + if (!timedOut) reject(err); + }); child.on("close", (status) => { + clearTimeout(timer); + if (timedOut) return; if (status === 0) { resolve({ stdout, stderr }); return; diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts index f9768b616..2afac9e90 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -118,7 +118,15 @@ async function main(): Promise { })); } -void main().catch((error) => { - process.stderr.write(error instanceof Error ? (error.stack ?? error.message) : String(error)); - process.exit(1); -}); +void main().then( + () => { + // Force a clean exit even if node-pty/Claude SDK/Codex left event-loop handles open. + // Without this, the packaged Electron-as-node child can hang forever after writing JSON to stdout. + // Flush stdout before exiting so the JSON payload isn't truncated. + process.stdout.write("", () => process.exit(0)); + }, + (error) => { + process.stderr.write(error instanceof Error ? (error.stack ?? error.message) : String(error)); + process.exit(1); + }, +); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 90723300a..d71b4f397 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -179,7 +179,7 @@ const DEFAULT_AI_FEATURE_FLAGS: Record = { }; const DEFAULT_CLAUDE_TASK_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_TASK_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex"; +const DEFAULT_CODEX_TASK_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; const TASK_DEFAULTS: Record = { planning: { diff --git a/apps/desktop/src/main/services/ai/claudeModelUtils.ts b/apps/desktop/src/main/services/ai/claudeModelUtils.ts index 209399a3e..4e43da20c 100644 --- a/apps/desktop/src/main/services/ai/claudeModelUtils.ts +++ b/apps/desktop/src/main/services/ai/claudeModelUtils.ts @@ -61,7 +61,7 @@ export function resolveClaudeCliModel(model: string | null | undefined): string */ export function resolveCodexCliModel(model: string | null | undefined): string { const raw = String(model ?? "").trim(); - if (!raw.length) return getDefaultModelDescriptor("codex")?.providerModelId ?? "gpt-5.4"; + if (!raw.length) return getDefaultModelDescriptor("codex")?.providerModelId ?? "gpt-5.5"; const descriptor = getModelById(raw) ?? resolveModelAlias(raw); if (descriptor?.isCliWrapped && descriptor.family === "openai") { diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 6b41b589d..94c38faa4 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -767,7 +767,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + const row = sessions.get(sessionId); + if (row) row.archivedAt = row.archivedAt ?? new Date().toISOString(); + return Boolean(row); + }), + unarchiveSession: vi.fn((sessionId: string) => { + const row = sessions.get(sessionId); + if (row) row.archivedAt = null; + return Boolean(row); + }), updateMeta: vi.fn((args: any) => { const row = sessions.get(args.sessionId); if (row) { @@ -2725,7 +2736,7 @@ describe("createAgentChatService", () => { const commands = service.getSlashCommands({ sessionId: session.id }); const loginCmd = commands.find((c: any) => c.name === "/login"); expect(loginCmd).toBeDefined(); - expect(loginCmd!.source).toBe("local"); + expect(loginCmd!.source).toBe("sdk"); }); it("includes project Claude Code command files before SDK init completes", async () => { @@ -2782,7 +2793,7 @@ describe("createAgentChatService", () => { const loginCmd = commands.find((c: any) => c.name === "/login"); expect(loginCmd).toMatchObject({ description: "Sign in to Claude Code for this chat runtime", - source: "local", + source: "sdk", }); }); @@ -2799,6 +2810,28 @@ describe("createAgentChatService", () => { const loginCmd = commands.find((c: any) => c.name === "/login"); expect(loginCmd).toBeUndefined(); }); + + it("includes Codex prompt files before the app server reports dynamic commands", async () => { + const promptsDir = path.join(tmpRoot, ".codex", "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "audit.md"), "Audit recent work."); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const commands = service.getSlashCommands({ sessionId: session.id }); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/audit", + description: "Audit recent work.", + source: "sdk", + }), + ])); + }); }); it("sends Claude provider slash commands as the raw SDK prompt", async () => { @@ -2851,6 +2884,126 @@ describe("createAgentChatService", () => { }); }); + it("expands project Claude command files before sending to the SDK", async () => { + const commandsDir = path.join(tmpRoot, ".claude", "commands"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "audit.md"), [ + "---", + "description: Audit recent work", + "---", + "", + "Audit the work you just did.", + "", + "Focus: $ARGUMENTS", + "", + ].join("\n")); + + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-project-slash-command", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-project-slash-command", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/audit command menus", + }); + + await vi.waitFor(() => { + expect(send).toHaveBeenLastCalledWith("Audit the work you just did.\n\nFocus: command menus"); + }); + }); + + it("expands Codex prompt files before sending to the app server", async () => { + const promptsDir = path.join(tmpRoot, ".codex", "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "audit.md"), [ + "Audit the Codex chat work.", + "", + "Focus: $ARGUMENTS", + "", + ].join("\n")); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/audit command menus", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start") as any; + expect(turnStartRequest.params.input).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: "Audit the Codex chat work.\n\nFocus: command menus", + }), + ])); + }); + + it("keeps built-in Codex slash commands routed to the app server", async () => { + const promptsDir = path.join(tmpRoot, ".codex", "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "review.md"), "This project prompt must not replace built-in review."); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/review", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "review/start")).toBe(true); + }); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + }); + // -------------------------------------------------------------------------- // updateSession // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1bbb3f134..a1c65e8ed 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -24,7 +24,8 @@ type ClaudeV2Session = { supportedCommands?: () => Promise>; }; import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message"; -import { discoverClaudeSlashCommands } from "./claudeSlashCommandDiscovery"; +import { discoverClaudeSlashCommands, resolveClaudeSlashCommandInvocation } from "./claudeSlashCommandDiscovery"; +import { discoverCodexSlashCommands, resolveCodexSlashCommandInvocation } from "./codexSlashCommandDiscovery"; import type { RuntimeFilePart as FilePart, RuntimeImagePart as ImagePart, @@ -69,6 +70,7 @@ import type { EpisodicSummaryService } from "../memory/episodicSummaryService"; import { DEFAULT_FLUSH_PROMPT } from "../memory/compactionFlushPrompt"; import type { AgentChatApprovalDecision, + AgentChatArchiveArgs, AgentChatCancelSteerArgs, AgentChatClaudePermissionMode, AgentChatCompletionReport, @@ -357,6 +359,88 @@ type ClaudeRuntime = { resumeIdleWatchdog?: (() => void) | null; }; +const CODEX_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ + { name: "/permissions", description: "Set what Codex can do without asking first.", source: "sdk" }, + { name: "/sandbox-add-read-dir", description: "Grant sandbox read access to an extra directory.", source: "sdk" }, + { name: "/agent", description: "Switch the active agent thread.", source: "sdk" }, + { name: "/apps", description: "Browse apps and insert them into your prompt.", source: "sdk" }, + { name: "/plugins", description: "Browse installed and discoverable plugins.", source: "sdk" }, + { name: "/clear", description: "Clear the terminal and start a fresh chat.", source: "sdk" }, + { name: "/compact", description: "Summarize the visible conversation to free tokens.", source: "sdk" }, + { name: "/copy", description: "Copy the latest completed Codex output.", source: "sdk" }, + { name: "/diff", description: "Show the Git diff, including untracked files.", source: "sdk" }, + { name: "/exit", description: "Exit the CLI.", source: "sdk" }, + { name: "/experimental", description: "Toggle experimental features.", source: "sdk" }, + { name: "/feedback", description: "Send logs to the Codex maintainers.", source: "sdk" }, + { name: "/init", description: "Generate an AGENTS.md scaffold in the current directory.", source: "sdk" }, + { name: "/logout", description: "Sign out of Codex.", source: "sdk" }, + { name: "/mcp", description: "List configured MCP tools.", source: "sdk" }, + { name: "/mention", description: "Attach a file to the conversation.", source: "sdk" }, + { name: "/model", description: "Choose the active model and reasoning effort.", source: "sdk" }, + { name: "/fast", description: "Toggle Fast mode for supported models.", source: "sdk" }, + { name: "/plan", description: "Switch to plan mode and optionally send a prompt.", source: "sdk" }, + { name: "/personality", description: "Choose a communication style for responses.", source: "sdk" }, + { name: "/ps", description: "Show experimental background terminals and recent output.", source: "sdk" }, + { name: "/stop", description: "Stop all background terminals.", source: "sdk" }, + { name: "/fork", description: "Fork the current conversation into a new thread.", source: "sdk" }, + { name: "/resume", description: "Resume a saved conversation from your session list.", source: "sdk" }, + { name: "/new", description: "Start a new conversation inside the same CLI session.", source: "sdk" }, + { name: "/quit", description: "Exit the CLI.", source: "sdk" }, + { name: "/review", description: "Ask Codex to review your working tree.", source: "sdk" }, + { name: "/status", description: "Display session configuration and token usage.", source: "sdk" }, + { name: "/debug-config", description: "Print config layer and requirements diagnostics.", source: "sdk" }, + { name: "/statusline", description: "Configure TUI status-line fields.", source: "sdk" }, + { name: "/title", description: "Configure terminal window or tab title fields.", source: "sdk" }, +]; + +const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ + { name: "/add-dir", description: "Add a working directory for file access.", source: "sdk", argumentHint: "" }, + { name: "/agents", description: "Manage agent configurations.", source: "sdk" }, + { name: "/batch", description: "Orchestrate large-scale changes across a codebase in parallel.", source: "sdk", argumentHint: "" }, + { name: "/branch", description: "Create a branch of the current conversation.", source: "sdk", argumentHint: "[name]" }, + { name: "/clear", description: "Start a new conversation with empty context.", source: "sdk" }, + { name: "/compact", description: "Free up context by summarizing the conversation so far.", source: "sdk", argumentHint: "[instructions]" }, + { name: "/config", description: "Open settings.", source: "sdk" }, + { name: "/context", description: "Visualize current context usage.", source: "sdk" }, + { name: "/copy", description: "Copy the last assistant response to clipboard.", source: "sdk", argumentHint: "[N]" }, + { name: "/cost", description: "Alias for usage.", source: "sdk" }, + { name: "/debug", description: "Enable debug logging and troubleshoot issues.", source: "sdk", argumentHint: "[description]" }, + { name: "/diff", description: "Open an interactive diff viewer.", source: "sdk" }, + { name: "/doctor", description: "Diagnose and verify Claude Code installation and settings.", source: "sdk" }, + { name: "/effort", description: "Set the model effort level.", source: "sdk", argumentHint: "[level|auto]" }, + { name: "/exit", description: "Exit the CLI.", source: "sdk" }, + { name: "/export", description: "Export the current conversation as plain text.", source: "sdk", argumentHint: "[filename]" }, + { name: "/fast", description: "Toggle fast mode on or off.", source: "sdk", argumentHint: "[on|off]" }, + { name: "/feedback", description: "Submit feedback about Claude Code.", source: "sdk", argumentHint: "[report]" }, + { name: "/help", description: "Show help and available commands.", source: "sdk" }, + { name: "/hooks", description: "View hook configurations for tool events.", source: "sdk" }, + { name: "/ide", description: "Manage IDE integrations and show status.", source: "sdk" }, + { name: "/init", description: "Initialize project with a CLAUDE.md guide.", source: "sdk" }, + { name: "/login", description: "Sign in to Claude Code for this chat runtime", source: "sdk" }, + { name: "/logout", description: "Sign out from Anthropic.", source: "sdk" }, + { name: "/mcp", description: "Manage MCP server connections and OAuth authentication.", source: "sdk" }, + { name: "/memory", description: "Edit CLAUDE.md memory files and memory settings.", source: "sdk" }, + { name: "/model", description: "Select or change the AI model.", source: "sdk", argumentHint: "[model]" }, + { name: "/permissions", description: "Manage allow, ask, and deny rules for tool permissions.", source: "sdk" }, + { name: "/plan", description: "Enter plan mode directly from the prompt.", source: "sdk", argumentHint: "[description]" }, + { name: "/plugin", description: "Manage Claude Code plugins.", source: "sdk" }, + { name: "/quit", description: "Exit the CLI.", source: "sdk" }, + { name: "/resume", description: "Resume a conversation by ID or name.", source: "sdk", argumentHint: "[session]" }, + { name: "/review", description: "Review a pull request locally in the current session.", source: "sdk", argumentHint: "[PR]" }, + { name: "/rewind", description: "Rewind the conversation and/or code to a previous point.", source: "sdk" }, + { name: "/security-review", description: "Analyze pending changes for security vulnerabilities.", source: "sdk" }, + { name: "/simplify", description: "Review recently changed files for reuse, quality, and efficiency issues.", source: "sdk", argumentHint: "[focus]" }, + { name: "/skills", description: "List available skills.", source: "sdk" }, + { name: "/status", description: "Show version, model, account, and connectivity.", source: "sdk" }, + { name: "/statusline", description: "Configure Claude Code status line.", source: "sdk" }, + { name: "/tasks", description: "List and manage background tasks.", source: "sdk" }, + { name: "/theme", description: "Change the color theme.", source: "sdk" }, + { name: "/usage", description: "Show session cost, plan usage limits, and activity stats.", source: "sdk" }, +]; + +const CODEX_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CODEX_BUILT_IN_SLASH_COMMANDS.map((command) => command.name)); +const CLAUDE_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CLAUDE_BUILT_IN_SLASH_COMMANDS.map((command) => command.name)); + type PendingOpenCodeApproval = { category: "bash" | "write"; permissionId: string; @@ -879,7 +963,7 @@ const DEFAULT_CODEX_DESCRIPTOR = getDefaultModelDescriptor("codex"); const DEFAULT_CLAUDE_DESCRIPTOR = getDefaultModelDescriptor("claude"); const DEFAULT_OPENCODE_DESCRIPTOR = getDefaultModelDescriptor("opencode"); const DEFAULT_CURSOR_DESCRIPTOR = getDefaultModelDescriptor("cursor"); -const DEFAULT_CODEX_MODEL = DEFAULT_CODEX_DESCRIPTOR?.providerModelId ?? "gpt-5.4"; +const DEFAULT_CODEX_MODEL = DEFAULT_CODEX_DESCRIPTOR?.providerModelId ?? "gpt-5.5"; const DEFAULT_CLAUDE_MODEL = DEFAULT_CLAUDE_DESCRIPTOR?.providerModelId ?? DEFAULT_CLAUDE_DESCRIPTOR?.shortId ?? "sonnet"; const DEFAULT_OPENCODE_MODEL_ID = DEFAULT_OPENCODE_DESCRIPTOR?.id ?? "anthropic/claude-sonnet-4-6"; const DEFAULT_CURSOR_MODEL = DEFAULT_CURSOR_DESCRIPTOR?.providerModelId ?? "auto"; @@ -7082,8 +7166,19 @@ export function createAgentChatService(args: { if (betaMessage?.content && Array.isArray(betaMessage.content)) { for (const [blockIndex, block] of betaMessage.content.entries()) { if (block.type === "text") { + // Check both the real-id key AND the id-less fallback key. When + // content_block_delta fires before message_start (or when the + // SDK omits message_start entirely), streamed deltas record + // fallback keys `${turnId}:b${N}:${idx}` into the set. The + // snapshot then arrives with a real id and would otherwise miss + // the dedup, re-emitting the same text and producing a doubled + // bubble in the renderer. const textKey = claudeDedupeKey(assistantMessageId, blockIndex); - if (!textKey || !streamedClaudeTextContentKeys.has(textKey)) { + const fallbackTextKey = assistantMessageId ? claudeDedupeKey(null, blockIndex) : null; + const alreadyStreamed = + (textKey ? streamedClaudeTextContentKeys.has(textKey) : false) + || (fallbackTextKey ? streamedClaudeTextContentKeys.has(fallbackTextKey) : false); + if (!textKey || !alreadyStreamed) { assistantText += block.text ?? ""; emitChatEvent(managed, { type: "text", @@ -7095,8 +7190,13 @@ export function createAgentChatService(args: { } else if (block.type === "thinking") { const thinkingText = block.thinking ?? block.text ?? ""; const reasoningItemId = buildClaudeContentItemId("thinking", blockIndex); + // Same snapshot-vs-delta dedup race as the text branch above. const thinkingKey = claudeDedupeKey(assistantMessageId, blockIndex); - if (thinkingText.trim().length > 0 && (!thinkingKey || !streamedClaudeThinkingContentKeys.has(thinkingKey))) { + const fallbackThinkingKey = assistantMessageId ? claudeDedupeKey(null, blockIndex) : null; + const alreadyStreamedThinking = + (thinkingKey ? streamedClaudeThinkingContentKeys.has(thinkingKey) : false) + || (fallbackThinkingKey ? streamedClaudeThinkingContentKeys.has(fallbackThinkingKey) : false); + if (thinkingText.trim().length > 0 && (!thinkingKey || !alreadyStreamedThinking)) { emitChatEvent(managed, { type: "activity", activity: "thinking", @@ -10387,13 +10487,15 @@ export function createAgentChatService(args: { .filter((entry): entry is AgentChatModelInfo => entry != null); if (models.length) { + const preferredIdx = models.findIndex((entry) => entry.id === DEFAULT_CODEX_MODEL); + if (preferredIdx >= 0) { + return models.map((entry, index) => ({ + ...entry, + isDefault: index === preferredIdx, + })); + } if (!models.some((entry) => entry.isDefault)) { - const preferredIdx = models.findIndex((entry) => entry.id === DEFAULT_CODEX_MODEL); - if (preferredIdx >= 0) { - models[preferredIdx] = { ...models[preferredIdx]!, isDefault: true }; - } else { - models[0] = { ...models[0]!, isDefault: true }; - } + models[0] = { ...models[0]!, isDefault: true }; } return models; } @@ -10921,8 +11023,28 @@ export function createAgentChatService(args: { // false (review 3134504183 / 3134403060). const providerHasPersistentGuidance = managed.session.provider === "claude"; const shouldInjectGuidance = !providerHasPersistentGuidance; + const claudeRuntimeSlashCommandNames = managed.runtime?.kind === "claude" + ? new Set(managed.runtime.slashCommands.map((command) => command.name)) + : new Set(); + const codexRuntimeSlashCommandNames = managed.runtime?.kind === "codex" + ? new Set((managed.runtime as { slashCommands?: Array<{ name: string }> }).slashCommands?.map((command) => command.name) ?? []) + : new Set(); + const expandedClaudeSlashCommand = providerSlashCommand + && managed.session.provider === "claude" + && slashCommand != null + && !CLAUDE_BUILT_IN_SLASH_COMMAND_NAMES.has(slashCommand) + && !claudeRuntimeSlashCommandNames.has(slashCommand) + ? resolveClaudeSlashCommandInvocation(managed.laneWorktreePath, trimmed) + : null; + const expandedCodexSlashCommand = providerSlashCommand + && managed.session.provider === "codex" + && slashCommand != null + && !CODEX_BUILT_IN_SLASH_COMMAND_NAMES.has(slashCommand) + && !codexRuntimeSlashCommandNames.has(slashCommand) + ? resolveCodexSlashCommandInvocation(managed.laneWorktreePath, trimmed) + : null; const promptText = providerSlashCommand - ? trimmed + ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? trimmed : composeLaunchDirectives(trimmed, [ shouldInjectLaneDirective ? buildLaneWorktreeDirective({ @@ -12741,6 +12863,7 @@ export function createAgentChatService(args: { : null, startedAt: row.startedAt, endedAt: row.endedAt, + archivedAt: row.archivedAt ?? null, lastActivityAt: liveSession?.lastActivityAt ?? persisted?.updatedAt ?? row.endedAt ?? row.startedAt, lastOutputPreview: row.lastOutputPreview, summary: row.summary ?? null, @@ -13426,6 +13549,24 @@ export function createAgentChatService(args: { sessionService.deleteSession(trimmedSessionId); }; + const archiveSession = async ({ sessionId }: AgentChatArchiveArgs): Promise => { + const trimmedSessionId = typeof sessionId === "string" ? sessionId.trim() : ""; + if (!trimmedSessionId.length) throw new Error("Chat session id is required."); + const existing = sessionService.get(trimmedSessionId); + if (!existing) throw new Error(`Chat session '${trimmedSessionId}' was not found.`); + if (!isChatToolType(existing.toolType)) throw new Error(`Session '${trimmedSessionId}' is not an agent chat session.`); + sessionService.archiveSession(trimmedSessionId); + }; + + const unarchiveSession = async ({ sessionId }: AgentChatArchiveArgs): Promise => { + const trimmedSessionId = typeof sessionId === "string" ? sessionId.trim() : ""; + if (!trimmedSessionId.length) throw new Error("Chat session id is required."); + const existing = sessionService.get(trimmedSessionId); + if (!existing) throw new Error(`Chat session '${trimmedSessionId}' was not found.`); + if (!isChatToolType(existing.toolType)) throw new Error(`Session '${trimmedSessionId}' is not an agent chat session.`); + sessionService.unarchiveSession(trimmedSessionId); + }; + const disposeAll = async (): Promise => { clearInterval(sessionCleanupTimer); for (const sessionId of [...managedSessions.keys()]) { @@ -13871,54 +14012,53 @@ export function createAgentChatService(args: { if (!managed) return []; const provider = managed.session.provider; - const localCommands: AgentChatSlashCommand[] = provider === "claude" + const localCommands: AgentChatSlashCommand[] = provider === "claude" || provider === "codex" ? [] : [{ name: "/clear", description: "Clear chat history", source: "local" }]; - if (provider === "claude") { - localCommands.push({ - name: "/login", - description: "Sign in to Claude Code for this chat runtime", - source: "local", - }); - } + + const mergeSlashCommands = (groups: AgentChatSlashCommand[][]): AgentChatSlashCommand[] => { + const merged = new Map(); + for (const group of groups) { + for (const command of group) { + merged.set(command.name, command); + } + } + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); + }; // Claude SDK commands plus filesystem-backed Claude Code commands/skills. if (provider === "claude") { - const sdkCommands = managed.runtime?.kind === "claude" ? managed.runtime.slashCommands : []; - const sdkCmds: AgentChatSlashCommand[] = [ - ...sdkCommands, - ...discoverClaudeSlashCommands(managed.laneWorktreePath), - ].map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + const runtimeCommands: AgentChatSlashCommand[] = (managed.runtime?.kind === "claude" ? managed.runtime.slashCommands : []).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, source: "sdk" as const, })); - const merged = new Map(); - for (const command of sdkCmds) { - merged.set(command.name, command); - } - for (const command of localCommands) { - merged.set(command.name, command); - } - return [...merged.values()]; + const projectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(managed.laneWorktreePath).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); + return mergeSlashCommands([projectCommands, CLAUDE_BUILT_IN_SLASH_COMMANDS, runtimeCommands]); } // Codex SDK commands - if (provider === "codex" && managed.runtime?.kind === "codex") { - const rt = managed.runtime; - const sdkCmds: AgentChatSlashCommand[] = rt.slashCommands.map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + if (provider === "codex") { + const rt = managed.runtime?.kind === "codex" ? managed.runtime : null; + const dynamicCommands: AgentChatSlashCommand[] = (rt?.slashCommands ?? []).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, source: "sdk" as const, })); - // Add /review as a built-in Codex command if not already in skills - if (!sdkCmds.some((c) => c.name === "/review")) { - sdkCmds.push({ name: "/review", description: "Review uncommitted changes", source: "sdk" as const }); - } - const sdkNames = new Set(sdkCmds.map((c: AgentChatSlashCommand) => c.name)); - return [...sdkCmds, ...localCommands.filter((c) => !sdkNames.has(c.name))]; + const promptCommands: AgentChatSlashCommand[] = discoverCodexSlashCommands(managed.laneWorktreePath).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); + return mergeSlashCommands([promptCommands, CODEX_BUILT_IN_SLASH_COMMANDS, dynamicCommands]); } // OpenCode / Cursor — only local commands @@ -14240,6 +14380,8 @@ export function createAgentChatService(args: { codexFuzzyFileSearch, dispose, deleteSession, + archiveSession, + unarchiveSession, disposeAll, forceDisposeAll, updateSession, diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts index 7df44c3d0..0ab3f7b62 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { discoverClaudeSlashCommands } from "./claudeSlashCommandDiscovery"; +import { discoverClaudeSlashCommands, resolveClaudeSlashCommandInvocation } from "./claudeSlashCommandDiscovery"; let tmpRoot: string; let homeRoot: string; @@ -153,3 +153,40 @@ describe("discoverClaudeSlashCommands", () => { ]); }); }); + +describe("resolveClaudeSlashCommandInvocation", () => { + it("expands project command files with $ARGUMENTS before sending to Claude", () => { + const commandsDir = path.join(tmpRoot, ".claude", "commands"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "audit.md"), [ + "---", + "description: Audit recent work", + "---", + "", + "Audit the work you just did.", + "", + "Focus: $ARGUMENTS", + "", + ].join("\n")); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/audit chat command menu")).toEqual({ + name: "/audit", + argumentsText: "chat command menu", + promptText: "Audit the work you just did.\n\nFocus: chat command menu", + }); + }); + + it("lets project command files override same-named personal command files", () => { + fs.mkdirSync(path.join(homeRoot, ".claude", "commands"), { recursive: true }); + fs.mkdirSync(path.join(tmpRoot, ".claude", "commands"), { recursive: true }); + fs.writeFileSync(path.join(homeRoot, ".claude", "commands", "ship.md"), "Personal $ARGUMENTS\n"); + fs.writeFileSync(path.join(tmpRoot, ".claude", "commands", "ship.md"), "Project $ARGUMENTS\n"); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/ship now")?.promptText).toBe("Project now"); + }); + + it("returns null for built-in commands and unknown command files", () => { + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/help")).toBeNull(); + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/missing")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts index d6460686f..24bae7e40 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts @@ -9,6 +9,12 @@ export type DiscoveredClaudeSlashCommand = { argumentHint?: string; }; +export type ResolvedClaudeSlashCommandInvocation = { + name: string; + promptText: string; + argumentsText: string; +}; + type CommandFrontmatter = { description?: unknown; "argument-hint"?: unknown; @@ -38,7 +44,7 @@ function readFrontmatter(markdown: string): Record { } function firstMarkdownParagraph(markdown: string): string { - const body = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, ""); + const body = stripFrontmatter(markdown); const paragraph = body .split(/\r?\n\r?\n/) .map((part) => part.trim()) @@ -46,6 +52,10 @@ function firstMarkdownParagraph(markdown: string): string { return paragraph?.split(/\r?\n/)[0]?.trim() ?? ""; } +function stripFrontmatter(markdown: string): string { + return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, ""); +} + function normalizeSlashCommandName(value: string): string | null { const name = value.trim().replace(/\.md$/i, "").replace(/[^A-Za-z0-9_:-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase(); return name.length ? `/${name}` : null; @@ -107,6 +117,55 @@ function discoverLegacyCommands(commandsDir: string): DiscoveredClaudeSlashComma return commands; } +function resolveLegacyCommandFile(commandsDir: string, commandName: string): string | null { + if (!fs.existsSync(commandsDir)) return null; + const commandPathParts = commandName.replace(/^\//, "").split(":").filter(Boolean); + if (!commandPathParts.length) return null; + const candidate = path.join(commandsDir, ...commandPathParts) + ".md"; + const relative = path.relative(commandsDir, candidate); + if (relative.startsWith("..") || path.isAbsolute(relative)) return null; + if (fs.existsSync(candidate)) { + try { + const stat = fs.statSync(candidate); + if (stat.isFile()) return candidate; + } catch { + // fall through to slow-path scan + } + } + // Slow path: discovery normalizes filenames (lowercase + slugified), so a + // file like `My Command.md` is exposed as `/my-command` but the literal + // path above won't find it. Walk the directory and match by normalized + // name so non-canonical filenames still resolve. + const targetName = commandName.toLowerCase(); + let match: string | null = null; + const visit = (dir: string, prefix: string[], depth: number): void => { + if (match || depth > MAX_LEGACY_COMMAND_DEPTH) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (match) return; + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(entryPath, [...prefix, entry.name], depth + 1); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".md")) continue; + const commandPath = [...prefix, entry.name].join(":"); + const normalized = normalizeSlashCommandName(commandPath); + if (normalized && normalized.toLowerCase() === targetName) { + match = entryPath; + return; + } + } + }; + visit(commandsDir, [], 0); + return match; +} + function discoverSkills(skillsDir: string): DiscoveredClaudeSlashCommand[] { const commands: DiscoveredClaudeSlashCommand[] = []; if (!fs.existsSync(skillsDir)) return commands; @@ -161,3 +220,45 @@ export function discoverClaudeSlashCommands(cwd: string): DiscoveredClaudeSlashC return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); } + +export function resolveClaudeSlashCommandInvocation( + cwd: string, + input: string, +): ResolvedClaudeSlashCommandInvocation | null { + const trimmed = input.trim(); + const match = trimmed.match(/^(\/[A-Za-z0-9][A-Za-z0-9_-]*(?::[A-Za-z0-9][A-Za-z0-9_-]*)*)(?:\s+([\s\S]*))?$/); + if (!match) return null; + + const name = match[1]?.toLowerCase(); + if (!name) return null; + const argumentsText = match[2]?.trim() ?? ""; + const roots = [ + path.join(os.homedir(), ".claude"), + path.join(cwd, ".claude"), + ]; + + let commandFile: string | null = null; + for (const root of roots) { + commandFile = resolveLegacyCommandFile(path.join(root, "commands"), name) ?? commandFile; + } + if (!commandFile) return null; + + try { + const content = fs.readFileSync(commandFile, "utf8"); + const body = stripFrontmatter(content).trim(); + if (!body.length) return null; + const hasPlaceholder = /\$ARGUMENTS/.test(body); + const promptText = hasPlaceholder + ? body.replace(/\$ARGUMENTS/g, argumentsText) + : argumentsText.length + ? `${body}\n\nArguments: ${argumentsText}` + : body; + return { + name, + argumentsText, + promptText, + }; + } catch { + return null; + } +} diff --git a/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.test.ts b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.test.ts new file mode 100644 index 000000000..5578246f3 --- /dev/null +++ b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { discoverCodexSlashCommands, resolveCodexSlashCommandInvocation } from "./codexSlashCommandDiscovery"; + +let tmpRoot: string; +let homeRoot: string; + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-codex-prompts-test-")); + homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-codex-home-test-")); + vi.spyOn(os, "homedir").mockReturnValue(homeRoot); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + fs.rmSync(homeRoot, { recursive: true, force: true }); +}); + +describe("discoverCodexSlashCommands", () => { + it("discovers user Codex prompt files as slash commands", () => { + const promptsDir = path.join(homeRoot, ".codex", "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "finalize.md"), "Finalize the current work.\n\nRun checks."); + + expect(discoverCodexSlashCommands(tmpRoot)).toEqual([ + { + name: "/finalize", + description: "Finalize the current work.", + }, + ]); + }); + + it("lets project prompt files override same-named user prompt files", () => { + fs.mkdirSync(path.join(homeRoot, ".codex", "prompts"), { recursive: true }); + fs.mkdirSync(path.join(tmpRoot, ".codex", "prompts"), { recursive: true }); + fs.writeFileSync(path.join(homeRoot, ".codex", "prompts", "audit.md"), "User audit."); + fs.writeFileSync(path.join(tmpRoot, ".codex", "prompts", "audit.md"), "Project audit."); + + expect(discoverCodexSlashCommands(tmpRoot)).toEqual([ + { + name: "/audit", + description: "Project audit.", + }, + ]); + }); +}); + +describe("resolveCodexSlashCommandInvocation", () => { + it("expands Codex prompt files and substitutes $ARGUMENTS", () => { + const promptsDir = path.join(homeRoot, ".codex", "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "audit.md"), "Audit the work.\n\nFocus: $ARGUMENTS"); + + expect(resolveCodexSlashCommandInvocation(tmpRoot, "/audit chat menu")).toEqual({ + name: "/audit", + argumentsText: "chat menu", + promptText: "Audit the work.\n\nFocus: chat menu", + }); + }); + + it("appends arguments when the prompt file has no placeholder", () => { + const promptsDir = path.join(homeRoot, ".codex", "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "trace.md"), "Trace recent changes."); + + expect(resolveCodexSlashCommandInvocation(tmpRoot, "/trace command routing")?.promptText).toBe( + "Trace recent changes.\n\ncommand routing", + ); + }); + + it("returns null for built-in or unknown commands", () => { + expect(resolveCodexSlashCommandInvocation(tmpRoot, "/help")).toBeNull(); + expect(resolveCodexSlashCommandInvocation(tmpRoot, "/missing")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts new file mode 100644 index 000000000..f66e80587 --- /dev/null +++ b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts @@ -0,0 +1,172 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type DiscoveredCodexSlashCommand = { + name: string; + description: string; + argumentHint?: string; +}; + +export type ResolvedCodexSlashCommandInvocation = { + name: string; + promptText: string; + argumentsText: string; +}; + +const MAX_PROMPT_DEPTH = 10; + +function stripFrontmatter(markdown: string): string { + return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, ""); +} + +function firstMarkdownParagraph(markdown: string): string { + const body = stripFrontmatter(markdown); + const paragraph = body + .split(/\r?\n\r?\n/) + .map((part) => part.trim()) + .find((part) => part.length > 0); + return paragraph?.split(/\r?\n/)[0]?.trim() ?? ""; +} + +function normalizeSlashCommandName(value: string): string | null { + const name = value.trim().replace(/\.md$/i, "").replace(/[^A-Za-z0-9_:-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase(); + return name.length ? `/${name}` : null; +} + +function discoverPromptCommands(promptsDir: string): DiscoveredCodexSlashCommand[] { + const commands: DiscoveredCodexSlashCommand[] = []; + if (!fs.existsSync(promptsDir)) return commands; + + const visit = (dir: string, depth = 0): void => { + if (depth > MAX_PROMPT_DEPTH) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(entryPath, depth + 1); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".md")) continue; + const relative = path.relative(promptsDir, entryPath).replace(/\.md$/i, ""); + const commandPath = relative.split(path.sep).filter(Boolean).join(":"); + const name = normalizeSlashCommandName(commandPath); + if (!name) continue; + let content = ""; + try { + content = fs.readFileSync(entryPath, "utf8"); + } catch { + continue; + } + commands.push({ + name, + description: firstMarkdownParagraph(content), + }); + } + }; + + visit(promptsDir); + return commands; +} + +function resolvePromptFile(promptsDir: string, commandName: string): string | null { + if (!fs.existsSync(promptsDir)) return null; + const commandPathParts = commandName.replace(/^\//, "").split(":").filter(Boolean); + if (!commandPathParts.length) return null; + const candidate = path.join(promptsDir, ...commandPathParts) + ".md"; + const relative = path.relative(promptsDir, candidate); + if (relative.startsWith("..") || path.isAbsolute(relative)) return null; + if (fs.existsSync(candidate)) { + try { + const stat = fs.statSync(candidate); + if (stat.isFile()) return candidate; + } catch { + // fall through to slow-path scan + } + } + // Slow path: discovery normalizes filenames (lowercase + slugified), so a + // file like `My Prompt.md` is exposed as `/my-prompt`. Walk the directory + // and match by normalized name so non-canonical filenames still resolve. + const targetName = commandName.toLowerCase(); + let match: string | null = null; + const visit = (dir: string, prefix: string[], depth: number): void => { + if (match || depth > MAX_PROMPT_DEPTH) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (match) return; + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(entryPath, [...prefix, entry.name], depth + 1); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".md")) continue; + const commandPath = [...prefix, entry.name].join(":"); + const normalized = normalizeSlashCommandName(commandPath); + if (normalized && normalized.toLowerCase() === targetName) { + match = entryPath; + return; + } + } + }; + visit(promptsDir, [], 0); + return match; +} + +function codexPromptRoots(cwd: string): string[] { + return [ + path.join(os.homedir(), ".codex", "prompts"), + path.join(cwd, ".codex", "prompts"), + ]; +} + +export function discoverCodexSlashCommands(cwd: string): DiscoveredCodexSlashCommand[] { + const byName = new Map(); + for (const root of codexPromptRoots(cwd)) { + for (const command of discoverPromptCommands(root)) { + byName.set(command.name, command); + } + } + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function resolveCodexSlashCommandInvocation( + cwd: string, + input: string, +): ResolvedCodexSlashCommandInvocation | null { + const trimmed = input.trim(); + const match = trimmed.match(/^(\/[A-Za-z0-9][A-Za-z0-9_-]*(?::[A-Za-z0-9][A-Za-z0-9_-]*)*)(?:\s+([\s\S]*))?$/); + if (!match) return null; + + const name = match[1]?.toLowerCase(); + if (!name) return null; + const argumentsText = match[2]?.trim() ?? ""; + + let promptFile: string | null = null; + for (const root of codexPromptRoots(cwd)) { + promptFile = resolvePromptFile(root, name) ?? promptFile; + } + if (!promptFile) return null; + + try { + const body = stripFrontmatter(fs.readFileSync(promptFile, "utf8")).trim(); + if (!body.length) return null; + const promptText = body.includes("$ARGUMENTS") + ? body.replace(/\$ARGUMENTS/g, argumentsText) + : argumentsText.length + ? `${body}\n\n${argumentsText}` + : body; + return { name, argumentsText, promptText }; + } catch { + return null; + } +} diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 894531bc8..0a6e4b5e2 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -81,7 +81,7 @@ const IMMUTABLE_CTO_DOCTRINE = [ "- Proactively check project health, recent events, and worker status to stay aware of the project state", "", "Precision rules:", - "- When the user specifies a model (e.g. 'use opus', 'use gpt-5.4'), pass the exact modelId to spawnChat or other tools. Never silently fall back to a default.", + "- When the user specifies a model (e.g. 'use opus', 'use gpt-5.5'), pass the exact modelId to spawnChat or other tools. Never silently fall back to a default.", "- When the user asks to 'start a chat' or 'launch an agent', use spawnChat with the specified model and initial prompt. If the user explicitly asks for a terminal, CLI tool, or shell command, use createTerminal instead — both are valid, just match the intent.", "- All ADE internals are fair game. The user can request any action: launching chats, opening terminals, running CLI tools, spawning agents, managing lanes, etc. Never refuse an action that ADE supports.", "- When the user asks about something you can look up (lane status, PR checks, test results), call the tool first and report facts. Do not guess.", @@ -164,10 +164,10 @@ const CTO_ENVIRONMENT_KNOWLEDGE = [ "", "ADE supports multiple AI providers and models. When spawning chats or configuring workers, use the correct modelId:", " Anthropic models (via Claude CLI): anthropic/claude-opus-4-7 (shortId: opus), anthropic/claude-sonnet-4-6 (shortId: sonnet), anthropic/claude-haiku-4-5 (shortId: haiku).", - " OpenAI models (via Codex CLI): openai/gpt-5.4-codex (shortId: gpt-5.4-codex), openai/gpt-5.4-mini-codex, openai/gpt-5.3-codex, openai/gpt-5.3-codex-spark, openai/gpt-5.2-codex, openai/gpt-5.1-codex-max, openai/gpt-5.1-codex-mini.", + " OpenAI models (via Codex CLI): openai/gpt-5.5-codex (shortId: gpt-5.5-codex), openai/gpt-5.4-codex, openai/gpt-5.4-mini-codex, openai/gpt-5.3-codex, openai/gpt-5.3-codex-spark, openai/gpt-5.2-codex, openai/gpt-5.1-codex-max, openai/gpt-5.1-codex-mini.", " Local models: ollama/llama-3.3, lmstudio/* (discovered at runtime).", " Reasoning effort (for supported models): low, medium, high, max (opus), xhigh (openai).", - " IMPORTANT: When the user says 'use opus' → modelId: 'anthropic/claude-opus-4-7'. 'Use sonnet' → 'anthropic/claude-sonnet-4-6'. 'Use gpt-5.4' → 'openai/gpt-5.4-codex'. Always pass the full modelId, never just the shortId, to spawnChat and other tools.", + " IMPORTANT: When the user says 'use opus' → modelId: 'anthropic/claude-opus-4-7'. 'Use sonnet' → 'anthropic/claude-sonnet-4-6'. 'Use gpt-5.5' → 'openai/gpt-5.5-codex'. Always pass the full modelId, never just the shortId, to spawnChat and other tools.", "", "## Critical Distinctions", "", @@ -235,7 +235,7 @@ const CTO_ENVIRONMENT_KNOWLEDGE = [ "## Task Routing (intent → tool mapping)", "", " 'Start a chat' or 'launch an agent' → spawnChat({ modelId, initialPrompt, title }).", - " 'Start a chat with opus/sonnet/gpt-5.4/haiku' → spawnChat({ modelId: '', ... }). Always map the name to the full ID.", + " 'Start a chat with opus/sonnet/gpt-5.5/haiku' → spawnChat({ modelId: '', ... }). Always map the name to the full ID.", " 'Check PR status' → getPullRequestStatus or getPullRequestConvergence.", " 'Start work on [feature]' → create/find a lane, then spawnChat or startMission.", " 'Open a terminal' → createTerminal.", diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 7d5038ee2..46e8c6a35 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -174,6 +174,7 @@ import type { ExportHistoryArgs, ExportHistoryResult, AgentChatApproveArgs, + AgentChatArchiveArgs, AgentChatClaudePermissionMode, AgentChatCreateArgs, AgentChatDeleteArgs, @@ -4526,6 +4527,16 @@ export function registerIpc({ await ctx.agentChatService.dispose(arg); }); + ipcMain.handle(IPC.agentChatArchive, async (_event, arg: AgentChatArchiveArgs): Promise => { + const ctx = getCtx(); + await ctx.agentChatService.archiveSession(arg); + }); + + ipcMain.handle(IPC.agentChatUnarchive, async (_event, arg: AgentChatArchiveArgs): Promise => { + const ctx = getCtx(); + await ctx.agentChatService.unarchiveSession(arg); + }); + ipcMain.handle(IPC.agentChatDelete, async (_event, arg: AgentChatDeleteArgs): Promise => { const ctx = getCtx(); await ctx.agentChatService.deleteSession(arg); diff --git a/apps/desktop/src/main/services/missions/phaseEngine.ts b/apps/desktop/src/main/services/missions/phaseEngine.ts index ed9b7deb7..2b77c1cd8 100644 --- a/apps/desktop/src/main/services/missions/phaseEngine.ts +++ b/apps/desktop/src/main/services/missions/phaseEngine.ts @@ -28,7 +28,7 @@ export const BUILT_IN_PHASE_KEYS = { } as const; const DEFAULT_CLAUDE_PHASE_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_PHASE_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex"; +const DEFAULT_CODEX_PHASE_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; const DEFAULT_MODELS: Record = { [BUILT_IN_PHASE_KEYS.planning]: { modelId: DEFAULT_CLAUDE_PHASE_MODEL_ID, thinkingLevel: "medium" }, diff --git a/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts b/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts index 2e9246579..70c2ffa0d 100644 --- a/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts +++ b/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts @@ -82,7 +82,7 @@ describe("executionPolicy", () => { missionMetadata: { planning: { mode: "manual_review" } } }); expect(policy.planning.mode).toBe("manual_review"); - expect(policy.implementation.model).toBe("openai/gpt-5.4-codex"); // from default + expect(policy.implementation.model).toBe("openai/gpt-5.5-codex"); // from default }); }); diff --git a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts index 1eab2a687..062559f64 100644 --- a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts +++ b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts @@ -39,7 +39,7 @@ import { TERMINAL_STEP_STATUSES, filterExecutionSteps } from "./orchestratorCont // ───────────────────────────────────────────────────── const DEFAULT_CLAUDE_POLICY_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_POLICY_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex"; +const DEFAULT_CODEX_POLICY_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; export const DEFAULT_EXECUTION_POLICY: MissionExecutionPolicy = { planning: { mode: "auto", model: DEFAULT_CLAUDE_POLICY_MODEL_ID }, diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index fae823408..e72d249bc 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -33,6 +33,7 @@ type SessionRow = { status: TerminalSessionStatus; startedAt: string; endedAt: string | null; + archivedAt: string | null; exitCode: number | null; transcriptPath: string; headShaStart: string | null; @@ -57,6 +58,7 @@ const SESSION_COLUMNS = ` s.status as status, s.started_at as startedAt, s.ended_at as endedAt, + s.archived_at as archivedAt, s.exit_code as exitCode, s.transcript_path as transcriptPath, s.head_sha_start as headShaStart, @@ -213,6 +215,7 @@ export function createSessionService({ db }: { db: AdeDb }) { runtimeState: runtimeStateFromStatus(row.status), resumeMetadata, resumeCommand: deriveResumeMetadataCommand(resumeMetadata, row.resumeCommand, toolType), + archivedAt: row.archivedAt ?? null, }; }; @@ -552,6 +555,32 @@ export function createSessionService({ db }: { db: AdeDb }) { ]); }, + archiveSession(sessionId: string, archivedAt: string = new Date().toISOString()): boolean { + const trimmed = sessionId.trim(); + if (!trimmed) return false; + const existing = db.get<{ present: number }>( + "select 1 as present from terminal_sessions where id = ? limit 1", + [trimmed], + ); + if (!existing) return false; + db.run("update terminal_sessions set archived_at = coalesce(archived_at, ?) where id = ?", [archivedAt, trimmed]); + emitChanged({ sessionId: trimmed, reason: "meta-updated" }); + return true; + }, + + unarchiveSession(sessionId: string): boolean { + const trimmed = sessionId.trim(); + if (!trimmed) return false; + const existing = db.get<{ present: number }>( + "select 1 as present from terminal_sessions where id = ? limit 1", + [trimmed], + ); + if (!existing) return false; + db.run("update terminal_sessions set archived_at = null where id = ?", [trimmed]); + emitChanged({ sessionId: trimmed, reason: "meta-updated" }); + return true; + }, + deleteSession(sessionId: string): boolean { const trimmed = sessionId.trim(); if (!trimmed) return false; diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index e806bb60f..6e254f378 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -815,6 +815,7 @@ function migrate(db: MigrationDb) { summary text, resume_command text, resume_metadata_json text, + archived_at text, foreign key(lane_id) references lanes(id) ) `); @@ -827,6 +828,7 @@ function migrate(db: MigrationDb) { try { db.run("alter table terminal_sessions add column resume_command text"); } catch {} try { db.run("alter table terminal_sessions add column resume_metadata_json text"); } catch {} try { db.run("alter table terminal_sessions add column manually_named integer not null default 0"); } catch {} + try { db.run("alter table terminal_sessions add column archived_at text"); } catch {} // Phase 2 process/test config and history tables. db.run(` diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index bfac98687..19853db82 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -650,6 +650,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { laneCount: 4, isAvailable: true, isCached: false, + isOpen: false, }; const connection = { authKind: "bootstrap" as const, diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index b958de1bc..986873602 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -1,5 +1,6 @@ import type { AgentChatCreateArgs, + AgentChatArchiveArgs, AgentChatApproveArgs, AgentChatDisposeArgs, AgentChatFileRef, @@ -663,6 +664,12 @@ function parseAgentChatDisposeArgs(value: Record): AgentChatDis }; } +function parseAgentChatArchiveArgs(value: Record, action: string): AgentChatArchiveArgs { + return { + sessionId: requireString(value.sessionId, `${action} requires sessionId.`), + }; +} + function parseGetTranscriptArgs(value: Record): { sessionId: string; limit?: number; @@ -1669,6 +1676,18 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg await requireService(args.agentChatService, "Agent chat service not available.").dispose(parseAgentChatDisposeArgs(payload)); return { ok: true }; }); + register("chat.archive", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").archiveSession(parseAgentChatArchiveArgs(payload, "chat.archive")); + return { ok: true }; + }); + register("chat.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").unarchiveSession(parseAgentChatArchiveArgs(payload, "chat.unarchive")); + return { ok: true }; + }); + register("chat.delete", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").deleteSession(parseAgentChatArchiveArgs(payload, "chat.delete")); + return { ok: true }; + }); register("chat.models", { viewerAllowed: true }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").getAvailableModels(parseChatModelsArgs(payload))); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index d65409559..5a880a661 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -65,6 +65,7 @@ import type { ExportHistoryResult, AgentTool, AgentChatApproveArgs, + AgentChatArchiveArgs, AgentChatCreateArgs, AgentChatDeleteArgs, AgentChatSuggestLaneNameArgs, @@ -1130,6 +1131,8 @@ declare global { respondToInput: (args: AgentChatRespondToInputArgs) => Promise; models: (args: AgentChatModelsArgs) => Promise; dispose: (args: AgentChatDisposeArgs) => Promise; + archive: (args: AgentChatArchiveArgs) => Promise; + unarchive: (args: AgentChatArchiveArgs) => Promise; delete: (args: AgentChatDeleteArgs) => Promise; updateSession: ( args: AgentChatUpdateSessionArgs, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 619134a73..6e6e0835b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -241,6 +241,7 @@ import type { ExportHistoryResult, AgentTool, AgentChatApproveArgs, + AgentChatArchiveArgs, AgentChatCreateArgs, AgentChatDeleteArgs, AgentChatSuggestLaneNameArgs, @@ -1598,6 +1599,10 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.agentChatModels, args), dispose: async (args: AgentChatDisposeArgs): Promise => ipcRenderer.invoke(IPC.agentChatDispose, args), + archive: async (args: AgentChatArchiveArgs): Promise => + ipcRenderer.invoke(IPC.agentChatArchive, args), + unarchive: async (args: AgentChatArchiveArgs): Promise => + ipcRenderer.invoke(IPC.agentChatUnarchive, args), delete: async (args: AgentChatDeleteArgs): Promise => ipcRenderer.invoke(IPC.agentChatDelete, args), updateSession: async ( diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 775b1f600..d1b1408bf 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -30,7 +30,7 @@ const resolvedArg2 = async (_a: any, _b: any) => v; const DEFAULT_BROWSER_MOCK_CODEX_MODEL = - getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex"; + getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; const MOCK_PROJECT = { id: "browser-mock", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 361875e48..fc39e5a87 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -507,6 +507,16 @@ describe("AgentChatComposer", () => { expect(composerShell?.parentElement?.className ?? "").not.toContain("rounded-none"); }); + it("opts the chat textarea into native typing assistance", () => { + renderComposer(); + + const textarea = screen.getByPlaceholderText("Steer the active turn...") as HTMLTextAreaElement; + expect(textarea.getAttribute("autocomplete")).toBe("on"); + expect(textarea.getAttribute("autocorrect")).toBe("on"); + expect(textarea.getAttribute("autocapitalize")).toBe("sentences"); + expect(textarea.getAttribute("spellcheck")).toBe("true"); + }); + it("focuses the grid composer when the tile becomes active", () => { const props = buildComposerProps({ layoutVariant: "grid-tile", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index bb6e7c235..d6704cdc9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -83,21 +83,11 @@ const LOCAL_SLASH_COMMANDS: SlashCommandEntry[] = [ { command: "/clear", label: "Clear", description: "Clear chat history", source: "local" }, ]; -/** Well-known defaults shown before the SDK session is initialized. */ -const CLAUDE_DEFAULT_COMMANDS: SlashCommandEntry[] = []; - -const CODEX_DEFAULT_COMMANDS: SlashCommandEntry[] = [ - { command: "/review", label: "Review", description: "Review uncommitted changes", source: "sdk" }, - { command: "/help", label: "Help", description: "Show available commands", source: "sdk" }, -]; - -const DEFAULT_COMMANDS_BY_FAMILY: Record = { - anthropic: CLAUDE_DEFAULT_COMMANDS, - openai: CODEX_DEFAULT_COMMANDS, -}; - /** Build the effective slash command list by merging SDK-provided commands with local ones. */ -function buildSlashCommands(sdkCommands: AgentChatSlashCommand[], modelFamily?: string): SlashCommandEntry[] { +function buildSlashCommands( + sdkCommands: AgentChatSlashCommand[], + options: { includeLocalClear: boolean }, +): SlashCommandEntry[] { const result: SlashCommandEntry[] = []; const seen = new Set(); @@ -115,19 +105,11 @@ function buildSlashCommands(sdkCommands: AgentChatSlashCommand[], modelFamily?: }); } - // If no SDK commands loaded yet, show well-known defaults for the provider - if (sdkCommands.length === 0) { - const defaults = (modelFamily ? DEFAULT_COMMANDS_BY_FAMILY[modelFamily] : undefined) ?? []; - for (const cmd of defaults) { - if (!seen.has(cmd.command)) { - seen.add(cmd.command); - result.push(cmd); - } - } - } - - // Local commands that aren't already provided by SDK + // Local commands that aren't already provided by SDK. Skip /clear when no + // handler is wired up — otherwise selecting it falls through to the generic + // draft path and sends literal "/clear" text to the model. for (const cmd of LOCAL_SLASH_COMMANDS) { + if (cmd.command === "/clear" && !options.includeLocalClear) continue; if (!seen.has(cmd.command)) { result.push(cmd); } @@ -517,9 +499,6 @@ export function AgentChatComposer({ const [attachmentCursor, setAttachmentCursor] = useState(0); const [attachError, setAttachError] = useState(null); - const [slashPickerOpen, setSlashPickerOpen] = useState(false); - const [slashQuery, setSlashQuery] = useState(""); - const [slashCursor, setSlashCursor] = useState(0); const [hoveredClaudeMode, setHoveredClaudeMode] = useState(null); const [hoveredCodexPreset, setHoveredCodexPreset] = useState | null>(null); const [claudeModePickerOpen, setClaudeModePickerOpen] = useState(false); @@ -560,24 +539,11 @@ export function AgentChatComposer({ }, [draft, resizeTextarea]); const attachedPaths = useMemo(() => new Set(attachments.map((a) => a.path)), [attachments]); - const selectedModel = useMemo(() => getModelById(modelId), [modelId]); - const effectiveSlashCommands = useMemo( - () => buildSlashCommands(sdkSlashCommands, selectedModel?.family), - [sdkSlashCommands, selectedModel?.family], + () => buildSlashCommands(sdkSlashCommands, { includeLocalClear: typeof onClearEvents === "function" }), + [sdkSlashCommands, onClearEvents], ); - const filteredSlashCommands = useMemo(() => { - if (!slashQuery.length) return effectiveSlashCommands; - const q = slashQuery.toLowerCase(); - return effectiveSlashCommands.filter( - (cmd) => - cmd.command.toLowerCase().includes(q) || - cmd.label.toLowerCase().includes(q) || - cmd.description.toLowerCase().includes(q) - ); - }, [slashQuery, effectiveSlashCommands]); - /* ── Attachment picker effects ── */ useEffect(() => { if (!attachmentPickerOpen) { @@ -681,15 +647,13 @@ export function AgentChatComposer({ } }; - const handleSlashSelect = (cmd: SlashCommandEntry) => { - setSlashPickerOpen(false); - setSlashQuery(""); + const handleSlashSelect = useCallback((cmd: SlashCommandEntry) => { // Local-only commands handled client-side if (cmd.command === "/clear" && cmd.source === "local" && onClearEvents) { onClearEvents(); onDraftChange(""); return; } // SDK and all other commands: set as draft text to be sent to the agent const suffix = cmd.argumentHint ? ` ${cmd.argumentHint}` : ""; onDraftChange(`${cmd.command}${suffix} `); - }; + }, [onClearEvents, onDraftChange]); const nativeControlsDisabled = permissionModeLocked; const slot = parallelControlSlot; @@ -1261,25 +1225,6 @@ export function AgentChatComposer({ const handleKeyDown = (event: React.KeyboardEvent) => { const commandModified = event.metaKey || event.ctrlKey; - /* Slash picker keyboard */ - if (slashPickerOpen) { - if (event.key === "Escape") { event.preventDefault(); setSlashPickerOpen(false); setSlashQuery(""); return; } - if (event.key === "ArrowDown") { event.preventDefault(); setSlashCursor((v) => Math.min(v + 1, Math.max(filteredSlashCommands.length - 1, 0))); return; } - if (event.key === "ArrowUp") { event.preventDefault(); setSlashCursor((v) => Math.max(v - 1, 0)); return; } - if (event.key === "Enter" || event.key === "Tab") { - const cmd = filteredSlashCommands[slashCursor]; - if (cmd) { event.preventDefault(); handleSlashSelect(cmd); return; } - } - } - - /* Trigger pickers — let "/" be typed so onChange can filter */ - if (event.key === "/" && draft.length === 0 && !commandModified && !event.altKey) { - setSlashPickerOpen(true); - setSlashQuery(""); - setSlashCursor(0); - // Don't preventDefault — the "/" will appear in the textarea and onChange will - // see val.startsWith("/"), keeping the picker open and enabling type-to-filter. - } /* Command menu keyboard navigation */ if (commandMenuTrigger) { if (event.key === "Escape") { event.preventDefault(); setCommandMenuTrigger(null); return; } @@ -1361,10 +1306,15 @@ export function AgentChatComposer({ onDraftChange(`${before}@${item.path} ${after}`); onAddAttachment({ path: item.path, type: inferAttachmentType(item.path) }); } else if (item.type === "command") { - onDraftChange(`/${item.name} `); + const selected = effectiveSlashCommands.find((cmd) => cmd.command.replace(/^\//, "") === item.name); + if (selected) { + handleSlashSelect(selected); + } else { + onDraftChange(`/${item.name} `); + } } setCommandMenuTrigger(null); - }, [attachBlockedReason, canAttach, commandMenuTrigger, draft, onDraftChange, onAddAttachment]); + }, [attachBlockedReason, canAttach, commandMenuTrigger, draft, effectiveSlashCommands, handleSlashSelect, onDraftChange, onAddAttachment]); const submitComposerDraft = useCallback(() => { const isQuestionPending = pendingInput && (pendingInput.kind === "question" || pendingInput.kind === "structured_question"); @@ -1526,35 +1476,6 @@ export function AgentChatComposer({ event.currentTarget.value = ""; }} /> - {slashPickerOpen && filteredSlashCommands.length > 0 ? ( -
-
- Commands -
-
- {filteredSlashCommands.map((cmd, index) => ( - - ))} -
-
- ) : null} - {attachmentPickerOpen ? (
@@ -1806,7 +1727,19 @@ export function AgentChatComposer({ + ) : null}
); } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 717bc001d..29b670c70 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -70,6 +70,7 @@ import { ChatTerminalDrawer, ChatTerminalToggle } from "./ChatTerminalDrawer"; import { deriveChatSubagentSnapshots, deriveTodoItems, deriveTurnDiffSummaries } from "./chatExecutionSummary"; import { derivePendingInputRequests, type DerivedPendingInput } from "./pendingInput"; import { ProviderModelSelector } from "../shared/ProviderModelSelector"; +import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { useClickOutside } from "../../hooks/useClickOutside"; import { DEFAULT_CHAT_FONT_SIZE_PX, useAppStore } from "../../state/appStore"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; @@ -928,6 +929,7 @@ export function AgentChatPane({ const modelSwitchPolicy = presentation?.modelSwitchPolicy ?? "same-family-after-launch"; const initialNativeControls = useMemo(() => defaultNativeControls(surfaceProfile), [surfaceProfile]); const [sessions, setSessions] = useState([]); + const [archivedSessions, setArchivedSessions] = useState([]); const [selectedSessionId, setSelectedSessionId] = useState(lockSessionId ?? initialSessionId ?? null); const [eventsBySession, setEventsBySession] = useState>({}); const [turnActiveBySession, setTurnActiveBySession] = useState>({}); @@ -1617,6 +1619,7 @@ export function AgentChatPane({ const refreshLockedSessionSummary = useCallback(async () => { if (!lockSessionId) { setSessions([]); + setArchivedSessions([]); return null; } @@ -1656,7 +1659,12 @@ export function AgentChatPane({ return; } - const rows = await window.ade.agentChat.list({ laneId }); + const allRows = await window.ade.agentChat.list({ laneId }); + const rows = allRows.filter((session) => !session.archivedAt); + setArchivedSessions(sortSessionSummariesByRecency( + allRows.filter((session) => Boolean(session.archivedAt)), + localTouchBySessionRef.current, + )); const nextRows = sortSessionSummariesByRecency(rows, localTouchBySessionRef.current); setSessions(nextRows); const retainedSessionIds = buildRetainedChatSessionIds({ @@ -2658,6 +2666,48 @@ export function AgentChatPane({ }); }, [refreshSessions, selectedSession, selectedSessionId]); + const handleArchiveChat = useCallback((sessionId: string) => { + setError(null); + void window.ade.agentChat.archive({ sessionId }) + .then(async () => { + invalidateSessionListCache(); + if (selectedSessionIdRef.current === sessionId) { + setSelectedSessionId(null); + } + await refreshSessions().catch(() => {}); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + setError(`Archive failed: ${message}`); + }); + }, [refreshSessions]); + + const archiveConfirm = useConfirmDialog(); + const requestArchiveChat = useCallback( + async (sessionId: string, title: string) => { + const ok = await archiveConfirm.confirmAsync({ + title: `Archive "${title}"?`, + message: "Archived chats are hidden from the active chat tabs.", + confirmLabel: "ARCHIVE", + }); + if (ok) handleArchiveChat(sessionId); + }, + [archiveConfirm, handleArchiveChat], + ); + + const handleUnarchiveChat = useCallback((sessionId: string) => { + setError(null); + void window.ade.agentChat.unarchive({ sessionId }) + .then(async () => { + invalidateSessionListCache(); + await refreshSessions().catch(() => {}); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + setError(`Restore failed: ${message}`); + }); + }, [refreshSessions]); + // ── Eager session creation ── // Create a session as soon as we have a model + lane, so slash commands // and other pre-chat metadata are available immediately. @@ -3390,6 +3440,10 @@ export function AgentChatPane({ + {archivedSessions.length ? ( + + ) : null}
) : null} @@ -3931,6 +4004,7 @@ export function AgentChatPane({ )} + ); } diff --git a/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx b/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx index 9b647c949..7692e3f5c 100644 --- a/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx @@ -29,7 +29,7 @@ type ChatCommandMenuProps = { /** The current trigger character and query. */ trigger: { type: "at" | "slash"; query: string; cursorIndex: number } | null; /** Available slash commands. */ - slashCommands: Array<{ name: string; description: string }>; + slashCommands: Array<{ name: string; description: string; argumentHint?: string; source?: "sdk" | "local" }>; /** Session ID for file search. */ sessionId: string | null; /** Anchor position: { top, left } relative to the container. */ @@ -98,7 +98,7 @@ export const ChatCommandMenu = forwardRef { - const map = new Map(); + const commandMap = useMemo(() => { + const map = new Map(); for (const cmd of slashCommands) { - map.set(cmd.name, cmd.description); + map.set(cmd.name, { + description: cmd.description, + argumentHint: cmd.argumentHint, + source: cmd.source, + }); } return map; }, [slashCommands]); @@ -191,8 +195,9 @@ export const ChatCommandMenu = forwardRef @@ -244,6 +249,9 @@ export const ChatCommandMenu = forwardRef )} + {!fileLoading && isAtEmptyResults && ( +
No files found
+ )} {/* Items */} {items.map((item, i) => { @@ -271,7 +279,8 @@ export const ChatCommandMenu = forwardRef /{item.name} + {command?.argumentHint ? ( + {command.argumentHint} + ) : null} {description && ( {description} )} diff --git a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx b/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx index 9b98c97a6..4e539bfe0 100644 --- a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx +++ b/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx @@ -75,7 +75,7 @@ const DECISION_TIMEOUT_CAP_OPTIONS: OrchestratorDecisionTimeoutCapHours[] = [6, const DEFAULT_ORCHESTRATOR_MODEL_BY_PROVIDER: Record<"claude" | "codex", MissionModelConfig["orchestratorModel"]> = { claude: { provider: "claude", modelId: getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }, - codex: { provider: "codex", modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex", thinkingLevel: "medium" }, + codex: { provider: "codex", modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex", thinkingLevel: "medium" }, }; const HIGH_TEAMMATE_COUNT_GUARDRAIL_THRESHOLD = 5; diff --git a/apps/desktop/src/renderer/components/missions/missionHelpers.ts b/apps/desktop/src/renderer/components/missions/missionHelpers.ts index 244a6c203..3f18ff3f0 100644 --- a/apps/desktop/src/renderer/components/missions/missionHelpers.ts +++ b/apps/desktop/src/renderer/components/missions/missionHelpers.ts @@ -249,7 +249,7 @@ export function plannerProviderToModelConfig(provider: PlannerProvider): ModelCo if (provider === "codex") { return { provider: "codex", - modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex", + modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex", thinkingLevel: "medium", }; } diff --git a/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts b/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts index 37632db39..d145c6093 100644 --- a/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts +++ b/apps/desktop/src/renderer/components/missions/missionPhaseDefaults.ts @@ -2,7 +2,7 @@ import type { ModelConfig, PhaseCard, PhaseProfile } from "../../../shared/types import { getDefaultModelDescriptor } from "../../../shared/modelRegistry"; const DEFAULT_CLAUDE_PHASE_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_PHASE_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex"; +const DEFAULT_CODEX_PHASE_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; const DEFAULT_MODELS: Record = { planning: { modelId: DEFAULT_CLAUDE_PHASE_MODEL_ID, thinkingLevel: "medium" }, diff --git a/apps/desktop/src/renderer/components/shared/MentionInput.tsx b/apps/desktop/src/renderer/components/shared/MentionInput.tsx index 62f37e3fd..dcd12e548 100644 --- a/apps/desktop/src/renderer/components/shared/MentionInput.tsx +++ b/apps/desktop/src/renderer/components/shared/MentionInput.tsx @@ -314,6 +314,10 @@ export function MentionInput({ disabled={disabled} placeholder={placeholder} autoFocus={autoFocus} + autoComplete="on" + autoCorrect="on" + autoCapitalize="sentences" + spellCheck={true} rows={1} className="w-full resize-none rounded-[var(--chat-radius-card)] border border-white/8 bg-black/12 px-4 py-3 text-[12px] outline-none transition-colors placeholder:text-white/24 disabled:opacity-50" style={{ diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 7b70ff998..898d62ae3 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -142,6 +142,8 @@ export const IPC = { agentChatModels: "ade.agentChat.models", agentChatDispose: "ade.agentChat.dispose", agentChatDelete: "ade.agentChat.delete", + agentChatArchive: "ade.agentChat.archive", + agentChatUnarchive: "ade.agentChat.unarchive", agentChatEvent: "ade.agentChat.event", agentChatUpdateSession: "ade.agentChat.updateSession", agentChatWarmupModel: "ade.agentChat.warmupModel", diff --git a/apps/desktop/src/shared/modelProfiles.ts b/apps/desktop/src/shared/modelProfiles.ts index ff6519698..f04126736 100644 --- a/apps/desktop/src/shared/modelProfiles.ts +++ b/apps/desktop/src/shared/modelProfiles.ts @@ -46,7 +46,7 @@ function descriptorToEntry(d: ModelDescriptor, overrides?: { recommended?: boole } const DEFAULT_CLAUDE_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; -const DEFAULT_CODEX_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex"; +const DEFAULT_CODEX_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5-codex"; // CLI-wrapped Anthropic models (claude provider) export const CLAUDE_MODELS: ModelEntry[] = MODEL_REGISTRY diff --git a/apps/desktop/src/shared/modelRegistry.test.ts b/apps/desktop/src/shared/modelRegistry.test.ts index 371c44265..0b8f2585a 100644 --- a/apps/desktop/src/shared/modelRegistry.test.ts +++ b/apps/desktop/src/shared/modelRegistry.test.ts @@ -50,7 +50,9 @@ describe("modelRegistry", () => { const byId = resolveModelSlug(" anthropic/claude-opus-4-7 "); expect(byId).toBe("anthropic/claude-opus-4-7"); expect(resolveModelSlug("gpt-5.4")).toBeUndefined(); + expect(resolveModelSlug("gpt-5.5")).toBeUndefined(); expect(resolveModelSlug("gpt-5.4", "codex")).toBe("openai/gpt-5.4-codex"); + expect(resolveModelSlug("gpt-5.5", "codex")).toBe("openai/gpt-5.5-codex"); expect(resolveModelSlug("")).toBeUndefined(); expect(resolveModelSlug(" ")).toBeUndefined(); expect(resolveModelSlug("not-a-real-model-xyz")).toBeUndefined(); @@ -77,6 +79,7 @@ describe("modelRegistry", () => { it("keeps only the allowed OpenAI chat models in the registry defaults", () => { expect(listModelDescriptorsForProvider("codex").map((model) => model.id)).toEqual([ + "openai/gpt-5.5-codex", "openai/gpt-5.4-codex", "openai/gpt-5.4-mini-codex", "openai/gpt-5.3-codex", @@ -89,7 +92,16 @@ describe("modelRegistry", () => { // API-key OpenAI models are now discovered dynamically through OpenCode, // so the static registry yields no hits for api-key auth alone. expect(getAvailableModels([{ type: "api-key", provider: "openai" }]).map((model) => model.id)).toEqual([]); - expect(getDefaultModelDescriptor("codex")?.id).toBe("openai/gpt-5.4-codex"); + expect(getDefaultModelDescriptor("codex")?.id).toBe("openai/gpt-5.5-codex"); + }); + + it("exposes GPT-5.5-Codex with the Codex app-server model id and expected reasoning tiers", () => { + expect(getModelById("openai/gpt-5.5-codex")).toMatchObject({ + displayName: "GPT-5.5", + providerRoute: "codex-cli", + providerModelId: "gpt-5.5", + reasoningTiers: ["low", "medium", "high", "xhigh"], + }); }); it("exposes GPT-5.4-Mini-Codex with the expected reasoning tiers", () => { @@ -100,7 +112,7 @@ describe("modelRegistry", () => { }); it("marks CLI-wrapped models as CLI subscription in the shared model source helper", () => { - expect(describeModelSource(getModelById("openai/gpt-5.4-codex")!)).toBe("CLI subscription"); + expect(describeModelSource(getModelById("openai/gpt-5.5-codex")!)).toBe("CLI subscription"); }); it("returns undefined for unknown model IDs", () => { @@ -126,11 +138,21 @@ describe("modelRegistry", () => { expect(resolved).toBeUndefined(); }); + it("returns undefined for bare gpt-5.5 alias since API-key variants are now OpenCode-dynamic", () => { + const resolved = resolveModelAlias("gpt-5.5"); + expect(resolved).toBeUndefined(); + }); + it("resolves gpt-5.4 to the Codex wrapper when the provider is codex", () => { const resolved = resolveModelDescriptorForProvider("gpt-5.4", "codex"); expect(resolved?.id).toBe("openai/gpt-5.4-codex"); }); + it("resolves gpt-5.5 to the Codex wrapper when the provider is codex", () => { + const resolved = resolveModelDescriptorForProvider("gpt-5.5", "codex"); + expect(resolved?.id).toBe("openai/gpt-5.5-codex"); + }); + it("resolves gpt-5.4-codex shortId to the codex variant", () => { const resolved = resolveModelAlias("gpt-5.4-codex"); expect(resolved).toBeTruthy(); @@ -143,6 +165,12 @@ describe("modelRegistry", () => { expect(getRuntimeModelRefForDescriptor(descriptor!, "codex")).toBe("gpt-5.4"); }); + it("returns the real Codex app-server runtime model name for wrapped GPT-5.5", () => { + const descriptor = getModelById("openai/gpt-5.5-codex"); + expect(descriptor).toBeTruthy(); + expect(getRuntimeModelRefForDescriptor(descriptor!, "codex")).toBe("gpt-5.5"); + }); + describe("Claude Opus 4.7 descriptors", () => { it("exposes the standard Opus 4.7 with the expected context window and pricing", () => { const opus = getModelById("anthropic/claude-opus-4-7"); diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 0c86f3e43..bc06dbe0d 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -192,6 +192,24 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ // ---- OpenAI (CLI-wrapped via codex) ---- // ADE codex chat surfaces expose a consistent ladder: // low | medium | high | xhigh, except GPT-5.1-Codex-Mini which only exposes medium | high. + { + id: "openai/gpt-5.5-codex", + shortId: "gpt-5.5-codex", + aliases: ["gpt-5.5-codex"], + displayName: "GPT-5.5", + family: "openai", + authTypes: ["cli-subscription"], + contextWindow: 400_000, + maxOutputTokens: 128_000, + capabilities: ALL_CAPS, + reasoningTiers: ["low", "medium", "high", "xhigh"], + color: "#10A37F", + providerRoute: "codex-cli", + providerModelId: "gpt-5.5", + cliCommand: "codex", + isCliWrapped: true, + costTier: "high", + }, { id: "openai/gpt-5.4-codex", shortId: "gpt-5.4-codex", diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 8a3c12d85..9756c872e 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -487,6 +487,7 @@ export type AgentChatSession = { completion?: AgentChatCompletionReport | null; status: AgentChatSessionStatus; idleSinceAt?: string | null; + archivedAt?: string | null; threadId?: string; /** Subdirectory or absolute path under the lane worktree used as cwd; persisted for relaunch/resume. */ requestedCwd?: string | null; @@ -525,6 +526,7 @@ export type AgentChatSessionSummary = { idleSinceAt?: string | null; startedAt: string; endedAt: string | null; + archivedAt?: string | null; lastActivityAt: string; lastOutputPreview: string | null; summary: string | null; @@ -728,6 +730,10 @@ export type AgentChatDeleteArgs = { sessionId: string; }; +export type AgentChatArchiveArgs = { + sessionId: string; +}; + export type AgentChatUpdateSessionArgs = { sessionId: string; title?: string | null; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 4c46a6733..d91433d15 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -69,6 +69,7 @@ export type TerminalSessionSummary = { status: TerminalSessionStatus; startedAt: string; endedAt: string | null; + archivedAt?: string | null; exitCode: number | null; transcriptPath: string; headShaStart: string | null; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index cc5db75f5..4ae982114 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -497,6 +497,9 @@ export type SyncRemoteCommandAction = | "chat.restart" | "chat.updateSession" | "chat.dispose" + | "chat.archive" + | "chat.unarchive" + | "chat.delete" | "chat.models" | "cto.getRoster" | "cto.ensureSession" diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 4ff1d3c71..fa70ae86c 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -525,6 +525,7 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { var idleSinceAt: String? var startedAt: String var endedAt: String? + var archivedAt: String? var lastActivityAt: String var lastOutputPreview: String? var summary: String? @@ -1042,6 +1043,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { var completion: ChatCompletionReport? var status: String var idleSinceAt: String? + var archivedAt: String? var threadId: String? var requestedCwd: String? var createdAt: String @@ -1077,6 +1079,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { case completion case status case idleSinceAt + case archivedAt case threadId case requestedCwd case createdAt @@ -1114,6 +1117,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { completion = try container.decodeIfPresent(ChatCompletionReport.self, forKey: .completion) status = try container.decode(String.self, forKey: .status) idleSinceAt = try container.decodeIfPresent(String.self, forKey: .idleSinceAt) + archivedAt = try container.decodeIfPresent(String.self, forKey: .archivedAt) threadId = try container.decodeIfPresent(String.self, forKey: .threadId) requestedCwd = try container.decodeIfPresent(String.self, forKey: .requestedCwd) createdAt = try container.decode(String.self, forKey: .createdAt) diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index 5e0aae842..29518f9dd 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -91,6 +91,7 @@ create table if not exists terminal_sessions ( summary text, resume_command text, resume_metadata_json text, + archived_at text, foreign key(lane_id) references lanes(id) ); @@ -108,6 +109,8 @@ alter table terminal_sessions add column resume_metadata_json text; alter table terminal_sessions add column manually_named integer not null default 0; +alter table terminal_sessions add column archived_at text; + create table if not exists process_definitions ( id text primary key, project_id text not null, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index b218a1a36..c09a0139d 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -2931,6 +2931,18 @@ final class SyncService: ObservableObject { _ = try await sendChatCommand(action: "chat.dispose", payload: AgentChatDisposeRequest(sessionId: sessionId)) } + func archiveChatSession(sessionId: String) async throws { + _ = try await sendChatCommand(action: "chat.archive", payload: AgentChatDisposeRequest(sessionId: sessionId)) + } + + func unarchiveChatSession(sessionId: String) async throws { + _ = try await sendChatCommand(action: "chat.unarchive", payload: AgentChatDisposeRequest(sessionId: sessionId)) + } + + func deleteChatSession(sessionId: String) async throws { + _ = try await sendChatCommand(action: "chat.delete", payload: AgentChatDisposeRequest(sessionId: sessionId)) + } + func readArtifact(artifactId: String? = nil, uri: String? = nil, path: String? = nil) async throws -> SyncFileBlob { var args: [String: Any] = [:] if let artifactId, !artifactId.isEmpty { diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 9ae8deaf1..9a0b32955 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -489,6 +489,8 @@ struct WorkQueuedSteerRow: View { if isEditing { TextField("Queued message", text: $draft, axis: .vertical) .lineLimit(1...5) + .autocorrectionDisabled(false) + .textInputAutocapitalization(.sentences) .adeInsetField(cornerRadius: 12, padding: 10) .disabled(busy || !isLive) @@ -754,6 +756,8 @@ struct WorkStructuredQuestionCard: View { } else { TextField(q.options.isEmpty ? "Response" : "Optional response", text: binding, axis: .vertical) .lineLimit(1...4) + .autocorrectionDisabled(false) + .textInputAutocapitalization(.sentences) .adeInsetField(cornerRadius: 14, padding: 12) .disabled(busy) } diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index a5db67520..ba02b4cb2 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -125,11 +125,6 @@ struct WorkToolCardView: View { .lineLimit(1) .truncationMode(.tail) } - if toolCard.status == .running { - Text("Running") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.warning) - } Spacer(minLength: 4) Image(systemName: "chevron.down") .font(.system(size: 10, weight: .semibold)) @@ -149,7 +144,11 @@ struct WorkToolCardView: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) HStack(spacing: 8) { - WorkTag(text: toolCard.status.rawValue.capitalized, icon: statusIcon, tint: statusTint) + // Running state is surfaced globally by WorkActivityIndicator at the + // bottom of the chat; repeating it on every card just stacks clutter. + if toolCard.status != .running { + WorkTag(text: toolCard.status.rawValue.capitalized, icon: statusIcon, tint: statusTint) + } Text(formattedSessionDuration(startedAt: toolCard.startedAt, endedAt: toolCard.completedAt)) .font(.caption2.monospacedDigit()) .foregroundStyle(ADEColor.textMuted) @@ -308,23 +307,23 @@ struct WorkToolGroupCardView: View { } private var latestTint: Color { - color(for: latest?.status ?? .completed) + // If any member is still running, surface that on the group icon — + // otherwise a just-completed latest member hides the fact that earlier + // calls in the cluster are still in flight. + if group.hasRunning { return color(for: .running) } + return color(for: latest?.status ?? .completed) } private var headerSubtitle: String { - var tools = 0, commands = 0, files = 0, running = 0 + var tools = 0, commands = 0, files = 0 for m in group.members { switch m { case .tool: tools += 1 case .command: commands += 1 case .fileChange: files += 1 } - if m.status == .running { - running += 1 - } } var parts: [String] = [] - if running > 0 { parts.append("\(running) running") } if tools > 0 { parts.append("\(tools) tool\(tools == 1 ? "" : "s")") } if commands > 0 { parts.append("\(commands) cmd\(commands == 1 ? "" : "s")") } if files > 0 { parts.append("\(files) file\(files == 1 ? "" : "s")") } @@ -539,7 +538,9 @@ struct WorkCommandCardView: View { .buttonStyle(.plain) HStack(spacing: 8) { - WorkTag(text: card.status.rawValue.capitalized, icon: statusIcon, tint: statusTint) + if card.status != .running { + WorkTag(text: card.status.rawValue.capitalized, icon: statusIcon, tint: statusTint) + } if !card.cwd.isEmpty { WorkTag(text: card.cwd, icon: "folder", tint: ADEColor.textSecondary) } @@ -630,7 +631,9 @@ struct WorkFileChangeCardView: View { HStack(spacing: 8) { WorkTag(text: card.kind.replacingOccurrences(of: "_", with: " ").capitalized, icon: fileChangeIcon, tint: statusTint) - WorkTag(text: card.status.rawValue.capitalized, icon: statusIcon, tint: statusTint) + if card.status != .running { + WorkTag(text: card.status.rawValue.capitalized, icon: statusIcon, tint: statusTint) + } } if !card.diff.isEmpty { diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 993edc2e8..8276b3dc1 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -666,8 +666,8 @@ private struct WorkChatComposerDraftInput: View { .foregroundStyle(ADEColor.textPrimary) .tint(ADEColor.accent) .disabled(!canCompose) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) + .autocorrectionDisabled(false) + .textInputAutocapitalization(.sentences) .focused($composerFocused) .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) .onChange(of: pendingInsert) { _, token in diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index b7617e722..814f0500a 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -45,62 +45,190 @@ func workTerminalDisplay(raw: String?, fallback: String?) -> WorkTerminalDisplay } func sanitizeTerminalOutputForDisplay(_ input: String) -> String { - var output = String.UnicodeScalarView() - var index = input.unicodeScalars.startIndex + let screen = WorkTerminalTextReplay() + screen.write(input) + return collapseDuplicatedWorkStreamTextIfNeeded(screen.text) +} + +private final class WorkTerminalTextReplay { + private var lines: [[UnicodeScalar]] = [[]] + private var row = 0 + private var column = 0 + + var text: String { + lines + .map { String(String.UnicodeScalarView($0)).trimmingTrailingTerminalPadding() } + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + func write(_ input: String) { + let scalars = Array(input.unicodeScalars) + var index = scalars.startIndex + + while index < scalars.endIndex { + let scalar = scalars[index] + index = scalars.index(after: index) - func skipUntilCSICommand() { - while index < input.unicodeScalars.endIndex { - let scalar = input.unicodeScalars[index] - index = input.unicodeScalars.index(after: index) + if scalar == "\u{001B}" { + consumeEscape(in: scalars, index: &index) + continue + } + + switch scalar { + case "\n": + newline() + case "\r": + column = 0 + case "\t": + let spaces = max(1, 4 - (column % 4)) + for _ in 0..= 0x20 && scalar.value != 0x7F { + put(scalar) + } + } + } + } + + private func put(_ scalar: UnicodeScalar) { + ensureCursor() + while lines[row].count < column { + lines[row].append(" ") + } + if column < lines[row].count { + lines[row][column] = scalar + } else { + lines[row].append(scalar) + } + column += 1 + } + + private func newline() { + row += 1 + column = 0 + ensureCursor() + } + + private func ensureCursor() { + while lines.count <= row { + lines.append([]) + } + } + + private func consumeEscape(in scalars: [UnicodeScalar], index: inout Int) { + guard index < scalars.endIndex else { return } + let kind = scalars[index] + index = scalars.index(after: index) + + switch kind { + case "[": + consumeCSI(in: scalars, index: &index) + case "]": + consumeOSC(in: scalars, index: &index) + case "c": + lines = [[]] + row = 0 + column = 0 + case "(", ")", "*", "+": + if index < scalars.endIndex { + index = scalars.index(after: index) + } + default: + break + } + } + + private func consumeOSC(in scalars: [UnicodeScalar], index: inout Int) { + while index < scalars.endIndex { + let current = scalars[index] + index = scalars.index(after: index) + if current == "\u{0007}" { + break + } + if current == "\u{001B}", index < scalars.endIndex, scalars[index] == "\\" { + index = scalars.index(after: index) + break + } + } + } + + private func consumeCSI(in scalars: [UnicodeScalar], index: inout Int) { + var body = String.UnicodeScalarView() + while index < scalars.endIndex { + let scalar = scalars[index] + index = scalars.index(after: index) if scalar.value >= 0x40 && scalar.value <= 0x7E { + applyCSI(command: Character(scalar), body: String(body)) break } + body.append(scalar) } } - while index < input.unicodeScalars.endIndex { - let scalar = input.unicodeScalars[index] - index = input.unicodeScalars.index(after: index) - - if scalar == "\u{001B}" { - guard index < input.unicodeScalars.endIndex else { break } - let next = input.unicodeScalars[index] - index = input.unicodeScalars.index(after: index) - if next == "[" { - skipUntilCSICommand() - } else if next == "]" { - while index < input.unicodeScalars.endIndex { - let current = input.unicodeScalars[index] - index = input.unicodeScalars.index(after: index) - if current == "\u{0007}" { - break - } - if current == "\u{001B}", index < input.unicodeScalars.endIndex, input.unicodeScalars[index] == "\\" { - index = input.unicodeScalars.index(after: index) - break - } + private func applyCSI(command: Character, body: String) { + let params = body + .split(separator: ";", omittingEmptySubsequences: false) + .map { Int(String($0).trimmingCharacters(in: CharacterSet(charactersIn: "?"))) ?? 0 } + let first = params.first ?? 0 + + switch command { + case "A": + row = max(0, row - max(1, first)) + case "B": + row += max(1, first) + ensureCursor() + case "C": + column += max(1, first) + case "D": + column = max(0, column - max(1, first)) + case "G": + column = max(0, max(1, first) - 1) + case "H", "f": + row = max(0, max(1, first) - 1) + column = max(0, max(1, params.dropFirst().first ?? 1) - 1) + ensureCursor() + case "J": + if first == 2 || first == 3 { + lines = [[]] + row = 0 + column = 0 + } + case "K": + ensureCursor() + if first == 1 { + let space: UnicodeScalar = " " + let endIndex = min(column + 1, lines[row].count) + for index in 0..= lines[row].count { + while lines[row].count <= column { + lines[row].append(space) + } } + } else if first == 2 { + lines[row].removeAll() + column = 0 + } else if column < lines[row].count { + lines[row].removeSubrange(column..= 0x20 && scalar.value != 0x7F { - output.append(scalar) - } + break } } +} - return collapseDuplicatedWorkStreamTextIfNeeded(String(output)) +private extension String { + func trimmingTrailingTerminalPadding() -> String { + var result = self + while let last = result.unicodeScalars.last, CharacterSet.whitespaces.contains(last) { + result.removeLast() + } + return result + } } func collapseDuplicatedWorkStreamTextIfNeeded(_ input: String) -> String { diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index e95e29a06..c111800bc 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -319,6 +319,8 @@ private struct WorkNewChatComposerBar: View { .font(.body) .foregroundStyle(ADEColor.textPrimary) .tint(ADEColor.accent) + .autocorrectionDisabled(false) + .textInputAutocapitalization(.sentences) .focused($composerFocused) .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) .onChange(of: pendingInsert) { _, newValue in diff --git a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift index 08d806a05..50960cb36 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift @@ -307,7 +307,7 @@ struct WorkNewChatSheet: View { VStack(alignment: .leading, spacing: 8) { TextField("Tell the agent what to do", text: $initialMessage, axis: .vertical) .textInputAutocapitalization(.sentences) - .autocorrectionDisabled() + .autocorrectionDisabled(false) .adeInsetField(cornerRadius: 14, padding: 12) .disabled(busy) diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 004bc4cde..b837dc9e7 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -313,6 +313,7 @@ private struct WorkPreviewSessionListScreen: View { onPin: { _ in }, onRename: { _ in }, onEnd: { _ in }, + onDelete: { _ in }, onResume: { _ in }, onCopyId: { _ in }, onGoToLane: { _ in } diff --git a/apps/ios/ADE/Views/Work/WorkReasoningCard.swift b/apps/ios/ADE/Views/Work/WorkReasoningCard.swift index 67fde9afd..498a4ce17 100644 --- a/apps/ios/ADE/Views/Work/WorkReasoningCard.swift +++ b/apps/ios/ADE/Views/Work/WorkReasoningCard.swift @@ -5,16 +5,16 @@ import SwiftUI /// Collapsed state mirrors desktop's compact "Thought" pill: a single-line /// capsule with chevron · brain icon · "Thought" that hugs the assistant /// column rather than spanning full width. While the turn is live the header -/// pulses ("Thinking …") and the body auto-expands so tokens stream in real -/// time; once the turn settles the card auto-collapses again. +/// pulses ("Thinking …") but the body stays collapsed by default; the user +/// must tap the pill to reveal streaming reasoning tokens. struct WorkReasoningCard: View { let card: WorkEventCardModel let isLive: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion // Default collapsed — reasoning is the model's scratchpad, not the answer. - // The live turn still auto-expands via the onChange(of: isLive) handler - // below so users can watch thoughts stream in real time. + // We no longer auto-expand while live: thoughts should not fill the view + // unless the user explicitly opts in by tapping the pill. @State private var isExpanded: Bool = false private var bodyText: String? { @@ -53,16 +53,6 @@ struct WorkReasoningCard: View { } Spacer(minLength: 0) } - .onAppear { - // Auto-expand while the turn is still thinking so the user can see - // tokens land as they arrive. - if isLive { isExpanded = true } - } - .onChange(of: isLive) { _, nowLive in - withAnimation(ADEMotion.standard(reduceMotion: reduceMotion)) { - isExpanded = nowLive - } - } } private var compactPill: some View { diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index fe8a481d1..6879f4b70 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -407,6 +407,7 @@ struct WorkSessionListRow: View { let onPin: (TerminalSessionSummary) -> Void let onRename: (TerminalSessionSummary) -> Void let onEnd: (TerminalSessionSummary) -> Void + let onDelete: (TerminalSessionSummary) -> Void let onResume: (TerminalSessionSummary) -> Void let onCopyId: (TerminalSessionSummary) -> Void let onGoToLane: (TerminalSessionSummary) -> Void @@ -450,6 +451,10 @@ struct WorkSessionListRow: View { Button("Close session", role: .destructive) { onEnd(session) } + } else if shouldShowDeleteAction { + Button("Delete chat", role: .destructive) { + onDelete(session) + } } else if shouldShowResumeAction { Button("Resume") { onResume(session) @@ -469,10 +474,16 @@ struct WorkSessionListRow: View { } private var shouldShowEndAction: Bool { - guard !isChatSession(session) else { return false } + if isChatSession(session) { + return status == "active" || status == "awaiting-input" || status == "idle" + } return status == "active" || status == "awaiting-input" } + private var shouldShowDeleteAction: Bool { + isChatSession(session) && status == "ended" + } + private var shouldShowResumeAction: Bool { guard !isChatSession(session) else { return false } return status == "idle" || status == "ended" diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index fa253f6c5..4bd3f980f 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -279,13 +279,32 @@ extension WorkRootScreen { } func toggleArchive(_ session: TerminalSessionSummary) { - var archived = archivedSessionIds - if archived.contains(session.id) { - archived.remove(session.id) - } else { - archived.insert(session.id) + Task { + do { + if isChatSession(session) { + if archivedSessionIds.contains(session.id) { + try await syncService.unarchiveChatSession(sessionId: session.id) + } else { + try await syncService.archiveChatSession(sessionId: session.id) + } + let localIds = Set(archivedSessionIdsStorage.split(separator: "\n").map(String.init)) + let prunedLocal = localIds.subtracting([session.id]) + archivedSessionIdsStorage = prunedLocal.sorted().joined(separator: "\n") + await reload(refreshRemote: true) + return + } + var archived = archivedSessionIds + if archived.contains(session.id) { + archived.remove(session.id) + } else { + archived.insert(session.id) + } + archivedSessionIdsStorage = archived.sorted().joined(separator: "\n") + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + } } - archivedSessionIdsStorage = archived.sorted().joined(separator: "\n") } func togglePin(_ session: TerminalSessionSummary) { @@ -368,6 +387,21 @@ extension WorkRootScreen { openSession(session) } + func deleteChatSession(_ session: TerminalSessionSummary) { + Task { + do { + try await syncService.deleteChatSession(sessionId: session.id) + let localIds = Set(archivedSessionIdsStorage.split(separator: "\n").map(String.init)) + let prunedLocal = localIds.subtracting([session.id]) + archivedSessionIdsStorage = prunedLocal.sorted().joined(separator: "\n") + await reload(refreshRemote: true) + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + } + } + } + @MainActor func endSession(_ session: TerminalSessionSummary) async { defer { endTarget = nil } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 10eafbbf8..7072522e1 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -65,7 +65,18 @@ struct WorkRootScreen: View { } var archivedSessionIds: Set { - Set(archivedSessionIdsStorage.split(separator: "\n").map(String.init)) + let local = Set(archivedSessionIdsStorage.split(separator: "\n").map(String.init)) + var result = Set() + for summary in chatSummaries.values { + if summary.archivedAt != nil { + result.insert(summary.sessionId) + } + } + let remoteKnownIds = Set(chatSummaries.values.map { $0.sessionId }) + for id in local where !remoteKnownIds.contains(id) { + result.insert(id) + } + return result } var laneById: [String: LaneSummary] { @@ -371,6 +382,7 @@ struct WorkRootScreen: View { onPin: togglePin, onRename: beginRename, onEnd: { session in endTarget = session }, + onDelete: deleteChatSession, onResume: resumeSession, onCopyId: copySessionId, onGoToLane: goToLane diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 3cc306580..7a9c334fe 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -3,6 +3,36 @@ import SQLite3 @testable import ADE final class ADETests: XCTestCase { + func testTerminalDisplayReplaysCarriageReturnProgressUpdates() { + let output = sanitizeTerminalOutputForDisplay("Downloading 10%\rDownloading 80%\rDownloading 100%\nDone") + + XCTAssertEqual(output, "Downloading 100%\nDone") + } + + func testTerminalDisplayHandlesEraseLineSpinners() { + let output = sanitizeTerminalOutputForDisplay("Working -\r\u{001B}[KWorking \\\r\u{001B}[KComplete\n") + + XCTAssertEqual(output, "Complete") + } + + func testTerminalDisplayAppliesCursorAddressingAndClearScreen() { + let output = sanitizeTerminalOutputForDisplay("old screen\u{001B}[2J\u{001B}[Htop\nbottom\u{001B}[1A\u{001B}[Gmiddle") + + XCTAssertEqual(output, "middle\nbottom") + } + + func testTerminalDisplayStripsAnsiColorAndBackspaces() { + let output = sanitizeTerminalOutputForDisplay("\u{001B}[31merr\u{001B}[0mor\u{0008}k") + + XCTAssertEqual(output, "errok") + } + + func testTerminalDisplayPreservesIndentedOutput() { + let output = sanitizeTerminalOutputForDisplay("if true {\n print(\"ok\")\n}\n") + + XCTAssertEqual(output, "if true {\n print(\"ok\")\n}") + } + func testUnwrapSyncCommandResponseReturnsResultPayload() throws { let raw: [String: Any] = [ "commandId": "cmd-1", diff --git a/apps/ios/ExportOptions.auto.plist b/apps/ios/ExportOptions.auto.plist new file mode 100644 index 000000000..c911e0a6c --- /dev/null +++ b/apps/ios/ExportOptions.auto.plist @@ -0,0 +1,18 @@ + + + + + method + app-store-connect + teamID + VQ372F39G6 + signingStyle + automatic + destination + export + stripSwiftSymbols + + uploadSymbols + + +