diff --git a/.env.example b/.env.example index 61cdd66d246a..fc67dcef9478 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,14 @@ # Optional: T3 Connect source builds -# Leave these unset to disable optional T3 Connect features in local source builds. -# Release builds inject their public values at build time. Do not add server-side -# secrets to this file. +# `cp .env.example .env` enables T3 Connect against the production deployment. +# These are the same public identifiers baked into official release builds, not +# secrets. Remove or comment them out to build with cloud features disabled. +# Do not add server-side secrets to this file. -# Get these from the Clerk Dashboard under API keys, JWT templates, and OAuth applications. -# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... -# T3CODE_CLERK_JWT_TEMPLATE=t3-relay -# T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_... +# Production Clerk instance. To use your own, get these from the Clerk Dashboard +# under API keys, JWT templates, and OAuth applications. +T3CODE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsudDMuY29kZXMk +T3CODE_CLERK_JWT_TEMPLATE=t3-relay +T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r # Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API # hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk @@ -15,8 +17,9 @@ # T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile # T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com -# Get this from your relay deployment. `infra/relay` deploys update it automatically. -# T3CODE_RELAY_URL=https://relay.example.com +# Production relay. For a self-hosted relay, `infra/relay` deploys update it +# automatically. +T3CODE_RELAY_URL=https://relay.t3.codes # Optional: hosted app origin used by the CLI's out-of-band OAuth flow. # Defaults to https://app.t3.codes; override to test against a staging deployment. diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs new file mode 100644 index 000000000000..94a02b7806dc --- /dev/null +++ b/.github/scripts/thread-transfer-report.cjs @@ -0,0 +1,429 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const ARTIFACT_NAME = "thread-transfer-results"; +const RESULT_FILE = "thread-transfer-result.json"; +const COMMENT_MARKER = ""; +const PROVIDERS = ["codex", "claudeAgent"]; +const OBSERVED_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "threadSnapshotDecodedBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const CEILING_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const SCENARIO_KEYS = [ + "id", + "historyTurns", + "historyCommandToolsPerTurn", + "historyMcpResultBytes", + "measuredCommandTools", + "measuredMcpResultBytes", +]; + +function resultShaMarker(sha) { + return ``; +} + +function assertObject(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function assertExactKeys(value, expected, label) { + assertObject(value, label); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`${label} has unexpected fields`); + } +} + +function assertMetric(value, label) { + if (!Number.isSafeInteger(value) || value < 0 || value > 1_000_000_000) { + throw new Error(`${label} must be a non-negative safe integer below 1,000,000,000`); + } +} + +function validateResult(value) { + assertExactKeys(value, ["schemaVersion", "scenario", "providers"], "result"); + if (value.schemaVersion !== 1) { + throw new Error("result.schemaVersion must be 1"); + } + + assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario"); + if (value.scenario.id !== "thread-transfer-v1") { + throw new Error("result.scenario.id is not supported"); + } + for (const key of SCENARIO_KEYS.slice(1)) { + assertMetric(value.scenario[key], `result.scenario.${key}`); + } + + assertExactKeys(value.providers, PROVIDERS, "result.providers"); + for (const provider of PROVIDERS) { + const entry = value.providers[provider]; + assertExactKeys(entry, ["observed", "ceiling"], `result.providers.${provider}`); + assertExactKeys(entry.observed, OBSERVED_KEYS, `result.providers.${provider}.observed`); + assertExactKeys(entry.ceiling, CEILING_KEYS, `result.providers.${provider}.ceiling`); + for (const key of OBSERVED_KEYS) { + assertMetric(entry.observed[key], `result.providers.${provider}.observed.${key}`); + } + for (const key of CEILING_KEYS) { + assertMetric(entry.ceiling[key], `result.providers.${provider}.ceiling.${key}`); + } + } + + return value; +} + +function readResult(directory) { + if (!directory) return undefined; + const file = path.join(directory, RESULT_FILE); + if (!fs.existsSync(file)) return undefined; + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.size > 64 * 1_024) { + throw new Error("thread transfer result must be a regular file smaller than 64 KiB"); + } + return validateResult(JSON.parse(fs.readFileSync(file, "utf8"))); +} + +function formatBytes(bytes) { + if (bytes < 1_024) return `${bytes} B`; + if (bytes >= 1_024 * 1_024) return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB`; + return `${(bytes / 1_024).toFixed(1)} KiB`; +} + +function formatValue(value, kind) { + return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value); +} + +function formatImpact(current, baseline, kind) { + if (baseline === undefined) return "—"; + const delta = current - baseline; + const prefix = delta > 0 ? "+" : delta < 0 ? "−" : ""; + const magnitude = formatValue(Math.abs(delta), kind); + const percent = + baseline === 0 ? "" : ` (${prefix}${Math.abs((delta / baseline) * 100).toFixed(1)}%)`; + return `${prefix}${magnitude}${percent}`; +} + +function sameScenario(left, right) { + return SCENARIO_KEYS.every((key) => left[key] === right[key]); +} + +const METRICS = [ + { key: "totalWireBytes", label: "Total thread wire", kind: "bytes" }, + { key: "threadSnapshotWireBytes", label: "Thread snapshot wire", kind: "bytes" }, + { + key: "measuredTurnWebSocketWireBytes", + label: "Live turn WebSocket wire", + kind: "bytes", + }, + { + key: "measuredTurnWebSocketDecodedBytes", + label: "Live turn WebSocket decoded", + kind: "bytes", + }, + { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" }, +]; + +function renderComment(input) { + const current = input.current; + const baseline = input.baseline; + const comparable = baseline !== undefined && sameScenario(current.scenario, baseline.scenario); + const rows = []; + const ceilingChanges = []; + let failed = false; + + for (const provider of PROVIDERS) { + for (const metric of METRICS) { + const observed = current.providers[provider].observed[metric.key]; + const ceiling = current.providers[provider].ceiling[metric.key]; + const baselineObserved = comparable + ? baseline.providers[provider].observed[metric.key] + : undefined; + const pass = observed <= ceiling; + failed ||= !pass; + rows.push( + `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, + ); + + if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) { + ceilingChanges.push( + `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, + ); + } + } + } + + const baselineLink = input.baselineRun + ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})` + : "unavailable"; + const currentLink = `[\`${input.currentRun.sha.slice(0, 7)}\`](${input.currentRun.url})`; + const notices = []; + if (!baseline) { + notices.push( + "> ℹ️ No successful `main` baseline artifact is available yet. This run establishes the initial measurement.", + ); + } else if (!comparable) { + notices.push( + "> ⚠️ The thread fixture changed, so impact percentages are not directly comparable to the `main` baseline.", + ); + } else if (!input.baselineRun.matchesBase) { + notices.push( + "> ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful `main` measurement shown below.", + ); + } + if (ceilingChanges.length > 0) { + notices.push( + `> ⚠️ **This PR changes transfer ceilings:**\n>\n${ceilingChanges.map((line) => `> ${line}`).join("\n")}`, + ); + } + + return [ + COMMENT_MARKER, + resultShaMarker(input.currentRun.sha), + "## Thread transfer impact", + "", + failed + ? "❌ One or more thread transfer ceilings were exceeded." + : "✅ Thread transfer remains within every enforced ceiling.", + ...(notices.length > 0 ? ["", ...notices] : []), + "", + "| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |", + "| --- | --- | ---: | ---: | ---: | ---: | --- |", + ...rows, + "", + `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`, + "", + "
", + "Scenario and decoded snapshot size", + "", + `${current.scenario.historyTurns} historical turns, ${current.scenario.historyCommandToolsPerTurn} command tools per turn, ${formatBytes(current.scenario.historyMcpResultBytes)} retained MCP result per historical turn, and a ${formatBytes(current.scenario.measuredMcpResultBytes)} retained result in the measured turn.`, + "", + ...PROVIDERS.map( + (provider) => + `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, + ), + "", + "
", + "", + "_Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed._", + ].join("\n"); +} + +async function artifactsForRun(github, owner, repo, runId) { + return github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: runId, + per_page: 100, + }); +} + +function findResultArtifact(artifacts) { + return artifacts.find((artifact) => artifact.name === ARTIFACT_NAME && !artifact.expired); +} + +async function resolve({ github, context, core }) { + const source = context.payload.workflow_run; + const { owner, repo } = context.repo; + if (source.event !== "pull_request") { + core.setOutput("publish", "false"); + return; + } + + let pullNumber = source.pull_requests?.[0]?.number; + if (!pullNumber) { + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: source.head_sha, per_page: 100 }, + ); + const matchingPulls = associated.filter( + (pull) => + pull.state === "open" && + pull.head.sha === source.head_sha && + pull.head.ref === source.head_branch, + ); + if (matchingPulls.length !== 1) { + core.info( + `Expected one open pull request for ${source.head_repository?.full_name ?? "unknown repository"}:${source.head_branch ?? "unknown branch"} at ${source.head_sha}; found ${matchingPulls.length}.`, + ); + core.setOutput("publish", "false"); + return; + } + pullNumber = matchingPulls[0].number; + } + if (!pullNumber) { + core.info("No open pull request is associated with the completed CI run."); + core.setOutput("publish", "false"); + return; + } + + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber }); + if (pull.head.sha !== source.head_sha) { + core.info(`Skipping stale CI result ${source.head_sha}; PR head is ${pull.head.sha}.`); + core.setOutput("publish", "false"); + return; + } + + const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); + const sourceArtifact = findResultArtifact(sourceArtifacts); + const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, + repo, + workflow_id: source.workflow_id, + branch: pull.base.ref, + event: "push", + status: "success", + per_page: 100, + }); + const orderedRuns = [ + ...workflowRuns.filter((run) => run.head_sha === pull.base.sha), + ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha), + ].slice(0, 20); + + let baselineRun; + for (const run of orderedRuns) { + const artifacts = await artifactsForRun(github, owner, repo, run.id); + if (findResultArtifact(artifacts)) { + baselineRun = run; + break; + } + } + + core.setOutput("publish", "true"); + core.setOutput("pull_number", String(pullNumber)); + core.setOutput("pr_artifact", sourceArtifact ? "true" : "false"); + core.setOutput("pr_run_id", String(source.id)); + core.setOutput("pr_sha", source.head_sha); + core.setOutput("pr_conclusion", source.conclusion ?? "unknown"); + core.setOutput("baseline_artifact", baselineRun ? "true" : "false"); + core.setOutput("baseline_run_id", baselineRun ? String(baselineRun.id) : ""); + core.setOutput("baseline_sha", baselineRun?.head_sha ?? ""); + core.setOutput( + "baseline_matches_base", + baselineRun?.head_sha === pull.base.sha ? "true" : "false", + ); +} + +async function upsertComment(github, context, pullNumber, body, options = {}) { + const { owner, repo } = context.repo; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pullNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER), + ); + if ( + options.preserveResultSha && + existing?.body?.includes(resultShaMarker(options.preserveResultSha)) + ) { + return; + } + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body }); + } +} + +async function upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + expectedSha, + body, + options, +) { + const { owner, repo } = context.repo; + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (pull.head.sha !== expectedSha) { + core.info(`Skipping stale CI result ${expectedSha}; PR head is ${pull.head.sha}.`); + return false; + } + + await upsertComment(github, context, pullNumber, body, options); + return true; +} + +async function publish({ github, context, core }) { + const pullNumber = Number(process.env.PR_NUMBER); + if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) { + throw new Error("PR_NUMBER is invalid"); + } + + const current = readResult(process.env.PR_RESULT_DIR); + const currentRun = { + sha: process.env.PR_SHA, + conclusion: process.env.PR_CONCLUSION, + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.PR_RUN_ID}`, + }; + if (!current) { + await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + [ + COMMENT_MARKER, + "## Thread transfer impact", + "", + `⚠️ The latest [CI run](${currentRun.url}) did not produce a thread transfer result for \`${currentRun.sha.slice(0, 7)}\`.`, + "", + "_This comment will update automatically after the next completed run._", + ].join("\n"), + { preserveResultSha: currentRun.sha }, + ); + return; + } + + const baseline = readResult(process.env.BASELINE_RESULT_DIR); + const baselineRun = baseline + ? { + sha: process.env.BASELINE_SHA, + matchesBase: process.env.BASELINE_MATCHES_BASE === "true", + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`, + } + : undefined; + const body = renderComment({ current, baseline, currentRun, baselineRun }); + const published = await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + body, + ); + if (published) { + core.info(`Updated thread transfer report on PR #${pullNumber}.`); + } +} + +module.exports = { + publish, + readResult, + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +}; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs new file mode 100644 index 000000000000..4935864e46f0 --- /dev/null +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -0,0 +1,292 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +} = require("./thread-transfer-report.cjs"); + +function result(overrides = {}) { + const observed = { + totalWireBytes: 2_200_000, + threadSnapshotWireBytes: 1_950_000, + threadSnapshotDecodedBytes: 9_100_000, + measuredTurnWebSocketWireBytes: 250_000, + measuredTurnWebSocketDecodedBytes: 1_150_000, + measuredTurnWebSocketMessages: 15, + }; + const ceiling = { + totalWireBytes: 2_900_000, + threadSnapshotWireBytes: 2_600_000, + measuredTurnWebSocketWireBytes: 320_000, + measuredTurnWebSocketDecodedBytes: 1_550_000, + measuredTurnWebSocketMessages: 20, + }; + return { + schemaVersion: 1, + scenario: { + id: "thread-transfer-v1", + historyTurns: 10, + historyCommandToolsPerTurn: 5, + historyMcpResultBytes: 900_000, + measuredCommandTools: 20, + measuredMcpResultBytes: 1_100_000, + }, + providers: { + codex: { observed: { ...observed, ...overrides }, ceiling }, + claudeAgent: { observed, ceiling }, + }, + }; +} + +test("validates the fixed artifact schema", () => { + assert.equal(validateResult(result()).schemaVersion, 1); + assert.throws( + () => validateResult({ ...result(), injectedMarkdown: "@everyone" }), + /unexpected fields/, + ); + assert.throws( + () => validateResult(result({ totalWireBytes: "lots" })), + /non-negative safe integer/, + ); +}); + +test("renders baseline, impact, ceiling, and ceiling changes", () => { + const baseline = result(); + const current = result({ measuredTurnWebSocketWireBytes: 260_000 }); + current.providers.codex.ceiling = { + ...current.providers.codex.ceiling, + measuredTurnWebSocketWireBytes: 330_000, + }; + const comment = renderComment({ + current, + baseline, + currentRun: { + sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + conclusion: "success", + url: "https://github.com/pingdotgg/t3code/actions/runs/2", + }, + baselineRun: { + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + matchesBase: true, + url: "https://github.com/pingdotgg/t3code/actions/runs/1", + }, + }); + + assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/); + assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/); + assert.match(comment, /This PR changes transfer ceilings/); + assert.match(comment, /312\.5 KiB → 322\.3 KiB/); + assert.match(comment, //); + assert.match( + comment, + //, + ); +}); + +test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { + const outputs = {}; + const listWorkflowRunArtifacts = () => {}; + const listWorkflowRuns = () => {}; + const listPullRequestsAssociatedWithCommit = () => {}; + const github = { + paginate: async (method, input) => { + if (method === listPullRequestsAssociatedWithCommit) { + return [ + { + number: 5350, + state: "open", + head: { sha: "head-sha", ref: "feature-branch", repo: null }, + }, + ]; + } + if (method === listWorkflowRunArtifacts) { + return [ + { + name: "thread-transfer-results", + expired: false, + runId: input.run_id, + }, + ]; + } + if (method === listWorkflowRuns) { + return [{ id: 1, head_sha: "base-sha" }]; + } + throw new Error("unexpected pagination call"); + }, + rest: { + actions: { listWorkflowRunArtifacts, listWorkflowRuns }, + pulls: { + get: async () => ({ + data: { + head: { sha: "head-sha" }, + base: { sha: "base-sha", ref: "main" }, + }, + }), + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }; + await resolve({ + github, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "true"); + assert.equal(outputs.pull_number, "5350"); + assert.equal(outputs.pr_artifact, "true"); + assert.equal(outputs.baseline_run_id, "1"); + assert.equal(outputs.baseline_matches_base, "true"); +}); + +test("does not guess when a fallback commit belongs to multiple PRs", async () => { + const outputs = {}; + const listPullRequestsAssociatedWithCommit = () => {}; + let fetchedPull = false; + await resolve({ + github: { + paginate: async (method) => { + assert.equal(method, listPullRequestsAssociatedWithCommit); + return [5350, 5351].map((number) => ({ + number, + state: "open", + head: { + sha: "head-sha", + ref: "feature-branch", + repo: { full_name: "pingdotgg/t3code" }, + }, + })); + }, + rest: { + actions: {}, + pulls: { + get: async () => { + fetchedPull = true; + }, + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "false"); + assert.equal(fetchedPull, false); +}); + +test("does not publish a stale result after the PR head advances", async () => { + let listedComments = false; + const info = []; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => { + listedComments = true; + return []; + }, + rest: { + issues: { + listComments: () => {}, + createComment: () => { + throw new Error("must not create a stale comment"); + }, + updateComment: () => { + throw new Error("must not update a stale comment"); + }, + }, + pulls: { + get: async () => ({ data: { head: { sha: "new-head-sha" } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: (message) => info.push(message) }, + 5350, + "old-head-sha", + "stale body", + ); + + assert.equal(published, false); + assert.equal(listedComments, false); + assert.deepEqual(info, ["Skipping stale CI result old-head-sha; PR head is new-head-sha."]); +}); + +test("preserves a successful result when a same-SHA rerun has no artifact", async () => { + let updatedComment = false; + const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => [ + { + id: 1, + user: { login: "github-actions[bot]" }, + body: `\n`, + }, + ], + rest: { + issues: { + listComments: () => {}, + createComment: () => { + updatedComment = true; + }, + updateComment: () => { + updatedComment = true; + }, + }, + pulls: { + get: async () => ({ data: { head: { sha } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: () => {} }, + 5350, + sha, + "missing artifact warning", + { preserveResultSha: sha }, + ); + + assert.equal(published, true); + assert.equal(updatedComment, false); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6637dff343af..40895f68c833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,8 +113,32 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron - name: Test + env: + T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md + T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json run: vp run --filter './apps/*' --filter './packages/*' --filter '!@t3tools/desktop' test + - name: Publish transfer budget report + if: always() + run: | + if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then + tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" + else + echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload thread transfer result + if: always() + uses: actions/upload-artifact@v7 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer-result.json + if-no-files-found: ignore + retention-days: 30 + + - name: Test resource monitor + run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml + # Non-blocking: Electron's binary cannot currently be installed reliably # on GitHub-hosted runners (see the best-effort step above), so these # would fail for environmental reasons. They still run and report; @@ -179,9 +203,6 @@ jobs: src/components/chat/CompactComposerControlsMenu.browser.tsx \ src/components/settings/SettingsPanels.browser.tsx - - name: Test resource monitor - run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml - mobile_native_static_analysis: name: Mobile Native Static Analysis runs-on: macos-26 diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml new file mode 100644 index 000000000000..23eec72923bd --- /dev/null +++ b/.github/workflows/thread-transfer-report.yml @@ -0,0 +1,75 @@ +name: Thread Transfer Report + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + publish: + name: Publish PR comment + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-24.04 + concurrency: + group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} + cancel-in-progress: true + steps: + # workflow_run has a write-capable token even for fork PRs. Only load the + # publisher from the trusted default branch and never execute PR code. + - name: Checkout trusted publisher + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + + - name: Test trusted publisher + run: node --test .github/scripts/thread-transfer-report.test.cjs + + - id: resolve + name: Resolve PR and baseline artifacts + uses: actions/github-script@v8 + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.resolve({ github, context, core }); + + - name: Download PR result + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/pr + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.pr_run_id }} + + - name: Download main baseline + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/main + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.baseline_run_id }} + + - name: Update thread transfer comment + if: steps.resolve.outputs.publish == 'true' + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} + PR_SHA: ${{ steps.resolve.outputs.pr_sha }} + PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }} + PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }} + PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr + BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }} + BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }} + BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }} + BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.publish({ github, context, core }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53ef74f21911..c1cb8588b5ea 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,7 +13,6 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { - autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], @@ -30,6 +29,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + planModeEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 111482208cfa..5557f5fb6b19 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -79,7 +79,7 @@ import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";
-
- - - - - - {agent.title} - {agent.role ? ( - - {agent.role} - - ) : null} - - - {agent.status === "completed" ? ( - - ) : null} - +
+ + + + + {agent.title} + {role ? ( + + {role} - {activity ? ( - - {activity} - + ) : null} + + + + + {agent.status === "completed" ? ( + ) : null} - - {modelLabel ? {modelLabel} : null} - {agent.usage ? ( - - {modelLabel ? "· " : ""} - {formatSubagentTokenCount(agent.usage.totalTokens)} tok - - ) : null} - {agent.usage?.toolUses !== undefined ? ( - · {agent.usage.toolUses} tools - ) : null} - {agent.activationCount > 1 ? · run {agent.activationCount} : null} - {visuals.label} - -
+
+ + {activity ?? visuals.label} + + + {metadata.join(" · ")} + + {visuals.label}
); } @@ -314,18 +308,32 @@ function WorkflowScriptView({ } /** - * Collapsible phase section (Claude Code Background-tasks pattern): live - * phases open by default, done phases collapsed to header + member dot row. - * User toggles override the default and stick for the phase's lifetime. + * Collapsible phase section. A phase opens when it becomes active, then keeps + * that shape as it settles so completion never yanks rows out from under the + * user. Manual toggles stick until a later activation begins. */ -function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) { - const [userOpen, setUserOpen] = useState(null); - const open = userOpen ?? phase.state === "running"; +function PhaseSection({ + phase, + defaultOpen = false, +}: { + phase: AgentPanelWorkflowGroup["phases"][number]; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen || phase.state === "running"); + const previousState = useRef(phase.state); + + useEffect(() => { + if (previousState.current !== "running" && phase.state === "running") { + setOpen(true); + } + previousState.current = phase.state; + }, [phase.state]); + return (
{scriptOpen && canShowScript ? ( @@ -416,7 +436,7 @@ function LiveWorkflowSection({ /> ) : null} {group.phases.map((phase) => ( - + ))} {group.unphasedMembers.map((member) => ( @@ -429,11 +449,16 @@ function LiveWorkflowSection({ } /** - * Settled workflow: one summary line. Click toggles the member list — the - * only expansion in the panel, at run granularity. + * Collapsed workflow: one summary line. The parent owns expansion so a live + * workflow keeps its shape when it settles. */ -function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) { - const [open, setOpen] = useState(false); +function CollapsedWorkflowSection({ + group, + onExpand, +}: { + group: AgentPanelWorkflowGroup; + onExpand: () => void; +}) { const members = workflowMembers(group); const failed = members.filter((member) => member.status === "failed").length; // Coordinator usage may already aggregate members (panel-footer rule): @@ -450,9 +475,9 @@ function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {
- {open ? ( -
- {members.map((member) => ( - - ))} -
- ) : null}
); } +/** A workflow's open state is presentation state, not a status derivative. */ +function WorkflowSection({ + group, + environmentId, + threadId, +}: { + group: AgentPanelWorkflowGroup; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; +}) { + const [open, setOpen] = useState(() => workflowIsLive(group)); + return open ? ( + setOpen(false)} + /> + ) : ( + setOpen(true)} /> + ); +} + export function AgentsPanel({ model, environmentId = null, @@ -503,48 +540,24 @@ export function AgentsPanel({ ); } - const liveWorkflows = model.workflows.filter(workflowIsLive); - const settledWorkflows = model.workflows.filter((group) => !workflowIsLive(group)); - const liveDirect = model.directAgents.filter( - (agent) => - agent.status === "running" || agent.status === "pending" || agent.status === "waiting", - ); - const settledDirect = model.directAgents.filter( - (agent) => - agent.status !== "running" && agent.status !== "pending" && agent.status !== "waiting", - ); - return (
- {liveWorkflows.map((group) => ( - ( + ))} - {liveDirect.length > 0 ? ( + {model.directAgents.length > 0 ? (
Direct spawns
- {liveDirect.map((agent) => ( - - ))} -
- ) : null} - {settledWorkflows.length > 0 || settledDirect.length > 0 ? ( -
-
- Earlier -
- {settledWorkflows.map((group) => ( - - ))} - {settledDirect.map((agent) => ( + {model.directAgents.map((agent) => ( ))}
diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 76336f1ef1f2..36d42a60fa81 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -17,6 +17,7 @@ import { resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, shouldIncludeBranchPickerItem, + shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -421,6 +422,38 @@ describe("shouldShowEnvironmentIndicator", () => { }); }); +describe("shouldShowComposerContextStrip", () => { + it("keeps the environment indicator visible for a non-Git project", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: true, + }), + ).toBe(true); + }); + + it("hides the strip when a non-Git project has no environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: false, + }), + ).toBe(false); + }); + + it("shows Git controls without requiring an environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: true, + showEnvironmentIndicator: false, + }), + ).toBe(true); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index d9737f17a323..485ffbf8d37f 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -54,6 +54,14 @@ export function shouldShowEnvironmentIndicator(input: { return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } +export function shouldShowComposerContextStrip(input: { + hasActiveProject: boolean; + isGitRepo: boolean; + showEnvironmentIndicator: boolean; +}): boolean { + return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator); +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index a3f043c65368..440f48d7c90a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -44,6 +44,7 @@ import { Separator } from "./ui/separator"; interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; + showGitControls: boolean; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; effectiveEnvModeOverride?: EnvMode; @@ -309,6 +310,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, + showGitControls, draftId, onEnvModeChange, effectiveEnvModeOverride, @@ -403,7 +405,7 @@ export const BranchToolbar = memo(function BranchToolbar({ data-compact={labelsOverflow ? "" : undefined} className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 ps-1 pe-2 pt-5 pb-1" > - {isMobile ? ( + {isMobile && showGitControls ? ( - + {showGitControls ? ( + + ) : null} )} - + {showGitControls ? ( + + ) : null}
)} - + {showGitControls ? ( + + ) : null}
); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8a67d2595b77..cefa978ddb57 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -85,7 +85,7 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, - findSidebarProposedPlan, + deriveTurnPlans, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, @@ -121,11 +121,6 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { - clearPlanSidebarDismissal, - dismissPlanSidebarForTurn, - isPlanSidebarDismissedForTurn, -} from "../planSidebarDismissal"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectActiveRightPanel, @@ -157,7 +152,6 @@ import { import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -223,14 +217,17 @@ import { serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; -import { threadEnvironment } from "../state/threads"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { + requestOlderThreadTurns, + threadHasOlderTurns, +} from "@t3tools/client-runtime/state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { useProject, useProjects, useThread, - useThreadProposedPlans, useThreadRefs, useThreadShell, } from "../state/entities"; @@ -240,11 +237,17 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { + resolveEffectiveEnvMode, + resolveLocalCheckoutBranchMismatch, + shouldShowComposerContextStrip, + shouldShowEnvironmentIndicator, +} from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, ProviderStatusBanner, @@ -442,8 +445,6 @@ type EnvironmentUnavailableState = { readonly connection: EnvironmentConnectionPresentation; }; -type ThreadPlanCatalogEntry = Pick; - function eventPathContainsSelector(event: Event, selector: string): boolean { const path = event.composedPath(); if (path.length === 0 && event.target) { @@ -1235,6 +1236,23 @@ function ChatViewContent(props: ChatViewProps) { [routeServerThreadShell, threadDetailLoading], ); const activeServerThread = serverThread ?? loadingServerThread; + // Pagination window state for the routed server thread: drives the + // "load earlier turns" header when the loaded window has older history. + const routeThreadState = useEnvironmentThread( + routeKind === "server" ? routeThreadRef.environmentId : null, + routeKind === "server" ? routeThreadRef.threadId : null, + ); + const loadEarlierTurns = useMemo(() => { + if (routeKind !== "server" || !threadHasOlderTurns(routeThreadState)) { + return null; + } + return { + loading: routeThreadState.page._tag === "Some" && routeThreadState.page.value.loadingOlder, + onLoadEarlier: () => { + requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId); + }, + }; + }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the @@ -1245,7 +1263,6 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; - const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. @@ -1315,10 +1332,7 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - // When set, the thread-change reset effect will open the sidebar instead of closing it. - // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. - const planSidebarOpenOnNextThreadRef = useRef(false); + const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -1468,8 +1482,13 @@ function ChatViewContent(props: ChatViewProps) { ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = - composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; + // Plan mode is legacy (Settings → Beta). With the flag off the effective + // mode is forced to "default" — even for threads with a stored plan mode — + // so nobody is trapped in plan mode while its toggle is hidden. The next + // send persists "default" back to the thread. + const interactionMode = settings.planModeEnabled + ? (composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE) + : DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; @@ -1551,10 +1570,10 @@ function ChatViewContent(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; - const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; useEffect(() => { if (!activeThreadRef) return; @@ -1581,36 +1600,11 @@ function ChatViewContent(props: ChatViewProps) { previewPanelOpen, ]); - const planSidebarOpen = activeRightPanelKind === "plan"; - const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const sourcePlanThreadRef = useMemo(() => { - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { - return null; - } - return scopeThreadRef(activeThread.environmentId, sourceThreadId); - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); - const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); - const threadPlanCatalog = useMemo(() => { - if (!activeThread) { - return []; - } - const entries: ThreadPlanCatalogEntry[] = [ - { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, - ]; - if (sourcePlanThreadRef) { - entries.push({ - id: sourcePlanThreadRef.threadId, - proposedPlans: sourceThreadProposedPlans, - }); - } - return entries; - }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -1746,6 +1740,14 @@ function ChatViewContent(props: ChatViewProps) { return envs; }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const activeEnvironmentOption = + logicalProjectEnvironments.find( + (environment) => environment.environmentId === activeThread?.environmentId, + ) ?? null; + const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: hasMultipleEnvironments, + }); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -2055,6 +2057,7 @@ function ChatViewContent(props: ChatViewProps) { // The panel derives account-scoped limits from activities, so the active // thread is the only source available on this surface. const rateLimitThreads = useMemo(() => [{ activities: threadActivities }], [threadActivities]); + const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the // Agents surface, live strip, and workflow cards. v2Projection is null // until orchestration-v2 lands (source precedence lives in the derive). @@ -2117,21 +2120,25 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); - const sidebarProposedPlan = useMemo( - () => - findSidebarProposedPlan({ - threads: threadPlanCatalog, - latestTurn: activeLatestTurn, - latestTurnSettled, - threadId: activeThread?.id ?? null, - }), - [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], - ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); - const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; + // Current step for the in-chat working row: only for the running turn's own + // plan (deriveActivePlanState falls back to older turns' plans, which must + // not label fresh work). Falls back to the first pending step so an + // all-pending freshly written plan labels the row, matching the chip and + // the server's planProgress. + const workingStepLabel = useMemo(() => { + if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) { + return null; + } + return ( + activePlan.steps.find((step) => step.status === "inProgress")?.step ?? + activePlan.steps.find((step) => step.status === "pending")?.step ?? + null + ); + }, [activeLatestTurn?.turnId, activePlan]); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -2400,8 +2407,13 @@ function ChatViewContent(props: ChatViewProps) { }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], + deriveTimelineEntries( + timelineMessages, + activeThread?.proposedPlans ?? [], + workLogEntries, + turnPlans, + ), + [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries], ); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = @@ -2518,7 +2530,11 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const showComposerContextStrip = isGitRepo && activeProject !== null; + const showComposerContextStrip = shouldShowComposerContextStrip({ + hasActiveProject: activeProject !== null, + isGitRepo, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + }); const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; @@ -3107,47 +3123,15 @@ function ChatViewContent(props: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const dismissPlanSidebarForCurrentTurn = useCallback(() => { - if (!activeThreadKey) return; - dismissPlanSidebarForTurn( - activeThreadKey, - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__", - ); - }, [activeThreadKey, activePlan?.turnId, sidebarProposedPlan?.turnId]); - const togglePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } else if (activeThreadKey) { - clearPlanSidebarDismissal(activeThreadKey); - } - useRightPanelStore.getState().toggle(activeThreadRef, "plan"); - }, [activeThreadKey, activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); - const closePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; - setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); - dismissPlanSidebarForCurrentTurn(); - }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); const createBrowserSurface = useCallback(() => { if (!activeThreadRef) return; void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); }, [activeThreadRef, openPreview]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - }, [ - activeThreadRef, - dismissPlanSidebarForCurrentTurn, - isGitRepo, - isServerThread, - onDiffPanelOpen, - planSidebarOpen, - ]); + }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -3283,11 +3267,6 @@ function ChatViewContent(props: ChatViewProps) { const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - if (surface.kind === "plan") { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - } else if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); @@ -3299,20 +3278,16 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen?.(); } }, - [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], + [activeThreadRef, diffOpen, onDiffPanelOpen], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; if (rightPanelOpen) { - if (planSidebarOpen) { - closePlanSidebar(); - } else { - closePreviewPanel(); - } + closePreviewPanel(); return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); + }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => @@ -3322,10 +3297,6 @@ function ChatViewContent(props: ChatViewProps) { const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; - if (surfaces.some((surface) => surface.kind === "plan")) { - dismissPlanSidebarForCurrentTurn(); - } - for (const surface of surfaces) { if (surface.kind === "preview" && surface.resourceId) { void closePreviewSession({ @@ -3351,7 +3322,6 @@ function ChatViewContent(props: ChatViewProps) { activePreviewState.sessions, closePreview, closeTerminalMutation, - dismissPlanSidebarForCurrentTurn, storeCloseTerminal, ], ); @@ -3533,6 +3503,10 @@ function ChatViewContent(props: ChatViewProps) { new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const timelineScrollModeRef = useRef("following-end"); + // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd + // re-pins on its own (independent of the refs), so the timeline needs a + // render-visible flag to switch it off once the user scrolls away. + const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); @@ -3570,6 +3544,7 @@ function ChatViewContent(props: ChatViewProps) { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; + setTimelineLiveFollowEnabled(false); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -3644,6 +3619,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -3656,91 +3632,128 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { let removeListeners: (() => void) | null = null; let frame: number | null = null; - const attachListeners = () => { - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - frame = requestAnimationFrame(attachListeners); - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - const handleWheelNavigation = (event: WheelEvent) => { - if (event.defaultPrevented || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) { - return; - } - handleManualNavigation(); - }; - const targetAcceptsKeyboardInput = (target: EventTarget | null) => { - if (!(target instanceof HTMLElement)) return false; - return Boolean(target.closest("input, textarea, select, [contenteditable='true']")); - }; - const targetUsesSpaceActivation = (target: EventTarget | null) => { - if (!(target instanceof HTMLElement)) return false; - return Boolean( - target.closest( - "button, a[href], summary, [role='button'], [role='checkbox'], [role='menuitem'], [role='switch'], [tabindex]:not([tabindex='-1'])", - ), - ); - }; - const handleKeyboardNavigation = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return; - if (targetAcceptsKeyboardInput(event.target)) return; - if (event.key === " " && targetUsesSpaceActivation(event.target)) return; - const activeElement = document.activeElement; - if ( - activeElement && - activeElement !== document.body && - activeElement !== scrollNode && - !scrollNode.contains(activeElement) - ) { + const attach = (remainingAttempts: number) => { + frame = requestAnimationFrame(() => { + frame = null; + const scrollNode = legendListRef.current?.getScrollableNode(); + if (!scrollNode) { + // The list may not have mounted on the first frame after a thread + // switch — without a retry the opt-out listeners never attach and + // live-follow becomes impossible to escape for the whole thread. + if (remainingAttempts > 0) { + attach(remainingAttempts - 1); + } return; } - if ( - event.key === "PageUp" || - event.key === "PageDown" || - event.key === "Home" || - event.key === "End" || - event.key === " " - ) { - handleManualNavigation(); - } - }; - const handlePointerDown = (event: PointerEvent) => { - if (event.button !== 0) return; - const rect = scrollNode.getBoundingClientRect(); - const scrollbarWidth = scrollNode.offsetWidth - scrollNode.clientWidth; - const scrollbarHeight = scrollNode.offsetHeight - scrollNode.clientHeight; - const onVerticalScrollbar = - scrollbarWidth > 0 && - event.clientX >= rect.right - scrollbarWidth && - event.clientX <= rect.right; - const onHorizontalScrollbar = - scrollbarHeight > 0 && - event.clientY >= rect.bottom - scrollbarHeight && - event.clientY <= rect.bottom; - if (onVerticalScrollbar || onHorizontalScrollbar) { - handleManualNavigation(); - } - }; - scrollNode.addEventListener("wheel", handleWheelNavigation, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, + const handleManualNavigation = () => { + cancelTimelineLiveFollowForUserNavigationRef.current(); + }; + // The gestures below must only break follow when they can actually + // move the viewport away from the live edge. Follow now gates + // LegendList's maintainScrollAtEnd, so a spurious break while pinned + // at the end produces no scroll event, never re-arms, and streaming + // silently stops following. Underflowing content can't scroll at all, + // so nothing there should break follow. + const contentScrollsUp = () => timelineRealContentOverflowsViewport(); + // The follow re-arm band, not the strict flag: streaming growth makes + // isAtEnd flicker false for a frame before the follow scroll catches + // up, and a gesture landing in that window while still pinned would + // otherwise break follow with no scroll event left to re-arm it. + const viewportIsAwayFromEnd = () => + resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) === + false; + // Only an upward wheel is a navigation intent; wheeling down while + // following either does nothing (at the end) or moves toward it. + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0 && contentScrollsUp()) { + handleManualNavigation(); + } + }; + // Touch direction isn't observable here (touchmove fires on any + // finger motion, scrolling or not), so break only once the drag has + // actually carried the viewport out of the end band — an upward flick + // gets there within its first few events and later touchmoves break. + const handleTouchMove = () => { + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Scrollbar drags produce no wheel/touch events; they are the only + // pointerdowns whose target is the scroll node itself rather than a + // message row. Content clicks break follow only away from the end + // (reading or selecting up there must hold position); clicking near + // the live edge keeps following. + const handlePointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + if (event.target === scrollNode) { + if (contentScrollsUp()) { + handleManualNavigation(); + } + return; + } + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + const targetAcceptsKeyboardInput = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + return Boolean(target.closest("input, textarea, select, [contenteditable='true']")); + }; + const targetUsesSpaceActivation = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + return Boolean( + target.closest( + "button, a[href], summary, [role='button'], [role='checkbox'], [role='menuitem'], [role='switch'], [tabindex]:not([tabindex='-1'])", + ), + ); + }; + // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and + // pointer events entirely; without this the timeline yanks back to + // the end on the next stream chunk. + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return; + if (targetAcceptsKeyboardInput(event.target)) return; + if (event.key === " " && targetUsesSpaceActivation(event.target)) return; + const activeElement = document.activeElement; + if ( + activeElement && + activeElement !== document.body && + activeElement !== scrollNode && + !scrollNode.contains(activeElement) + ) { + return; + } + switch (event.key) { + case "PageUp": + case "Home": + case "ArrowUp": + if (contentScrollsUp()) { + handleManualNavigation(); + } + break; + default: + break; + } + }; + scrollNode.addEventListener("wheel", handleWheel, { + passive: true, + }); + scrollNode.addEventListener("touchmove", handleTouchMove, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); + window.addEventListener("keydown", handleKeyDown, true); + removeListeners = () => { + scrollNode.removeEventListener("wheel", handleWheel); + scrollNode.removeEventListener("touchmove", handleTouchMove); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown, true); + }; }); - window.addEventListener("keydown", handleKeyboardNavigation, true); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleWheelNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - window.removeEventListener("keydown", handleKeyboardNavigation, true); - }; }; - frame = requestAnimationFrame(attachListeners); + attach(12); return () => { if (frame !== null) { @@ -3748,10 +3761,22 @@ function ChatViewContent(props: ChatViewProps) { } removeListeners?.(); }; - }, [activeThread?.id]); + }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); const onTimelineAnchorReady = useCallback( (messageId: MessageId, anchorIndex: number) => { + // Anchored-end space can be remeasured when the turn completes. Once the + // user has scrolled away (or returned to ordinary end-following), that + // remeasurement must not restart the send-time anchor positioning. + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + if ( + positionedTimelineAnchorRef.current !== messageId && + settledTimelineAnchorRef.current !== messageId + ) { + clearTimelineAnchorForMessage(messageId); + } + return; + } if (pendingTimelineAnchorRef.current !== messageId) { if ( positionedTimelineAnchorRef.current !== messageId && @@ -3869,6 +3894,7 @@ function ChatViewContent(props: ChatViewProps) { if (isAtEnd) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { @@ -3949,43 +3975,16 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - if (planSidebarOpenOnNextThreadRef.current) { - planSidebarOpenOnNextThreadRef.current = false; - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } - } // activeThreadRef resets transitively with the active thread. }, [activeThread?.id]); - // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. - // Don't auto-open for plans carried over from a previous turn (the user can open manually). - useEffect(() => { - if (!autoOpenPlanSidebar) return; - if (!activePlan) return; - if (planSidebarOpen) return; - const latestTurnId = activeLatestTurn?.turnId ?? null; - if (latestTurnId && activePlan.turnId !== latestTurnId) return; - const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - if (!activeThreadRef) return; - if (isPlanSidebarDismissedForTurn(scopedThreadKey(activeThreadRef), turnKey)) return; - useRightPanelStore.getState().open(activeThreadRef, "plan"); - }, [ - activePlan, - activeLatestTurn?.turnId, - activeThreadRef, - autoOpenPlanSidebar, - planSidebarOpen, - sidebarProposedPlan?.turnId, - ]); - useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); @@ -4803,12 +4802,21 @@ function ChatViewContent(props: ChatViewProps) { isSendBusy || isConnecting || threadDetailLoading || - activeEnvironmentUnavailable || sendInFlightRef.current ) { notifyDirectAnnotationAttached(); return; } + if (activeEnvironmentUnavailable) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Not connected: message not sent", + description: "Reconnecting to the environment. Try again once it is connected.", + }), + ); + return; + } if (activePendingProgress) { if (directAnnotation) { notifyDirectAnnotationAttached(); @@ -4883,7 +4891,10 @@ function ChatViewContent(props: ChatViewProps) { }); return; } + // Legacy plan mode: /plan and /default only act when the beta flag is on; + // otherwise they send as plain text like any other message. const standaloneSlashCommand = + settings.planModeEnabled && composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && @@ -5007,6 +5018,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -5452,6 +5464,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -5526,15 +5539,6 @@ function ChatViewContent(props: ChatViewProps) { if (failure === null) { acknowledgeActiveThreadWoke(); - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default" && autoOpenPlanSidebar) { - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } - } sendInFlightRef.current = false; return; } @@ -5569,7 +5573,6 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, startThreadTurn, - autoOpenPlanSidebar, environmentId, composerRef, ], @@ -5672,8 +5675,6 @@ function ChatViewContent(props: ChatViewProps) { } if (failure === null) { - // Signal that the plan sidebar should open on the new thread when enabled. - planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; const navigateResult = await settlePromise(() => navigate({ to: "/$environmentId/$threadId", @@ -5730,7 +5731,6 @@ function ChatViewContent(props: ChatViewProps) { resetLocalDispatch, runtimeMode, startThreadTurn, - autoOpenPlanSidebar, environmentId, composerRef, ]); @@ -5922,12 +5922,12 @@ function ChatViewContent(props: ChatViewProps) {
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( + {rightPanelOpen && !shouldUseRightPanelSheet ? ( - ) : activeRightPanelSurface?.kind === "plan" ? ( - ) : activeRightPanelSurface?.kind === "agents" ? ( - {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} + {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
{/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -6235,10 +6226,6 @@ function ChatViewContent(props: ChatViewProps) { respondingRequestIds={respondingRequestIds} showPlanFollowUpPrompt={showPlanFollowUpPrompt} activeProposedPlan={activeProposedPlan} - activePlan={activePlan as { turnId?: TurnId } | null} - sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} - planSidebarLabel={planSidebarLabel} - planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} interactionMode={interactionMode} lockedProvider={lockedProvider} @@ -6276,7 +6263,6 @@ function ChatViewContent(props: ChatViewProps) { toggleInteractionMode={toggleInteractionMode} handleRuntimeModeChange={handleRuntimeModeChange} handleInteractionModeChange={handleInteractionModeChange} - togglePlanSidebar={togglePlanSidebar} focusComposer={focusComposer} scheduleComposerFocus={scheduleComposerFocus} setThreadError={setThreadError} @@ -6294,6 +6280,7 @@ function ChatViewContent(props: ChatViewProps) { - {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - + {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( + - - - ); - } - if (status === "inProgress") { - return ( - - - - ); - } - return ( - - - - ); -} - -interface PlanSidebarProps { - activePlan: ActivePlanState | null; - activeProposedPlan: LatestProposedPlanState | null; - label?: string; - environmentId: EnvironmentId; - threadRef?: ScopedThreadRef | undefined; - markdownCwd: string | undefined; - workspaceRoot: string | undefined; - timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar" | "embedded"; -} - -const PlanSidebar = memo(function PlanSidebar({ - activePlan, - activeProposedPlan, - label = "Plan", - environmentId, - threadRef, - markdownCwd, - workspaceRoot, - timestampFormat, - mode = "sidebar", -}: PlanSidebarProps) { - const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); - const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { - reportFailure: false, - }); - const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); - - const planMarkdown = activeProposedPlan?.planMarkdown ?? null; - const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; - const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; - - const handleCopyPlan = useCallback(() => { - if (!planMarkdown) return; - copyToClipboard(planMarkdown); - }, [planMarkdown, copyToClipboard]); - - const handleDownload = useCallback(() => { - if (!planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); - }, [planMarkdown]); - - const handleSaveToWorkspace = useCallback(() => { - if (!workspaceRoot || !planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - setIsSavingToWorkspace(true); - void (async () => { - const result = await writeProjectFile({ - environmentId, - input: { - cwd: workspaceRoot, - relativePath: filename, - contents: normalizePlanMarkdownForExport(planMarkdown), - }, - }); - setIsSavingToWorkspace(false); - if (result._tag === "Success") { - toastManager.add({ - type: "success", - title: "Plan saved", - description: result.value.relativePath, - }); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); - - return ( -
- {/* Header */} -
-
- - {label} - - {activePlan ? ( - - {formatTimestamp(activePlan.createdAt, timestampFormat)} - - ) : null} -
-
- {planMarkdown ? ( - - - } - > - - - - - {isCopied ? "Copied!" : "Copy to clipboard"} - - Download as markdown - - Save to workspace - - - - ) : null} -
-
- - {/* Content */} - -
- {/* Explanation */} - {activePlan?.explanation ? ( -

- {activePlan.explanation} -

- ) : null} - - {/* Plan Steps */} - {activePlan && activePlan.steps.length > 0 ? ( -
-

- Steps -

- {activePlan.steps.map((step) => ( -
- {stepStatusIcon(step.status)} -

- {step.step} -

-
- ))} -
- ) : null} - - {/* Proposed Plan Markdown */} - {planMarkdown ? ( -
- - {proposedPlanExpanded ? ( -
- -
- ) : null} -
- ) : null} - - {/* Empty state */} - {!activePlan && !planMarkdown ? ( -
-

No active plan yet.

-

- Plans will appear here when generated. -

-
- ) : null} -
-
-
- ); -}); - -export default PlanSidebar; -export type { PlanSidebarProps }; diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b0f18f6e1266..b9345ab8c3c5 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,6 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { Bot, ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { Bot, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -213,8 +213,6 @@ function surfaceTitle( terminalLabelsById.get(surface.activeTerminalId) ?? getTerminalLabel(surface.activeTerminalId) ); - case "plan": - return "Plan"; case "agents": return "Agents"; case "preview": { @@ -276,8 +274,6 @@ function SurfaceIcon({ ); case "terminal": return ; - case "plan": - return ; case "agents": return ; } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 3f6355cbdbb9..17c91e3c70ee 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -507,13 +507,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? { label: "Working", icon: "working" as const, - className: - "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", + // No shimmer: a label that animates forever is noise in a sidebar + // full of them (and repaints every vsync on high-refresh displays). + // Working is a background state, so it rests at the dim end of what + // the old pulse cycled through; only the thread you have open gets + // the label at full strength. + className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"), } : status === "monitoring" ? { - // Steady label, no duty-cycled shimmer: monitoring is calm - // background presence, not active progress (monitoring-pill D6). + // Monitoring is calm background presence, not active progress + // (monitoring-pill D6), so it keeps the label at full strength. label: "Monitoring", icon: null, className: "text-sky-600 dark:text-sky-400", @@ -1103,7 +1107,18 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
- {thread.branch ? ( + {/* While working, the current plan step outranks the branch: + it's the one line that says what the thread is doing. */} + {status === "working" && thread.planProgress ? ( + + {thread.planProgress.step} + {/* Completed count, matching the transcript chip's n/m. */} + + {" "} + {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} + + + ) : thread.branch ? ( {thread.branch} ) : ( @@ -2229,6 +2244,40 @@ export default function SidebarV2() { ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); + const performSnooze = useCallback( + async ( + threadRef: ScopedThreadRef, + preset: SnoozePreset, + opts: { coSnoozingKeys?: ReadonlySet } = {}, + ) => { + const threadKey = scopedThreadKey(threadRef); + if (snoozingThreadKeysRef.current.has(threadKey)) { + return { status: "skipped" } as const; + } + snoozingThreadKeysRef.current.add(threadKey); + try { + // Snoozing the open thread moves you forward, same as settle — + // both park the thread you're done with for now. + const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not snooze. + return isAtomCommandInterrupted(result) + ? ({ status: "interrupted" } as const) + : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); + } + // Only move forward if the user is still on the snoozed thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSnooze?.(); + } + return { status: "success" } as const; + } finally { + snoozingThreadKeysRef.current.delete(threadKey); + } + }, + [planForwardNavigation, snoozeThread], + ); const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, @@ -2236,52 +2285,35 @@ export default function SidebarV2() { opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { - const threadKey = scopedThreadKey(threadRef); - if (snoozingThreadKeysRef.current.has(threadKey)) return; - snoozingThreadKeysRef.current.add(threadKey); - try { - // Snoozing the open thread moves you forward, same as settle — - // both park the thread you're done with for now. - const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not snooze. - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - // Snooze hides the row, so the toast is the only confirmation — - // and the Undo is the escape hatch for a mis-click. + const outcome = await performSnooze(threadRef, preset, opts); + if (outcome.status === "failure") { toastManager.add( stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => attemptUnsnooze(threadRef), - }, + type: "error", + title: "Failed to snooze thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", }), ); - // Only move forward if the user is still on the snoozed thread — - // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { - navigateAfterSnooze?.(); - } - } finally { - snoozingThreadKeysRef.current.delete(threadKey); + return; } + if (outcome.status !== "success") return; + // Snooze hides the row, so the toast is the only confirmation — + // and the Undo is the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => attemptUnsnooze(threadRef), + }, + }), + ); })(); }, - [attemptUnsnooze, planForwardNavigation, snoozeThread, timestampFormat], + [attemptUnsnooze, performSnooze, timestampFormat], ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); @@ -2355,12 +2387,55 @@ export default function SidebarV2() { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. const coSnoozingKeys = new Set(threadKeys); - for (const thread of selectedThreads) { - attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { - coSnoozingKeys, - }); - } clearSelection(); + const outcomes = await Promise.all( + selectedThreads.map(async (thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); + return { outcome, threadRef }; + }), + ); + const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => + outcome.status === "success" ? [threadRef] : [], + ); + const failures = outcomes.flatMap(({ outcome }) => + outcome.status === "failure" ? [outcome.error] : [], + ); + + if (snoozedThreadRefs.length > 0) { + const snoozedCount = snoozedThreadRefs.length; + const failedCount = failures.length; + toastManager.add( + stackedThreadToast({ + type: failedCount > 0 ? "warning" : "success", + title: + failedCount > 0 + ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` + : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, + description: + failedCount > 0 + ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` + : undefined, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); + }, + }, + }), + ); + } else if (failures.length > 0) { + const firstError = failures[0]; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze threads", + description: + firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); + } } return; } @@ -2456,8 +2531,10 @@ export default function SidebarV2() { confirmThreadDelete, deleteThread, markThreadUnread, + performSnooze, removeFromSelection, serverConfigs, + attemptUnsnooze, updateThreadMetadata, timestampFormat, ], diff --git a/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx index 07711ca84b7b..f5391c13abaf 100644 --- a/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx +++ b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx @@ -5,7 +5,10 @@ import { toastManager } from "./ui/toast"; function describeSlowRequests(requests: ReadonlyArray): string { const count = requests.length; - const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); + // Thresholds vary per method, so report the smallest one the batch has passed. + const thresholdSeconds = Math.round( + Math.min(...requests.map((request) => request.thresholdMs)) / 1000, + ); return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d6db3bce5ae7..86749eac7ed9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -10,7 +10,6 @@ import type { ScopedThreadRef, ServerProvider, ThreadId, - TurnId, } from "@t3tools/contracts"; import { ProviderDriverKind, @@ -192,7 +191,7 @@ import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; -import { BotIcon, CircleAlertIcon, ListTodoIcon, PencilRulerIcon, XIcon } from "lucide-react"; +import { BotIcon, CircleAlertIcon, PencilRulerIcon, XIcon } from "lucide-react"; import { getRuntimeModeConfig, getRuntimeModeOptions } from "./runtimeModePresentation"; import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; @@ -264,12 +263,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; - showPlanToggle: boolean; - planSidebarLabel: string; - planSidebarOpen: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; - onTogglePlanSidebar: () => void; }) { const runtimeModeConfig = getRuntimeModeConfig(props.provider); const runtimeModeOptions = getRuntimeModeOptions(props.provider); @@ -279,9 +274,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop props.interactionMode === "plan" ? "Plan mode — click to return to normal build mode" : "Default mode — click to enter plan mode"; - const planSidebarTooltip = props.planSidebarOpen - ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` - : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`; const interactionModeToggle = props.showInteractionModeToggle ? ( <> @@ -357,36 +349,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {interactionModeToggle} - - {props.showPlanToggle ? ( - <> - - - - } - > - - {props.planSidebarLabel} - - {planSidebarTooltip} - - - ) : null} ); }); @@ -542,10 +504,6 @@ export interface ChatComposerProps { // Plan showPlanFollowUpPrompt: boolean; activeProposedPlan: Thread["proposedPlans"][number] | null; - activePlan: { turnId?: TurnId } | null; - sidebarProposedPlan: { turnId?: TurnId } | null; - planSidebarLabel: string; - planSidebarOpen: boolean; // Mode runtimeMode: RuntimeMode; @@ -598,7 +556,6 @@ export interface ChatComposerProps { toggleInteractionMode: () => void; handleRuntimeModeChange: (mode: RuntimeMode) => void; handleInteractionModeChange: (mode: ProviderInteractionMode) => void; - togglePlanSidebar: () => void; focusComposer: () => void; scheduleComposerFocus: () => void; @@ -641,10 +598,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) respondingRequestIds, showPlanFollowUpPrompt, activeProposedPlan, - activePlan, - sidebarProposedPlan, - planSidebarLabel, - planSidebarOpen, runtimeMode, interactionMode, lockedProvider, @@ -675,7 +628,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleInteractionMode, handleRuntimeModeChange, handleInteractionModeChange, - togglePlanSidebar, focusComposer, scheduleComposerFocus, setThreadError, @@ -890,14 +842,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; + // Plan mode is a legacy feature behind Settings → Beta. With the flag off, + // ChatView forces the effective mode to "default", so hiding the toggle + // can't trap anyone in plan mode. + const planModeUiEnabled = settings.planModeEnabled; const composerProviderControls = useMemo( () => ({ - showInteractionModeToggle: getProviderInteractionModeToggle( - providerStatuses, - selectedProvider, - ), + showInteractionModeToggle: + planModeUiEnabled && getProviderInteractionModeToggle(providerStatuses, selectedProvider), }), - [providerStatuses, selectedProvider], + [planModeUiEnabled, providerStatuses, selectedProvider], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), @@ -1058,20 +1012,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/model", description: "Switch response model for this thread", }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal build mode", - }, + ...(planModeUiEnabled + ? ([ + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] as const) + : []), ] satisfies ReadonlyArray>; const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( (command) => ({ @@ -1106,7 +1064,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + planModeUiEnabled, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1146,7 +1110,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; - const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); const composerFooterActionLayoutKey = useMemo(() => { if (activePendingProgress) { return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; @@ -1873,6 +1836,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event: KeyboardEvent, ) => { if (key === "Tab" && event.shiftKey) { + if (!planModeUiEnabled) return false; toggleInteractionMode(); return true; } @@ -3154,15 +3118,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {isComposerFooterCompact ? ( ) : ( @@ -3178,12 +3138,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} interactionMode={interactionMode} runtimeMode={runtimeMode} - showPlanToggle={showPlanSidebarToggle} - planSidebarLabel={planSidebarLabel} - planSidebarOpen={planSidebarOpen} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} - onTogglePlanSidebar={togglePlanSidebar} /> )} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index aec207577f1f..2f17f6c03be2 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,10 +1,9 @@ import { ProviderDriverKind, ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; import { memo, type ReactNode } from "react"; -import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { EllipsisIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, - MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, @@ -15,15 +14,11 @@ import { getRuntimeModeConfig, getRuntimeModeOptions } from "./runtimeModePresen export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { provider: ProviderDriverKind; - activePlan: boolean; interactionMode: ProviderInteractionMode; - planSidebarLabel: string; - planSidebarOpen: boolean; runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; - onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const runtimeModeConfig = getRuntimeModeConfig(props.provider); @@ -80,17 +75,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls ))} - {props.activePlan ? ( - <> - - - - {props.planSidebarOpen - ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` - : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`} - - - ) : null} ); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index e5ecdbd20045..6bc0a2a6203c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -4,6 +4,7 @@ import { workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, + type TurnPlanEntry, type WorkLogEntry, } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; @@ -18,11 +19,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; export interface TimelineEndState { readonly isAtEnd?: boolean; - readonly isNearEnd?: boolean; + readonly contentLength?: number; + readonly scroll?: number; + readonly scrollLength?: number; } -export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { - return state?.isNearEnd ?? state?.isAtEnd; +/** + * Follow re-arm band above the hard bottom. Strict on purpose: LegendList's + * isNearEnd fires within half a viewport, which re-armed live-follow while the + * user was reading history and yanked them back down on the next stream chunk. + * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming + * reliable while streaming content is still growing under the viewport. + */ +export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; + +export function resolveTimelineIsAtEnd( + state: TimelineEndState | undefined, + endInset = 0, +): boolean | undefined { + if (!state) { + return undefined; + } + if (state.isAtEnd) { + return true; + } + const { contentLength, scroll, scrollLength } = state; + if (contentLength === undefined || scroll === undefined || scrollLength === undefined) { + return state.isAtEnd; + } + // contentLength includes the end inset (composer overlay), so subtract it to + // measure the distance to the real content bottom. + return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } export function resolveTimelineMinimapHeightStyle(itemCount: number): string { @@ -175,6 +202,12 @@ export type MessagesTimelineRow = createdAt: string; proposedPlan: ProposedPlan; } + | { + kind: "turn-plan"; + id: string; + createdAt: string; + turnPlan: TurnPlanEntry; + } | { kind: "working"; id: string; createdAt: string | null }; export interface StableMessagesTimelineRowsState { @@ -550,6 +583,16 @@ export function deriveMessagesTimelineRows(input: { continue; } + if (timelineEntry.kind === "turn-plan") { + nextRows.push({ + kind: "turn-plan", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + turnPlan: timelineEntry.turnPlan, + }); + continue; + } + const assistantTurnStillInProgress = timelineEntry.message.role === "assistant" && unsettledTurnId !== null && @@ -633,6 +676,13 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "proposed-plan": return a.proposedPlan === (b as typeof a).proposedPlan; + case "turn-plan": { + const bp = b as typeof a; + // Plans rewrite in place: compare the snapshot's identity fields so an + // unchanged plan keeps its row reference (virtualization stability). + return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; + } + case "work": return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e9527..cf055f05b742 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -194,6 +194,7 @@ function buildProps() { onAnchorReady: () => {}, onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, + liveFollowEnabled: true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, }; @@ -296,7 +297,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("1 changed file"); }); - it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { + it("treats only the strict list end as the live edge", async () => { const { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -307,10 +308,36 @@ describe("MessagesTimeline", () => { resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); - expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true); - expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false); expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); + // Within the pixel band above the content bottom counts as the end... + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 1170, + scrollLength: 800, + }), + ).toBe(true); + // ...but half a viewport up (LegendList's isNearEnd territory) does not. + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 900, + scrollLength: 800, + }), + ).toBe(false); + // The composer inset is part of contentLength and must not count as + // distance-to-end. + expect( + resolveTimelineIsAtEnd( + { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 }, + 100, + ), + ).toBe(true); + // Geometry missing (older state shape): fall back to the strict flag. + expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c952eb3d128f..9e10a8b39cfc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -152,12 +152,41 @@ interface TimelineRowActivityState { isRevertingCheckpoint: boolean; activeTurnInProgress: boolean; latestTurnId: TurnId | null; + /** Current plan step label for the working row, when the turn has a plan. */ + workingStepLabel: string | null; } const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER =
; + +// Header row shown when older turns exist beyond the loaded window. Plain +// button, no spinner animation; the label change is the loading indicator. +function TimelineLoadEarlierHeader({ + loading, + onLoadEarlier, + fade, +}: { + loading: boolean; + onLoadEarlier: () => void; + fade: boolean; +}) { + return ( +
+
+ +
+
+ ); +} const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; @@ -169,6 +198,7 @@ interface MessagesTimelineProps { agentPanelModel?: AgentPanelModel; onOpenAgents?: () => void; isWorking: boolean; + workingStepLabel?: string | null; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; @@ -192,10 +222,19 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; + /** + * Whether the timeline should keep pinning to the live edge as content + * grows. Off while the user is reading history; LegendList's own + * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's + * scroll-mode refs whenever the user drifts near the bottom. + */ + liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; + /** Non-null when older turns exist beyond the loaded window. */ + loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; } // --------------------------------------------------------------------------- @@ -204,6 +243,7 @@ interface MessagesTimelineProps { export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, + workingStepLabel = null, activeTurnInProgress, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, @@ -229,10 +269,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, + liveFollowEnabled, onIsAtEndChange, onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, + loadEarlier = null, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -371,7 +413,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - const isAtEnd = resolveTimelineIsAtEnd(state); + const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } @@ -397,7 +439,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -470,8 +512,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isRevertingCheckpoint, activeTurnInProgress, latestTurnId: latestTurn?.turnId ?? null, + workingStepLabel, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], + [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel], ); // Stable renderItem — no closure deps. Row components read shared state @@ -513,7 +556,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace + anchoredEndSpace || !liveFollowEnabled ? false : { animated: false, @@ -533,7 +576,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "chat-timeline-scroll-fade", )} - ListHeaderComponent={topFadeEnabled ? TIMELINE_LIST_FADE_HEADER : TIMELINE_LIST_HEADER} + ListHeaderComponent={ + loadEarlier !== null ? ( + + ) : topFadeEnabled ? ( + TIMELINE_LIST_FADE_HEADER + ) : ( + TIMELINE_LIST_HEADER + ) + } ListFooterComponent={TIMELINE_LIST_FOOTER} /> ) : null} {row.kind === "proposed-plan" ? : null} + {row.kind === "turn-plan" ? : null} {row.kind === "working" ? : null}
); @@ -1109,16 +1166,117 @@ function ProposedPlanTimelineRow({ ); } +/** + * Inline folded plan chip: one row per turn that produced plan/todo steps. + * Collapsed by default — a segment bar plus the in-progress step label — + * and expands in place to the full step list. Replaces the old plan sidebar. + */ +const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ + row, +}: { + row: Extract; +}) { + const [expanded, setExpanded] = useState(false); + const { steps } = row.turnPlan.plan; + const completedCount = steps.filter((step) => step.status === "completed").length; + const allDone = completedCount === steps.length; + // Label priority: the in-progress step, else the next pending step (plan + // just created), else the last step (plan finished, rendered muted). + const label = + steps.find((step) => step.status === "inProgress")?.step ?? + steps.find((step) => step.status === "pending")?.step ?? + steps.at(-1)?.step ?? + "Plan"; + const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon; + + return ( +
+ + {expanded ? ( +
+ {steps.map((step) => ( +
+ + {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"} + + + {step.step} + +
+ ))} +
+ ) : null} +
+ ); +}); + function WorkingTimelineRow({ row }: { row: Extract }) { + const { workingStepLabel } = use(TimelineRowActivityCtx); return (
-
+
- + {row.createdAt ? ( <> Working for @@ -1127,6 +1285,9 @@ function WorkingTimelineRow({ row }: { row: Extract + {workingStepLabel ? ( + · {workingStepLabel} + ) : null}
); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx new file mode 100644 index 000000000000..3c502c624ddd --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -0,0 +1,54 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +const settingsHooks = vi.hoisted(() => ({ + read: vi.fn(() => ({ providerInstances: {} })), + update: vi.fn(() => vi.fn()), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useMemo: reactHookHarness.useMemo, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentSettings: settingsHooks.read, + useUpdateEnvironmentSettings: settingsHooks.update, +})); + +import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; + +const remoteEnvironmentId = EnvironmentId.make("remote-device"); + +describe("AddProviderInstanceDialog environment routing", () => { + beforeEach(() => { + hooks.reset(); + settingsHooks.read.mockClear(); + settingsHooks.update.mockClear(); + }); + + it("reads and writes settings through the supplied environment", () => { + hooks.beginRender(); + AddProviderInstanceDialog({ + open: true, + environmentId: remoteEnvironmentId, + environmentLabel: "Remote device", + onOpenChange: vi.fn(), + }); + + expect(settingsHooks.read).toHaveBeenCalledWith(remoteEnvironmentId); + expect(settingsHooks.update).toHaveBeenCalledWith(remoteEnvironmentId); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index a6da37c15519..158908b5e942 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -6,10 +6,11 @@ import { useMemo, useState } from "react"; import { ProviderInstanceId, ProviderDriverKind, + type EnvironmentId, type ProviderInstanceConfig, } from "@t3tools/contracts"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; @@ -115,13 +116,20 @@ function validateInstanceId(id: string, existing: ReadonlySet): string | } interface AddProviderInstanceDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; + readonly open: boolean; + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly onOpenChange: (open: boolean) => void; } -export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); +export function AddProviderInstanceDialog({ + open, + environmentId, + environmentLabel, + onOpenChange, +}: AddProviderInstanceDialogProps) { + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); @@ -227,8 +235,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns Add provider instance - Configure an additional provider instance — for example, a second Codex install - pointed at a different workspace. + Configure an additional provider instance on {environmentLabel} — for example, a + second Codex install pointed at a different workspace. settings.sidebarAutoSettleAfterDays, ); + const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); const updateSettings = useUpdateClientSettings(); return ( @@ -114,6 +115,17 @@ export function BetaSettingsPanel() { ) : null} ) : null} + updateSettings({ planModeEnabled: Boolean(checked) })} + aria-label="Restore plan mode (legacy)" + /> + } + /> ); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx new file mode 100644 index 000000000000..2b304378ae91 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -0,0 +1,256 @@ +import type { ReactElement } from "react"; +import { + DEFAULT_UNIFIED_SETTINGS, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type UnifiedSettings, +} from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { visitElements } from "../../test/reactElementTree"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +const atoms = vi.hoisted(() => ({ + providers: null as ReadonlyArray | null, + providersAtom: Symbol("providers"), + refreshProviders: Symbol("refreshProviders"), + updateProvider: Symbol("updateProvider"), +})); + +const commands = vi.hoisted(() => ({ + refresh: vi.fn(), + updateProvider: vi.fn(), +})); + +const settingsState = vi.hoisted(() => ({ + value: null as UnifiedSettings | null, + readEnvironmentIds: [] as EnvironmentId[], + updateEnvironmentIds: [] as EnvironmentId[], + updateSettings: vi.fn(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => atoms.providers, +})); + +vi.mock("../../state/server", () => ({ + EMPTY_SERVER_PROVIDERS: [], + serverEnvironment: { + providersValueAtom: () => atoms.providersAtom, + refreshProviders: atoms.refreshProviders, + updateProvider: atoms.updateProvider, + }, +})); + +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (atom: symbol) => + atom === atoms.refreshProviders ? commands.refresh : commands.updateProvider, +})); + +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentSettings: (environmentId: EnvironmentId) => { + settingsState.readEnvironmentIds.push(environmentId); + return settingsState.value; + }, + useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => { + settingsState.updateEnvironmentIds.push(environmentId); + return settingsState.updateSettings; + }, +})); + +vi.mock("../../environments/primary", () => ({ + usePrimarySessionState: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), +})); + +vi.mock("../../state/session", () => ({ + useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }), +})); + +import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; + +const environmentId = EnvironmentId.make("remote-device"); +const codexId = ProviderInstanceId.make("codex"); +const customId = ProviderInstanceId.make("codex_work"); + +function provider(): ServerProvider { + return { + instanceId: codexId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-07-24T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "pnpm add -g @openai/codex@latest", + canUpdate: true, + checkedAt: "2026-07-24T12:00:00.000Z", + message: "Update available.", + }, + }; +} + +function renderPanel(options?: { + readonly readOnly?: boolean; +}): ReactElement> { + hooks.beginRender(); + return EnvironmentProviderSettings({ + environmentId, + environmentLabel: "Remote device", + ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }), + }) as ReactElement>; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("EnvironmentProviderSettings routing", () => { + beforeEach(() => { + hooks.reset(); + atoms.providers = null; + settingsState.value = DEFAULT_UNIFIED_SETTINGS; + settingsState.readEnvironmentIds = []; + settingsState.updateEnvironmentIds = []; + settingsState.updateSettings.mockReset(); + commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); + commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); + }); + + it("coalesces a nullable provider snapshot before rendering array-backed UI", () => { + expect(() => renderPanel()).not.toThrow(); + expect(settingsState.readEnvironmentIds).toEqual([environmentId]); + expect(settingsState.updateEnvironmentIds).toEqual([environmentId]); + }); + + it("routes refresh and provider update commands to the selected environment", async () => { + atoms.providers = [provider()]; + const panel = renderPanel(); + const refreshButton = visitElements( + panel, + (element) => element.props["aria-label"] === "Refresh provider status", + ); + expect(refreshButton).not.toBeNull(); + (refreshButton?.props.onClick as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} }); + + const providerCard = visitElements( + panel, + (element) => + element.props.instanceId === codexId && typeof element.props.onRunUpdate === "function", + ); + expect(providerCard).not.toBeNull(); + (providerCard?.props.onRunUpdate as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.updateProvider).toHaveBeenCalledWith({ + environmentId, + input: { provider: ProviderDriverKind.make("codex"), instanceId: codexId }, + }); + }); + + it("renders the provider layout inert with a limited-permissions notice when read only", () => { + atoms.providers = [provider()]; + const panel = renderPanel({ readOnly: true }); + + const inertWrapper = visitElements(panel, (element) => element.props.inert === true); + expect(inertWrapper).not.toBeNull(); + const providerCard = visitElements(panel, (element) => element.props.instanceId === codexId); + expect(providerCard).not.toBeNull(); + + const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); + expect(notice).not.toBeNull(); + + expect( + visitElements(panel, (element) => element.props["aria-label"] === "Add provider instance"), + ).toBeNull(); + expect( + visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), + ).toBeNull(); + }); + + it("keeps the editable layout interactive when not read only", () => { + atoms.providers = [provider()]; + const panel = renderPanel(); + expect(visitElements(panel, (element) => element.props.inert === true)).toBeNull(); + expect( + visitElements(panel, (element) => element.props.title === "Limited permissions"), + ).toBeNull(); + }); + + it("deletes and resets provider configuration without erasing shared preferences", () => { + settingsState.value = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + }, + [customId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + }, + }, + providerModelPreferences: { + [customId]: { hiddenModels: ["hidden"], modelOrder: ["model"] }, + }, + favorites: [{ provider: customId, model: "favorite" }], + }; + const panel = renderPanel(); + const customCard = visitElements(panel, (element) => element.props.instanceId === customId); + expect(customCard).not.toBeNull(); + (customCard?.props.onDelete as (() => void) | undefined)?.(); + + expect(settingsState.updateSettings).toHaveBeenLastCalledWith({ + providerInstances: { + [codexId]: settingsState.value.providerInstances?.[codexId], + }, + }); + + settingsState.updateSettings.mockClear(); + const defaultCard = visitElements(panel, (element) => element.props.instanceId === codexId); + const resetAction = defaultCard?.props.headerAction; + const resetButton = visitElements( + resetAction, + (element) => typeof element.props.onClick === "function", + ); + expect(resetButton).not.toBeNull(); + (resetButton?.props.onClick as (() => void) | undefined)?.(); + + const resetPatch = settingsState.updateSettings.mock.lastCall?.[0] as + | Record + | undefined; + expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providerInstances", "providers"]); + expect(resetPatch).not.toHaveProperty("favorites"); + expect(resetPatch).not.toHaveProperty("providerModelPreferences"); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts new file mode 100644 index 000000000000..bf558f5a4d66 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -0,0 +1,260 @@ +import { AuthOrchestrationOperateScope, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildProviderEnvironmentOptions, + classifyProviderEnvironmentAccess, + resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, + resolveSelectedProviderEnvironmentId, +} from "./ProviderSettingsPanel.logic"; + +const primaryId = EnvironmentId.make("primary"); +const relayId = EnvironmentId.make("relay"); +const sshId = EnvironmentId.make("ssh"); + +const environments = [ + { environmentId: sshId, label: "Zulu SSH" }, + { environmentId: relayId, label: "Alpha Relay" }, + { environmentId: primaryId, label: "This device" }, +] as const; + +describe("provider environment selection", () => { + it("sorts the primary environment first and the rest by label", () => { + expect( + buildProviderEnvironmentOptions(environments, primaryId).map( + (environment) => environment.environmentId, + ), + ).toEqual([primaryId, relayId, sshId]); + }); + + it("keeps a valid selection, then falls back to primary or the first environment", () => { + const options = buildProviderEnvironmentOptions(environments, primaryId); + + expect(resolveSelectedProviderEnvironmentId(options, sshId, primaryId)).toBe(sshId); + expect( + resolveSelectedProviderEnvironmentId( + options.filter((environment) => environment.environmentId !== sshId), + sshId, + primaryId, + ), + ).toBe(primaryId); + expect(resolveSelectedProviderEnvironmentId(options.slice(1), primaryId, primaryId)).toBe( + relayId, + ); + expect(resolveSelectedProviderEnvironmentId([], null, primaryId)).toBeNull(); + }); +}); + +describe("provider environment access", () => { + it("allows connected environments with config and operate access", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + operateAccess: "granted", + }), + ).toEqual({ kind: "editable" }); + }); + + it("waits for config before exposing controls", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: false, + operateAccess: "granted", + }), + ).toEqual({ kind: "loading", reason: "config" }); + }); + + it("waits for unresolved operate access instead of assuming it is editable", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + operateAccess: "pending", + }), + ).toEqual({ kind: "loading", reason: "permissions" }); + }); + + it("represents known missing operate access as read only", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + operateAccess: "denied", + }), + ).toEqual({ kind: "read-only" }); + }); + + it.each(["available", "offline", "connecting", "reconnecting"] as const)( + "keeps %s environments unavailable", + (connectionPhase) => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase, + hasServerConfig: true, + operateAccess: "granted", + }), + ).toEqual({ kind: "unavailable" }); + }, + ); + + it("separates connection errors from other unavailable states", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "error", + hasServerConfig: true, + operateAccess: "granted", + }), + ).toEqual({ kind: "error" }); + }); +}); + +describe("primary operate access", () => { + const authenticated = { + authenticated: true as const, + scopes: [AuthOrchestrationOperateScope], + }; + + it("keeps cached session data authoritative while SWR revalidates", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: authenticated, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + }); + + it("reports pending only before any session has resolved", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: true, + hasError: false, + }), + ).toBe("pending"); + }); + + it("treats a failed session fetch as a transport problem, not a denial", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: false, + hasError: true, + }), + ).toBe("granted"); + }); + + it("denies unauthenticated sessions and sessions without the operate scope", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: { authenticated: false }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: { authenticated: true, scopes: ["orchestration:read"] }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + }); + + it("grants desktop bridge and remote environments without blocking on the primary session", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: true, + session: null, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: false, + hasDesktopBridge: false, + session: null, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + }); +}); + +describe("remote operate access", () => { + it("derives access from the environment session's granted scopes", () => { + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, + isPending: false, + hasError: false, + }), + ).toBe("granted"); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: ["orchestration:read"] }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: false }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + }); + + it("reports pending before the first session resolve, then keeps cached data", () => { + expect(resolveRemoteOperateAccess({ session: null, isPending: true, hasError: false })).toBe( + "pending", + ); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + }); + + it("stays optimistic when the session fetch fails or an older server omits scopes", () => { + // Transport failures and pre-scope-reporting servers are not permission + // decisions; the environment RPC layer still rejects unauthorized writes. + expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe( + "granted", + ); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true }, + isPending: false, + hasError: false, + }), + ).toBe("granted"); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts new file mode 100644 index 000000000000..1c7dac391f6a --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -0,0 +1,160 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { + AuthOrchestrationOperateScope, + type AuthSessionState, + type EnvironmentId, +} from "@t3tools/contracts"; + +export interface ProviderEnvironmentOptionLike { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export function buildProviderEnvironmentOptions( + environments: ReadonlyArray, + primaryEnvironmentId: EnvironmentId | null, +): ReadonlyArray { + return environments.toSorted((left, right) => { + const leftIsPrimary = left.environmentId === primaryEnvironmentId; + const rightIsPrimary = right.environmentId === primaryEnvironmentId; + if (leftIsPrimary !== rightIsPrimary) { + return leftIsPrimary ? -1 : 1; + } + return ( + left.label.localeCompare(right.label) || + String(left.environmentId).localeCompare(String(right.environmentId)) + ); + }); +} + +export function resolveSelectedProviderEnvironmentId( + environments: ReadonlyArray, + selectedEnvironmentId: EnvironmentId | null, + primaryEnvironmentId: EnvironmentId | null, +): EnvironmentId | null { + if ( + selectedEnvironmentId !== null && + environments.some((environment) => environment.environmentId === selectedEnvironmentId) + ) { + return selectedEnvironmentId; + } + if ( + primaryEnvironmentId !== null && + environments.some((environment) => environment.environmentId === primaryEnvironmentId) + ) { + return primaryEnvironmentId; + } + return environments[0]?.environmentId ?? null; +} + +export type ProviderEnvironmentAccess = + | { readonly kind: "editable" } + /** `reason` distinguishes waiting on the device from waiting on permissions. */ + | { readonly kind: "loading"; readonly reason: "config" | "permissions" } + | { readonly kind: "read-only" } + | { readonly kind: "unavailable" } + | { readonly kind: "error" }; + +/** + * Whether the session may change provider configuration on an environment. + * `pending` means the answer is still unknown, which must not be presented as + * editable: rendering controls we already know might be rejected only turns a + * permission problem into a failed write. + */ +export type ProviderOperateAccess = "granted" | "denied" | "pending"; + +/** + * Resolve operate access from an environment's `/api/auth/session` answer. + * + * Cached session data wins over an in-flight revalidation. The session atoms + * are SWR-backed, so they report `isPending` on every background refresh; + * treating that as unknown would flip a working panel back to loading and + * discard in-progress edits. + * + * `missingScopesAccess` decides the case where the session resolved but did + * not report scopes: the primary serves the web app itself so its server + * always reports them (absence means denial), while a remote device may run an + * older server version that predates scope reporting, where denial would lock + * out a legitimate session. The environment RPC layer stays authoritative + * either way. + */ +function resolveSessionOperateAccess(input: { + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; + readonly missingScopesAccess: "granted" | "denied"; +}): ProviderOperateAccess { + if (input.session === null) { + if (input.isPending) { + return "pending"; + } + // A failed session fetch is a transport problem, not a permission + // decision — locking the panel read-only would misreport it. Stay + // optimistic; the environment RPC layer still rejects unauthorized writes. + return input.hasError ? "granted" : "denied"; + } + if (!input.session.authenticated) { + return "denied"; + } + if (input.session.scopes === undefined) { + return input.missingScopesAccess; + } + return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied"; +} + +/** Operate access for the primary environment's own browser session. */ +export function resolvePrimaryOperateAccess(input: { + readonly isPrimary: boolean; + readonly hasDesktopBridge: boolean; + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; +}): ProviderOperateAccess { + if (!input.isPrimary || input.hasDesktopBridge) { + return "granted"; + } + return resolveSessionOperateAccess({ + session: input.session, + isPending: input.isPending, + hasError: input.hasError, + missingScopesAccess: "denied", + }); +} + +/** + * Operate access for a non-primary environment, derived from the scopes its + * `/api/auth/session` endpoint reports for this client's credential. + */ +export function resolveRemoteOperateAccess(input: { + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; +}): ProviderOperateAccess { + return resolveSessionOperateAccess({ + ...input, + missingScopesAccess: "granted", + }); +} + +export function classifyProviderEnvironmentAccess(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly hasServerConfig: boolean; + readonly operateAccess: ProviderOperateAccess; +}): ProviderEnvironmentAccess { + if (input.connectionPhase === "error") { + return { kind: "error" }; + } + if (input.connectionPhase !== "connected") { + return { kind: "unavailable" }; + } + if (!input.hasServerConfig) { + return { kind: "loading", reason: "config" }; + } + if (input.operateAccess === "pending") { + return { kind: "loading", reason: "permissions" }; + } + if (input.operateAccess === "denied") { + return { kind: "read-only" }; + } + return { kind: "editable" }; +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx new file mode 100644 index 000000000000..c06826d450d4 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -0,0 +1,902 @@ +import { useAtomValue } from "@effect/atom-react"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + defaultInstanceIdForDriver, + type EnvironmentId, + PROVIDER_DISPLAY_NAMES, + ProviderDriverKind, + type ProviderInstanceConfig, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + getBackgroundActivityPresetSettings, + resolveServerBackgroundActivitySettings, +} from "@t3tools/shared/backgroundActivitySettings"; +import * as Arr from "effect/Array"; +import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; +import * as Result from "effect/Result"; +import { + CloudIcon, + LaptopIcon, + LoaderIcon, + MonitorIcon, + PlusIcon, + RefreshCwIcon, + TerminalIcon, +} from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; +import { isElectron } from "../../env"; +import { usePrimarySessionState } from "../../environments/primary"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { resolveAppModelSelectionState } from "../../modelSelection"; +import { + useEnvironments, + usePrimaryEnvironmentId, + type EnvironmentPresentation, +} from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useEnvironmentSessionState } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { getRelativeTimeState } from "../../timestampFormat"; +import { + ConnectionStatusDot, + connectionPhaseDotClassName, + connectionPhasePingClassName, +} from "../ConnectionStatusDot"; +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + hasOneClickUpdateProviderCandidate, + isProviderUpdateActive, + type ProviderUpdateCandidate, +} from "../ProviderUpdateLaunchNotification.logic"; +import { Button } from "../ui/button"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; +import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; +import { searchableSetting } from "./settingsSearch"; +import { + backgroundActivityOverrideSettings, + buildProviderInstanceUpdatePatch, + durationToSeconds, + normalizeIntervalSeconds, + PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, +} from "./SettingsPanels.logic"; +import { + PolicyTooltip, + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; +import { + buildProviderEnvironmentOptions, + classifyProviderEnvironmentAccess, + type ProviderEnvironmentAccess, + type ProviderOperateAccess, + resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, + resolveSelectedProviderEnvironmentId, +} from "./ProviderSettingsPanel.logic"; + +function withoutProviderInstanceKey( + record: Readonly> | undefined, + key: ProviderInstanceId, +): Record { + const next = { ...record } as Record; + delete next[key]; + return next; +} + +function withoutProviderInstanceFavorites( + favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, + instanceId: ProviderInstanceId, +) { + return favorites.filter((favorite) => favorite.provider !== instanceId); +} + +const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ + provider: definition.value, +})); + +function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { + useRelativeTimeTick(); + const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); + + if (lastCheckedRelative.status === "missing") { + return null; + } + + if (lastCheckedRelative.status === "invalid") { + return Checked unavailable; + } + + return ( + + {lastCheckedRelative.suffix ? ( + <> + Checked {lastCheckedRelative.value}{" "} + {lastCheckedRelative.suffix} + + ) : ( + <>Checked {lastCheckedRelative.value} + )} + + ); +} + +function providerEnvironmentIcon(environment: EnvironmentPresentation) { + if (environment.entry.target._tag === "PrimaryConnectionTarget") return MonitorIcon; + if (environment.entry.target._tag === "RelayConnectionTarget") return CloudIcon; + if (environment.entry.target._tag === "SshConnectionTarget") return TerminalIcon; + if (isDesktopLocalConnectionTarget(environment.entry.target)) return LaptopIcon; + return CloudIcon; +} + +function providerEnvironmentDetail(environment: EnvironmentPresentation): string { + if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device"; + if (environment.relayManaged) return "T3 Connect"; + if (environment.entry.target._tag === "SshConnectionTarget") return "SSH"; + if (isDesktopLocalConnectionTarget(environment.entry.target)) return "Local device"; + return environment.displayUrl ?? "Remote device"; +} + +function EnvironmentUnavailableRow({ + environment, + access, +}: { + readonly environment: EnvironmentPresentation; + readonly access: Exclude; +}) { + const isLoading = access.kind === "loading"; + const title = isLoading + ? "Loading provider settings" + : access.kind === "error" + ? "Could not connect to this device" + : "Provider settings are unavailable"; + const description = isLoading + ? access.reason === "permissions" + ? "Checking what this session is allowed to change." + : `Waiting for ${environment.label}'s configuration.` + : connectionStatusText(environment.connection); + // No spinner: this state can persist indefinitely for a wedged device, and a + // continuously repainting animation would run the whole time. + return ( + + + + ); +} + +export function ProviderSettingsPanel() { + const { environments, isReady } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const options = useMemo( + () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), + [environments, primaryEnvironmentId], + ); + // Raw user intent; the effective selection is re-derived every render so a + // device that drops out of the catalog falls back without erasing the pick — + // if it reappears (e.g. after a reconnect) the selection is restored. + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( + primaryEnvironmentId, + ); + const effectiveEnvironmentId = resolveSelectedProviderEnvironmentId( + options, + selectedEnvironmentId, + primaryEnvironmentId, + ); + const selectedEnvironment = + options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; + const onlyPrimaryDevice = + options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; + + return ( + + {!onlyPrimaryDevice ? ( + + {options.length === 0 ? ( + // The catalog hydrates asynchronously, so an empty list before it is + // ready means "not loaded yet", not "nothing is connected". + + ) : ( +
+ {options.map((environment) => { + const Icon = providerEnvironmentIcon(environment); + const selected = environment.environmentId === effectiveEnvironmentId; + const statusText = connectionStatusText(environment.connection); + return ( + + ); + })} +
+ )} +
+ ) : null} + + {selectedEnvironment ? ( + + ) : null} +
+ ); +} + +function SelectedEnvironmentProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + if (isPrimary) { + // The desktop app owns its primary server outright; a browser session + // checks the scopes its cookie session was granted. + if (isElectron) { + return ; + } + return ; + } + return ; +} + +function PrimarySessionGatedProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const primarySessionState = usePrimarySessionState(); + const operateAccess = resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: primarySessionState.data, + isPending: primarySessionState.isPending, + hasError: primarySessionState.error !== null, + }); + return ; +} + +function RemoteSessionGatedProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const sessionState = useEnvironmentSessionState(environment.environmentId); + const operateAccess = resolveRemoteOperateAccess({ + session: sessionState.data, + isPending: sessionState.isPending, + hasError: sessionState.hasError, + }); + return ; +} + +function AccessGatedProviderSettings({ + environment, + operateAccess, +}: { + readonly environment: EnvironmentPresentation; + readonly operateAccess: ProviderOperateAccess; +}) { + const access = classifyProviderEnvironmentAccess({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + operateAccess, + }); + if (access.kind !== "editable" && access.kind !== "read-only") { + return ; + } + return ( + + ); +} + +export function EnvironmentProviderSettings({ + environmentId, + environmentLabel, + readOnly = false, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + /** + * Render the full provider layout, greyed out and inert, when this session's + * credential lacks `orchestration:operate` on the environment. Showing the + * real configuration keeps the view honest; disabling interaction keeps + * every one of its writes from being offered and then rejected. + */ + readonly readOnly?: boolean; +}) { + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const serverProviders = + useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; + const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { + reportFailure: false, + }); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); + const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< + ReadonlySet + >(() => new Set()); + const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); + const refreshingRef = useRef(false); + const updatingDriversRef = useRef>(new Set()); + + const providerUpdateCandidates = useMemo( + () => collectProviderUpdateCandidates(serverProviders), + [serverProviders], + ); + const providerUpdateCandidateByInstanceId = useMemo( + () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), + [providerUpdateCandidates], + ); + const visibleProviderSettings = PROVIDER_SETTINGS.filter( + (providerSettings) => + providerSettings.provider !== "cursor" || + serverProviders.some( + (provider) => + provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), + ), + ); + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const textGenInstanceId = textGenerationModelSelection.instanceId; + const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); + const providerHealthPreset = getBackgroundActivityPresetSettings( + resolvedBackgroundActivity.profile, + ).providerHealthRefreshInterval; + const providerHealthRefreshIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.providerHealthRefreshInterval, + ); + const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); + const lastCheckedAt = + serverProviders.length > 0 + ? serverProviders.reduce( + (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), + serverProviders[0]!.checkedAt, + ) + : null; + + const refreshProviders = useCallback(() => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setIsRefreshingProviders(true); + void (async () => { + const result = await refreshServerProviders({ + environmentId, + input: {}, + }); + refreshingRef.current = false; + setIsRefreshingProviders(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + console.warn("Failed to refresh providers", { + operation: "refresh-providers", + environmentId, + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); + } + })(); + }, [environmentId, refreshServerProviders]); + + const runProviderUpdate = useCallback( + async (candidate: ProviderUpdateCandidate) => { + // Ref-based re-entry guard, mirroring refreshProviders: a state updater + // may run after this function returns, so it cannot gate the dispatch. + if (updatingDriversRef.current.has(candidate.driver)) { + return; + } + updatingDriversRef.current.add(candidate.driver); + setUpdatingProviderDrivers((previous) => new Set(previous).add(candidate.driver)); + + const result = await updateProvider({ + environmentId, + input: { + provider: candidate.driver, + instanceId: candidate.instanceId, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, + description: + error instanceof Error + ? error.message + : "The provider update command could not be started.", + }), + ); + } + updatingDriversRef.current.delete(candidate.driver); + setUpdatingProviderDrivers((previous) => { + if (!previous.has(candidate.driver)) { + return previous; + } + const next = new Set(previous); + next.delete(candidate.driver); + return next; + }); + }, + [environmentId, updateProvider], + ); + + interface InstanceRow { + readonly instanceId: ProviderInstanceId; + readonly instance: ProviderInstanceConfig; + readonly driver: ProviderDriverKind; + readonly isDefault: boolean; + readonly isDirty?: boolean; + } + + const instancesByDriver = new Map< + ProviderDriverKind, + Array<[ProviderInstanceId, ProviderInstanceConfig]> + >(); + for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { + const driver = instance.driver; + const list = instancesByDriver.get(driver) ?? []; + list.push([rawId as ProviderInstanceId, instance]); + instancesByDriver.set(driver, list); + } + + const defaultSlotIdsBySource = new Set( + visibleProviderSettings.map((providerSettings) => + String(defaultInstanceIdForDriver(providerSettings.provider)), + ), + ); + + const rows: InstanceRow[] = []; + const visibleDriverKinds = new Set( + visibleProviderSettings.map((providerSettings) => providerSettings.provider), + ); + + for (const providerSettings of visibleProviderSettings) { + type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; + const legacyProviders = settings.providers as Record; + const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< + string, + LegacyProviderSettings + >; + const driver = providerSettings.provider; + const defaultInstanceId = defaultInstanceIdForDriver(driver); + const explicitInstance = settings.providerInstances?.[defaultInstanceId]; + // A remote device may run a server version whose settings predate this + // driver, so the legacy mirror can be absent. Without either an explicit + // instance or a legacy blob there is nothing to render for the slot. + const legacyConfig = legacyProviders[providerSettings.provider]; + const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]; + const effectiveInstance: ProviderInstanceConfig | undefined = + explicitInstance ?? + (legacyConfig !== undefined + ? ({ + driver, + enabled: legacyConfig.enabled, + config: legacyConfig, + } satisfies ProviderInstanceConfig) + : undefined); + // Only the default slot depends on the legacy blob; custom instances for + // the driver must still render even when the slot has nothing to show. + if (effectiveInstance !== undefined) { + const isDirty = + explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); + rows.push({ + instanceId: defaultInstanceId, + instance: effectiveInstance, + driver, + isDefault: true, + isDirty, + }); + } + for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { + if (id === defaultInstanceId) continue; + rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); + } + } + for (const [driver, list] of instancesByDriver) { + if (visibleDriverKinds.has(driver)) continue; + for (const [id, instance] of list) { + rows.push({ + instanceId: id, + instance, + driver: instance.driver, + isDefault: defaultSlotIdsBySource.has(String(id)), + }); + } + } + + const updateProviderInstance = ( + row: InstanceRow, + next: ProviderInstanceConfig, + options?: { + readonly textGenerationModelSelection?: Parameters< + typeof buildProviderInstanceUpdatePatch + >[0]["textGenerationModelSelection"]; + }, + ) => { + updateSettings( + buildProviderInstanceUpdatePatch({ + settings, + instanceId: row.instanceId, + instance: next, + driver: row.driver, + isDefault: row.isDefault, + textGenerationModelSelection: options?.textGenerationModelSelection, + }), + ); + }; + + const deleteProviderInstance = (id: ProviderInstanceId) => { + updateSettings({ + providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), + }); + }; + + const updateProviderModelPreferences = ( + instanceId: ProviderInstanceId, + next: { + readonly hiddenModels: ReadonlyArray; + readonly modelOrder: ReadonlyArray; + }, + ) => { + const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; + const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; + const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); + updateSettings({ + providerModelPreferences: + hiddenModels.length === 0 && modelOrder.length === 0 + ? rest + : { + ...rest, + [instanceId]: { + hiddenModels, + modelOrder, + }, + }, + }); + }; + + const updateProviderFavoriteModels = ( + instanceId: ProviderInstanceId, + nextFavoriteModels: ReadonlyArray, + ) => { + const favoriteModels = [ + ...new Set( + Arr.filterMap(nextFavoriteModels, (slug) => { + const trimmedSlug = slug.trim(); + return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; + }), + ), + ]; + updateSettings({ + favorites: [ + ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), + ...favoriteModels.map((model) => ({ provider: instanceId, model })), + ], + }); + }; + + const resetDefaultInstance = (driverKind: ProviderDriverKind) => { + type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; + const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< + string, + LegacyProviderSettings | undefined + >; + const defaultInstanceId = defaultInstanceIdForDriver(driverKind); + const defaultLegacyProvider = defaultLegacyProviders[driverKind]; + if (defaultLegacyProvider === undefined) return; + updateSettings({ + providers: { + ...settings.providers, + [driverKind]: defaultLegacyProvider, + } as typeof settings.providers, + providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), + }); + }; + + return ( + <> + + + {!readOnly ? ( + <> + + setIsAddInstanceDialogOpen(true)} + aria-label="Add provider instance" + > + + + } + /> + Add provider instance + + + void refreshProviders()} + aria-label="Refresh provider status" + > + {isRefreshingProviders ? ( + + ) : ( + + )} + + } + /> + Refresh provider status + + + ) : null} +
+ } + > + {readOnly ? ( + + ) : null} +
+ + Health check interval + + This interval is configured here, then the shared Background activity policy + decides whether provider probes may run when the timer fires. Custom intervals + appear as Advanced in General settings. + + + } + description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." + resetAction={ + providerHealthRefreshIntervalSeconds !== + defaultProviderHealthRefreshIntervalSeconds ? ( + + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: undefined, + }, + ), + ) + } + /> + ) : null + } + control={ +
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+ } + /> + + {rows.map((row) => { + const driverOption = getDriverOption(row.driver); + const liveProvider = serverProviders.find( + (candidate) => candidate.instanceId === row.instanceId, + ); + const updateCandidate = liveProvider + ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) + : undefined; + const isDriverUpdateRunning = + updateCandidate !== undefined && + (updatingProviderDrivers.has(updateCandidate.driver) || + serverProviders.some( + (provider) => + provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + )); + const showInlineUpdateButton = + updateCandidate !== undefined && + hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); + const canRunInlineUpdate = + updateCandidate !== undefined && + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && + !updatingProviderDrivers.has(updateCandidate.driver); + const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { + hiddenModels: [], + modelOrder: [], + }; + const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => + favorite.provider === row.instanceId + ? Result.succeed(favorite.model) + : Result.failVoid, + ); + const resetLabel = driverOption?.label ?? String(row.driver); + const headerAction = + row.isDefault && row.isDirty ? ( + resetDefaultInstance(row.driver)} + /> + ) : null; + return ( + + setOpenInstanceDetails((existing) => ({ + ...existing, + [row.instanceId]: open, + })) + } + onUpdate={(next) => { + const wasEnabled = row.instance.enabled ?? true; + const isDisabling = next.enabled === false && wasEnabled; + const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; + if (shouldClearTextGen) { + updateProviderInstance(row, next, { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }); + } else { + updateProviderInstance(row, next); + } + }} + onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} + headerAction={headerAction} + hiddenModels={modelPreferences.hiddenModels} + favoriteModels={favoriteModels} + modelOrder={modelPreferences.modelOrder} + onHiddenModelsChange={(hiddenModels) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + hiddenModels, + }) + } + onFavoriteModelsChange={(favoriteModels) => + updateProviderFavoriteModels(row.instanceId, favoriteModels) + } + onModelOrderChange={(modelOrder) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + modelOrder, + }) + } + onRunUpdate={ + showInlineUpdateButton && updateCandidate + ? () => { + if (!canRunInlineUpdate) { + return; + } + void runProviderUpdate(updateCandidate); + } + : undefined + } + isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} + /> + ); + })} +
+ + + {isAddInstanceDialogOpen ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 1d4baefa53a5..efb5e12ff33d 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -10,10 +10,12 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { + getBackgroundActivityBaseProfile, normalizeBackgroundActivitySettings, normalizeServerBackgroundActivitySettings, resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; +import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { @@ -190,3 +192,58 @@ export function buildProviderInstanceUpdatePatch(input: { : {}), }; } + +// ── Background-activity interval helpers ───────────────────────────── +// Shared by the General panel's interval rows and the Providers panel's +// health-check row. + +export const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30; + +type BackgroundActivityOverridePatch = Partial<{ + [K in keyof BackgroundActivitySettings["overrides"]]: + | BackgroundActivitySettings["overrides"][K] + | undefined; +}>; + +export function durationToSeconds(duration: Duration.Duration): number { + return Math.round(Duration.toMillis(duration) / 1_000); +} + +export function normalizeIntervalSeconds(value: number | null, minimum = 0): number { + if (value === null || !Number.isFinite(value)) { + return minimum; + } + return Math.max(minimum, Math.round(value)); +} + +export function backgroundActivityOverrideSettings( + current: BackgroundActivitySettings, + resolved: ReturnType, + overrides: BackgroundActivityOverridePatch, +) { + const nextOverrides: BackgroundActivityOverridePatch = { + automaticGitFetchInterval: resolved.automaticGitFetchInterval, + providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, + hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, + hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, + idleClientTtl: resolved.idleClientTtl, + pauseWhenHostLocked: resolved.pauseWhenHostLocked, + pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, + pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, + pauseWhenOnBattery: resolved.pauseWhenOnBattery, + ...overrides, + }; + for (const [key, value] of Object.entries(nextOverrides)) { + if (value === undefined) { + delete nextOverrides[key as keyof typeof nextOverrides]; + } + } + return { + backgroundActivity: { + schemaVersion: 1 as const, + profile: "custom" as const, + baseProfile: getBackgroundActivityBaseProfile(current), + overrides: nextOverrides as BackgroundActivitySettings["overrides"], + }, + }; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9a5fe3195685..00d9573a1acd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,30 +1,16 @@ -import { - ArchiveIcon, - ArchiveX, - InfoIcon, - LoaderIcon, - PlusIcon, - RefreshCwIcon, - SettingsIcon, -} from "lucide-react"; +import { ArchiveIcon, ArchiveX, LoaderIcon, SettingsIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { - defaultInstanceIdForDriver, type BackgroundActivityProfile, - type BackgroundActivitySettings, type DesktopUpdateChannel, - PROVIDER_DISPLAY_NAMES, ProviderDriverKind, - type ProviderInstanceConfig, - type ProviderInstanceId, type ScopedThreadRef, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, settlePromise, @@ -45,16 +31,10 @@ import { MIN_PROMPT_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; -import { - getBackgroundActivityBaseProfile, - getBackgroundActivityPresetSettings, - resolveServerBackgroundActivitySettings, -} from "@t3tools/shared/backgroundActivitySettings"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; -import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { @@ -88,15 +68,10 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { - primaryServerObservabilityAtom, - primaryServerProvidersAtom, - serverEnvironment, -} from "../../state/server"; -import { usePrimaryEnvironment } from "../../state/environments"; +import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Dialog, @@ -131,20 +106,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; -import { - canOneClickUpdateProviderCandidate, - collectProviderUpdateCandidates, - hasOneClickUpdateProviderCandidate, - isProviderUpdateActive, - type ProviderUpdateCandidate, -} from "../ProviderUpdateLaunchNotification.logic"; -import { ProviderInstanceCard } from "./ProviderInstanceCard"; -import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { + backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, - buildProviderInstanceUpdatePatch, + durationToSeconds, formatDiagnosticsDescription, + normalizeIntervalSeconds, + PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, @@ -153,16 +121,15 @@ import { resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; import { + PolicyTooltip, SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection, - useRelativeTimeTick, useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; -import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ { @@ -198,11 +165,6 @@ const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record; const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record = { ...BACKGROUND_ACTIVITY_PROFILE_LABELS, @@ -219,7 +181,6 @@ const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record, - overrides: BackgroundActivityOverridePatch, -) { - const nextOverrides: BackgroundActivityOverridePatch = { - automaticGitFetchInterval: resolved.automaticGitFetchInterval, - providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, - hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, - hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, - idleClientTtl: resolved.idleClientTtl, - pauseWhenHostLocked: resolved.pauseWhenHostLocked, - pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, - pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, - pauseWhenOnBattery: resolved.pauseWhenOnBattery, - ...overrides, - }; - for (const [key, value] of Object.entries(nextOverrides)) { - if (value === undefined) { - delete nextOverrides[key as keyof typeof nextOverrides]; - } - } - return { - backgroundActivity: { - schemaVersion: 1 as const, - profile: "custom" as const, - baseProfile: getBackgroundActivityBaseProfile(current), - overrides: nextOverrides as BackgroundActivitySettings["overrides"], - }, - }; -} - -function PolicyTooltip({ children }: { readonly children: string }) { - return ( - - - - - } - /> - - {children} - - - ); -} - -function withoutProviderInstanceKey( - record: Readonly> | undefined, - key: ProviderInstanceId, -): Record { - const next = { ...record } as Record; - delete next[key]; - return next; -} - -function withoutProviderInstanceFavorites( - favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, - instanceId: ProviderInstanceId, -) { - return favorites.filter((favorite) => favorite.provider !== instanceId); -} - -const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ - provider: definition.value, -})); - -function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { - useRelativeTimeTick(); - const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); - - if (lastCheckedRelative.status === "missing") { - return null; - } - - if (lastCheckedRelative.status === "invalid") { - return Checked unavailable; - } - - return ( - - {lastCheckedRelative.suffix ? ( - <> - Checked {lastCheckedRelative.value}{" "} - {lastCheckedRelative.suffix} - - ) : ( - <>Checked {lastCheckedRelative.value} - )} - - ); -} - function AboutVersionTitle() { return ( @@ -623,9 +474,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), - ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar - ? ["Auto-open task panel"] - : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -655,7 +503,6 @@ export function useSettingsRestore(onRestored?: () => void) { [ isTextGenerationModelDirty, isBackgroundActivityDirty, - settings.autoOpenPlanSidebar, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -701,7 +548,6 @@ export function useSettingsRestore(onRestored?: () => void) { glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -1897,32 +1743,6 @@ export function GeneralSettingsPanel() { } /> - - updateSettings({ - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, - }) - } - /> - ) : null - } - control={ - - updateSettings({ autoOpenPlanSidebar: Boolean(checked) }) - } - aria-label="Open the task panel automatically" - /> - } - /> - - >(() => new Set()); - const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); - const refreshingRef = useRef(false); - - const providerUpdateCandidates = useMemo( - () => collectProviderUpdateCandidates(serverProviders), - [serverProviders], - ); - const providerUpdateCandidateByInstanceId = useMemo( - () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), - [providerUpdateCandidates], - ); - const visibleProviderSettings = PROVIDER_SETTINGS.filter( - (providerSettings) => - providerSettings.provider !== "cursor" || - serverProviders.some( - (provider) => - provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), - ), - ); - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); - const textGenInstanceId = textGenerationModelSelection.instanceId; - const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); - const providerHealthPreset = getBackgroundActivityPresetSettings( - resolvedBackgroundActivity.profile, - ).providerHealthRefreshInterval; - const providerHealthRefreshIntervalSeconds = durationToSeconds( - resolvedBackgroundActivity.providerHealthRefreshInterval, - ); - const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); - const lastCheckedAt = - serverProviders.length > 0 - ? serverProviders.reduce( - (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), - serverProviders[0]!.checkedAt, - ) - : null; - - const refreshProviders = useCallback(() => { - if (refreshingRef.current) return; - refreshingRef.current = true; - setIsRefreshingProviders(true); - if (!primaryEnvironment) { - refreshingRef.current = false; - setIsRefreshingProviders(false); - return; - } - void (async () => { - const result = await refreshServerProviders({ - environmentId: primaryEnvironment.environmentId, - input: {}, - }); - refreshingRef.current = false; - setIsRefreshingProviders(false); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - console.warn("Failed to refresh providers", { - operation: "refresh-providers", - environmentId: primaryEnvironment.environmentId, - ...safeErrorLogAttributes(squashAtomCommandFailure(result)), - }); - } - })(); - }, [primaryEnvironment, refreshServerProviders]); - - const runProviderUpdate = useCallback( - async (candidate: ProviderUpdateCandidate) => { - if (!primaryEnvironment) return; - let started = false; - setUpdatingProviderDrivers((previous) => { - if (previous.has(candidate.driver)) { - return previous; - } - started = true; - const next = new Set(previous); - next.add(candidate.driver); - return next; - }); - if (!started) { - return; - } - - const result = await updateProvider({ - environmentId: primaryEnvironment.environmentId, - input: { - provider: candidate.driver, - instanceId: candidate.instanceId, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, - description: - error instanceof Error - ? error.message - : "The provider update command could not be started.", - }), - ); - } - setUpdatingProviderDrivers((previous) => { - if (!previous.has(candidate.driver)) { - return previous; - } - const next = new Set(previous); - next.delete(candidate.driver); - return next; - }); - }, - [primaryEnvironment, updateProvider], - ); - - interface InstanceRow { - readonly instanceId: ProviderInstanceId; - readonly instance: ProviderInstanceConfig; - readonly driver: ProviderDriverKind; - readonly isDefault: boolean; - readonly isDirty?: boolean; - } - - const instancesByDriver = new Map< - ProviderDriverKind, - Array<[ProviderInstanceId, ProviderInstanceConfig]> - >(); - for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { - const driver = instance.driver; - const list = instancesByDriver.get(driver) ?? []; - list.push([rawId as ProviderInstanceId, instance]); - instancesByDriver.set(driver, list); - } - - const defaultSlotIdsBySource = new Set( - visibleProviderSettings.map((providerSettings) => - String(defaultInstanceIdForDriver(providerSettings.provider)), - ), - ); - - const rows: InstanceRow[] = []; - const visibleDriverKinds = new Set( - visibleProviderSettings.map((providerSettings) => providerSettings.provider), - ); - - for (const providerSettings of visibleProviderSettings) { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const legacyProviders = settings.providers as Record; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings - >; - const driver = providerSettings.provider; - const defaultInstanceId = defaultInstanceIdForDriver(driver); - const explicitInstance = settings.providerInstances?.[defaultInstanceId]; - const legacyConfig = legacyProviders[providerSettings.provider]!; - const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; - const effectiveInstance: ProviderInstanceConfig = - explicitInstance ?? - ({ - driver, - enabled: legacyConfig.enabled, - config: legacyConfig, - } satisfies ProviderInstanceConfig); - const isDirty = - explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); - rows.push({ - instanceId: defaultInstanceId, - instance: effectiveInstance, - driver, - isDefault: true, - isDirty, - }); - for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { - if (id === defaultInstanceId) continue; - rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); - } - } - for (const [driver, list] of instancesByDriver) { - if (visibleDriverKinds.has(driver)) continue; - for (const [id, instance] of list) { - rows.push({ - instanceId: id, - instance, - driver: instance.driver, - isDefault: defaultSlotIdsBySource.has(String(id)), - }); - } - } - - const updateProviderInstance = ( - row: InstanceRow, - next: ProviderInstanceConfig, - options?: { - readonly textGenerationModelSelection?: Parameters< - typeof buildProviderInstanceUpdatePatch - >[0]["textGenerationModelSelection"]; - }, - ) => { - updateSettings( - buildProviderInstanceUpdatePatch({ - settings, - instanceId: row.instanceId, - instance: next, - driver: row.driver, - isDefault: row.isDefault, - textGenerationModelSelection: options?.textGenerationModelSelection, - }), - ); - }; - - const deleteProviderInstance = (id: ProviderInstanceId) => { - updateSettings({ - providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), - providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id), - favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id), - }); - }; - - const updateProviderModelPreferences = ( - instanceId: ProviderInstanceId, - next: { - readonly hiddenModels: ReadonlyArray; - readonly modelOrder: ReadonlyArray; - }, - ) => { - const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; - const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; - const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); - updateSettings({ - providerModelPreferences: - hiddenModels.length === 0 && modelOrder.length === 0 - ? rest - : { - ...rest, - [instanceId]: { - hiddenModels, - modelOrder, - }, - }, - }); - }; - - const updateProviderFavoriteModels = ( - instanceId: ProviderInstanceId, - nextFavoriteModels: ReadonlyArray, - ) => { - const favoriteModels = [ - ...new Set( - Arr.filterMap(nextFavoriteModels, (slug) => { - const trimmedSlug = slug.trim(); - return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; - }), - ), - ]; - updateSettings({ - favorites: [ - ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), - ...favoriteModels.map((model) => ({ provider: instanceId, model })), - ], - }); - }; - - const resetDefaultInstance = (driverKind: ProviderDriverKind) => { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings | undefined - >; - const defaultInstanceId = defaultInstanceIdForDriver(driverKind); - const defaultLegacyProvider = defaultLegacyProviders[driverKind]; - if (defaultLegacyProvider === undefined) return; - updateSettings({ - providers: { - ...settings.providers, - [driverKind]: defaultLegacyProvider, - } as typeof settings.providers, - providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), - providerModelPreferences: withoutProviderInstanceKey( - settings.providerModelPreferences, - defaultInstanceId, - ), - favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId), - }); - }; - - return ( - - - - - setIsAddInstanceDialogOpen(true)} - aria-label="Add provider instance" - > - - - } - /> - Add provider instance - - - void refreshProviders()} - aria-label="Refresh provider status" - > - {isRefreshingProviders ? ( - - ) : ( - - )} - - } - /> - Refresh provider status - -
- } - > - - Health check interval - - This interval is configured here, then the shared Background activity policy decides - whether provider probes may run when the timer fires. Custom intervals appear as - Advanced in General settings. - - - } - description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." - resetAction={ - providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( - - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: undefined, - }, - ), - ) - } - /> - ) : null - } - control={ -
- - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, - ), - ) - } - > - - - - - - - seconds -
- } - /> - - {rows.map((row) => { - const driverOption = getDriverOption(row.driver); - const liveProvider = serverProviders.find( - (candidate) => candidate.instanceId === row.instanceId, - ); - const updateCandidate = liveProvider - ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) - : undefined; - const isDriverUpdateRunning = - updateCandidate !== undefined && - (updatingProviderDrivers.has(updateCandidate.driver) || - serverProviders.some( - (provider) => - provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), - )); - const showInlineUpdateButton = - updateCandidate !== undefined && - hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); - const canRunInlineUpdate = - updateCandidate !== undefined && - canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && - !updatingProviderDrivers.has(updateCandidate.driver); - const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { - hiddenModels: [], - modelOrder: [], - }; - const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => - favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, - ); - const resetLabel = driverOption?.label ?? String(row.driver); - const headerAction = - row.isDefault && row.isDirty ? ( - resetDefaultInstance(row.driver)} - /> - ) : null; - return ( - - setOpenInstanceDetails((existing) => ({ - ...existing, - [row.instanceId]: open, - })) - } - onUpdate={(next) => { - const wasEnabled = row.instance.enabled ?? true; - const isDisabling = next.enabled === false && wasEnabled; - const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; - if (shouldClearTextGen) { - updateProviderInstance(row, next, { - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }); - } else { - updateProviderInstance(row, next); - } - }} - onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} - headerAction={headerAction} - hiddenModels={modelPreferences.hiddenModels} - favoriteModels={favoriteModels} - modelOrder={modelPreferences.modelOrder} - onHiddenModelsChange={(hiddenModels) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - hiddenModels, - }) - } - onFavoriteModelsChange={(favoriteModels) => - updateProviderFavoriteModels(row.instanceId, favoriteModels) - } - onModelOrderChange={(modelOrder) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - modelOrder, - }) - } - onRunUpdate={ - showInlineUpdateButton && updateCandidate - ? () => { - if (!canRunInlineUpdate) { - return; - } - void runProviderUpdate(updateCandidate); - } - : undefined - } - isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} - /> - ); - })} - - - {isAddInstanceDialogOpen ? ( - - ) : null} - - ); -} - export function ArchivedThreadsPanel() { const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 84fb95a47417..0bb1a1aa6784 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -1,4 +1,4 @@ -import { Undo2Icon } from "lucide-react"; +import { InfoIcon, Undo2Icon } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { createContext, @@ -83,6 +83,28 @@ function useSettingsSearchTarget(id: string | undefined) return targetRef; } +/** Info affordance explaining how a setting interacts with the shared background policy. */ +export function PolicyTooltip({ children }: { readonly children: string }) { + return ( + + + + + } + /> + + {children} + + + ); +} + /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ export function useRelativeTimeTick(intervalMs = 1_000) { const [nowMs, setNowMs] = useState(() => Date.now()); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 1ba231a58350..3b3a8c220ac4 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -110,11 +110,6 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Provider update checks", to: "/settings/general", }, - { - id: "auto-open-task-panel", - title: "Auto-open task panel", - to: "/settings/general", - }, { id: "new-threads", title: "New threads", @@ -182,6 +177,11 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/beta", targetId: "sidebar-v2", }, + { + id: "restore-plan-mode", + title: "Restore plan mode (legacy)", + to: "/settings/beta", + }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 06a0e714a6ea..89120850f202 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -44,7 +44,7 @@ function SidebarUpdateReleaseNotesTooltip({
{tooltip}
-
+
{state.releaseNotes.map((releaseNote, index) => (
{index > 0 && } @@ -203,7 +203,9 @@ export function SidebarUpdatePill() { align="start" className={ state?.channel === "nightly" && state.releaseNotes.length > 0 - ? "max-w-none text-balance" + ? // pointer-events-auto overrides the positioner's pointer-events-none so the + // release notes stay open (and scrollable) when the cursor moves into them. + "pointer-events-auto max-w-none text-balance" : undefined } side="top" diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 623c3767d752..24c142380a64 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -625,7 +625,7 @@ const rpcRequestObserverLayer = Layer.succeed( Effect.sync(() => { nextObservedRpcRequestId += 1; const requestId = `${environmentId}:${nextObservedRpcRequestId}`; - trackRpcRequestSent(requestId, `${method} · ${environmentId}`); + trackRpcRequestSent(requestId, method, `${method} · ${environmentId}`); return Effect.sync(() => { acknowledgeRpcRequest(requestId); }); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 5da93e3f5c55..4ae476c1d11f 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -51,9 +51,12 @@ const StoredShellSnapshot = Schema.Struct({ const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); // v2 stores the snapshot sequence alongside the thread so a warm cache can // resume via `afterSequence` instead of re-downloading the full thread body. -// Older v1 entries (no sequence) fail to decode and are treated as a cold cache. +// v3 adds windowed (paginated) snapshots carrying `page` metadata. The bump +// exists for rollback safety: a pre-pagination client would decode a windowed +// v2 record, silently drop the unknown `page` field, and treat the partial +// thread as complete forever. Older entries fail to decode → cold cache. const StoredThreadSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(2), + schemaVersion: Schema.Literal(3), environmentId: EnvironmentId, threadId: ThreadId, snapshot: OrchestrationThreadDetailSnapshot, @@ -561,7 +564,7 @@ export const connectionStorageLayer = Layer.effectContext( saveThread: (environmentId, snapshot) => Effect.gen(function* () { const encoded = yield* encodeStoredThreadSnapshot({ - schemaVersion: 2, + schemaVersion: 3, environmentId, threadId: snapshot.thread.id, snapshot, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 54b3b5c0ce6f..3108ef2171bf 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -146,7 +146,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil compositor updates discrete frames instead of every vsync. */ --animate-status-pulse: status-pulse 2s infinite; --animate-status-ping: status-ping 2s infinite; - --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; --color-warning-foreground: var(--warning-foreground); --color-warning: var(--warning); --color-success-foreground: var(--success-foreground); @@ -223,21 +222,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil scale: 2; } } - @keyframes sidebar-working-text { - 0%, - 36% { - opacity: 1; - animation-timing-function: steps(10); - } - 50%, - 86% { - opacity: 0.75; - animation-timing-function: steps(10); - } - 100% { - opacity: 1; - } - } } @layer base { diff --git a/apps/web/src/planSidebarDismissal.ts b/apps/web/src/planSidebarDismissal.ts deleted file mode 100644 index b92cfaf899e6..000000000000 --- a/apps/web/src/planSidebarDismissal.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Tracks which turn's plan sidebar the user explicitly dismissed, per thread. - * - * Kept outside React state so a dismissal survives leaving and re-entering a - * thread (ChatView resets its per-thread refs on navigation). Dismissals are - * keyed by turn, so when a new turn produces fresh plan steps the sidebar - * still auto-opens. - */ -const dismissedTurnByThreadKey = new Map(); - -export function dismissPlanSidebarForTurn(threadKey: string, turnKey: string): void { - dismissedTurnByThreadKey.set(threadKey, turnKey); -} - -export function clearPlanSidebarDismissal(threadKey: string): void { - dismissedTurnByThreadKey.delete(threadKey); -} - -export function isPlanSidebarDismissedForTurn(threadKey: string, turnKey: string): boolean { - return dismissedTurnByThreadKey.get(threadKey) === turnKey; -} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index c7457cfd3040..69831242f2f4 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -102,6 +102,41 @@ describe("rightPanelStore", () => { }); }); + it("drops persisted plan surfaces and does not reopen an empty panel", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "plan", + surfaces: [{ id: "plan", kind: "plan" }], + }, + "env-1:thread-B": { + isOpen: true, + activeSurfaceId: "plan", + surfaces: [ + { id: "plan", kind: "plan" }, + { id: "diff", kind: "diff" }, + ], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: false, + activeSurfaceId: null, + surfaces: [], + }, + "env-1:thread-B": { + isOpen: true, + activeSurfaceId: "diff", + surfaces: [{ id: "diff", kind: "diff" }], + }, + }, + }); + }); + it("open sets the active panel for a thread", () => { useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); @@ -109,7 +144,7 @@ describe("rightPanelStore", () => { }); it("opening a different kind keeps both surfaces and activates the new one", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); expect( @@ -119,7 +154,7 @@ describe("rightPanelStore", () => { it("reopening an inactive singleton activates its existing surface", () => { useRightPanelStore.getState().open(refA, "diff"); - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().open(refA, "diff"); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ @@ -127,7 +162,7 @@ describe("rightPanelStore", () => { activeSurfaceId: "diff", surfaces: [ { id: "diff", kind: "diff" }, - { id: "plan", kind: "plan" }, + { id: "agents", kind: "agents" }, ], }); }); @@ -207,15 +242,15 @@ describe("rightPanelStore", () => { it("removes persisted file surfaces when their workspace no longer exists", () => { useRightPanelStore.getState().openFile(refA, "src/index.ts"); - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().openFile(refA, "README.md"); useRightPanelStore.getState().reconcileFileSurfaces(refA, false); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: true, - activeSurfaceId: "plan", - surfaces: [{ id: "plan", kind: "plan" }], + activeSurfaceId: "agents", + surfaces: [{ id: "agents", kind: "agents" }], }); useRightPanelStore.getState().openFile(refB, "conductor.json"); @@ -228,13 +263,13 @@ describe("rightPanelStore", () => { }); it("close hides the panel without clearing its selected surface", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().close(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: false, - activeSurfaceId: "plan", - surfaces: [{ id: "plan", kind: "plan" }], + activeSurfaceId: "agents", + surfaces: [{ id: "agents", kind: "agents" }], }); }); @@ -264,12 +299,12 @@ describe("rightPanelStore", () => { it("toggle to a different kind switches active", () => { useRightPanelStore.getState().toggle(refA, "preview"); - useRightPanelStore.getState().toggle(refA, "plan"); - expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); + useRightPanelStore.getState().toggle(refA, "agents"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("agents"); }); it("removeThread clears persisted state", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().removeThread(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index cccb7238ca8d..2e72c7b4e10a 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -5,7 +5,7 @@ * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at - * workspace paths, and diff/plan/files remain singleton surfaces. + * workspace paths, and diff/files remain singleton surfaces. */ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; @@ -15,7 +15,6 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; export const RIGHT_PANEL_KINDS = [ - "plan", "diff", "files", "file", @@ -45,11 +44,11 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } - | { id: "plan"; kind: "plan" } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; -const RIGHT_PANEL_STORAGE_VERSION = 8; +// v9 removed the "plan" surface kind (plans render inline in the transcript). +const RIGHT_PANEL_STORAGE_VERSION = 9; export interface ThreadRightPanelState { isOpen: boolean; @@ -99,8 +98,6 @@ const singletonSurface = ( return { id: "diff", kind }; case "files": return { id: "files", kind }; - case "plan": - return { id: "plan", kind }; case "agents": return { id: "agents", kind }; } @@ -181,6 +178,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): { threadState && typeof threadState === "object" ? threadState : null; const surfaces = Array.isArray(validThreadState?.surfaces) ? validThreadState.surfaces.flatMap((surface) => { + // Dropped surface kind: plans now render inline in the + // transcript (v9). + if ((surface as { kind?: string }).kind === "plan") return []; if (surface.kind === "file") { const revealLine = typeof surface.revealLine === "number" && @@ -229,15 +229,23 @@ export function migratePersistedRightPanelState(persistedState: unknown): { ]; }) : []; - const activeSurfaceId = surfaces.some( + const persistedActiveSurfaceId = surfaces.some( (surface) => surface.id === validThreadState?.activeSurfaceId, ) ? (validThreadState?.activeSurfaceId ?? null) : null; + // A migration that dropped every surface (e.g. plan-only panels + // in v9) must not reopen an empty panel. const isOpen = - typeof validThreadState?.isOpen === "boolean" + surfaces.length > 0 && + (typeof validThreadState?.isOpen === "boolean" ? validThreadState.isOpen - : activeSurfaceId !== null; + : persistedActiveSurfaceId !== null); + // An open panel needs an active surface: if migration dropped + // the persisted one (e.g. plan was active), fall back to the + // first survivor instead of rendering an open empty panel. + const activeSurfaceId = + persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); return [threadKey, { isOpen, surfaces, activeSurfaceId }]; }, ), diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx index a7a86c2b50b0..deab014722dc 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/settings.providers.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ProviderSettingsPanel } from "../components/settings/SettingsPanels"; +import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel"; function SettingsProvidersRoute() { return ; diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index 504c93e1f78d..e5b3144d2520 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -6,6 +6,7 @@ import { getSlowRpcAckRequests, resetRequestLatencyStateForTests, trackRpcRequestSent, + LONG_RUNNING_RPC_ACK_THRESHOLD_MS, SLOW_RPC_ACK_THRESHOLD_MS, MAX_TRACKED_RPC_ACK_REQUESTS, } from "./requestLatencyState"; @@ -58,6 +59,32 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); + it("keeps ignoring untracked methods when a display tag is supplied", () => { + trackRpcRequestSent( + "1", + WS_METHODS.previewAutomationConnect, + `${WS_METHODS.previewAutomationConnect} · env-1`, + ); + vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); + + expect(getSlowRpcAckRequests()).toEqual([]); + }); + + it("gives provider updates a longer threshold before warning", () => { + trackRpcRequestSent("1", WS_METHODS.serverUpdateProvider, "server.updateProvider · env-1"); + vi.advanceTimersByTime(LONG_RUNNING_RPC_ACK_THRESHOLD_MS - 1); + expect(getSlowRpcAckRequests()).toEqual([]); + + vi.advanceTimersByTime(1); + expect(getSlowRpcAckRequests()).toMatchObject([ + { + requestId: "1", + tag: "server.updateProvider · env-1", + thresholdMs: LONG_RUNNING_RPC_ACK_THRESHOLD_MS, + }, + ]); + }); + it("evicts the oldest pending requests once the tracker reaches capacity", () => { for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) { trackRpcRequestSent(String(index), "server.getConfig"); diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 1d8663edcfdd..c9d40700ea4c 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -5,6 +5,12 @@ import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; export const SLOW_RPC_ACK_THRESHOLD_MS = 15_000; +/** + * Some requests are slow by design — they shell out to a package manager on the + * server and only respond once the install finishes. Warning about those after + * 15s is noise, so they get a much longer leash. + */ +export const LONG_RUNNING_RPC_ACK_THRESHOLD_MS = 120_000; export const MAX_TRACKED_RPC_ACK_REQUESTS = 256; let slowRpcAckThresholdMs = SLOW_RPC_ACK_THRESHOLD_MS; @@ -22,6 +28,12 @@ interface PendingRpcAckRequest { } const pendingRpcAckRequests = new Map(); +const untrackedRpcAckMethods = new Set([WS_METHODS.previewAutomationConnect]); +const longRunningRpcAckMethods = new Set([ + WS_METHODS.serverUpdateProvider, + WS_METHODS.serverRefreshProviders, + WS_METHODS.serverUpdateServer, +]); const slowRpcAckRequestsAtom = Atom.make>([]).pipe( Atom.keepAlive, @@ -36,8 +48,8 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { return appAtomRegistry.get(slowRpcAckRequestsAtom); } -function shouldTrackRpcAck(tag: string): boolean { - if (tag === WS_METHODS.previewAutomationConnect) { +function shouldTrackRpcAck(method: string): boolean { + if (untrackedRpcAckMethods.has(method)) { return false; } @@ -45,15 +57,26 @@ function shouldTrackRpcAck(tag: string): boolean { // later than the user-visible payload). Match `subscribe` at the start of // the tag or after a path-segment delimiter so `thread/unsubscribe` and // similar still get tracked. - return !/(?:^|[./:])subscribe/i.test(tag); + return !/(?:^|[./:])subscribe/i.test(method); +} + +function rpcAckThresholdMs(method: string): number { + return longRunningRpcAckMethods.has(method) + ? Math.max(slowRpcAckThresholdMs, LONG_RUNNING_RPC_ACK_THRESHOLD_MS) + : slowRpcAckThresholdMs; } export function getSlowRpcAckRequests(): ReadonlyArray { return getSlowRpcAckRequestsValue(); } -export function trackRpcRequestSent(requestId: string, tag: string): void { - if (!shouldTrackRpcAck(tag)) { +/** + * Starts the slow-request timer for one in-flight unary RPC. `method` is the + * bare WS method (used to decide whether and how long to wait); `tag` is the + * human-readable label shown in the toast, which defaults to the method. + */ +export function trackRpcRequestSent(requestId: string, method: string, tag = method): void { + if (!shouldTrackRpcAck(method)) { return; } @@ -61,17 +84,18 @@ export function trackRpcRequestSent(requestId: string, tag: string): void { evictOldestPendingRpcRequestIfNeeded(); const startedAtMs = Date.now(); + const thresholdMs = rpcAckThresholdMs(method); const request: SlowRpcAckRequest = { requestId, startedAt: new Date(startedAtMs).toISOString(), startedAtMs, tag, - thresholdMs: slowRpcAckThresholdMs, + thresholdMs, }; const timeoutId = setTimeout(() => { pendingRpcAckRequests.delete(requestId); appendSlowRpcAckRequest(request); - }, slowRpcAckThresholdMs); + }, thresholdMs); pendingRpcAckRequests.set(requestId, { request, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f17f2364062f..f1b6ecd5c957 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -11,12 +11,12 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveActiveWorkStartedAt, deriveActivePlanState, + deriveTurnPlans, derivePendingApprovals, derivePendingUserInputs, deriveTimelineEntries, deriveWorkLogEntries, findLatestProposedPlan, - findSidebarProposedPlan, hasActionableProposedPlan, isLatestTurnSettled, PROVIDER_OPTIONS, @@ -411,6 +411,95 @@ describe("deriveActivePlanState", () => { }); }); +describe("deriveTurnPlans", () => { + it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-1a", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + plan: [{ step: "Inspect code", status: "inProgress" }], + }, + }), + makeActivity({ + id: "plan-1b", + createdAt: "2026-02-23T00:00:05.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + plan: [{ step: "Inspect code", status: "completed" }], + }, + }), + makeActivity({ + id: "plan-2a", + createdAt: "2026-02-23T00:01:00.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-2", + payload: { + plan: [{ step: "Ship it", status: "pending" }], + }, + }), + ]; + + const turnPlans = deriveTurnPlans(activities); + expect(turnPlans).toHaveLength(2); + expect(turnPlans[0]).toMatchObject({ + id: "turn-plan:turn-1", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + }); + expect(turnPlans[0]?.plan.steps).toEqual([{ step: "Inspect code", status: "completed" }]); + expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]); + }); + + it("skips activities without parseable steps", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-bad", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [] }, + }), + ]; + expect(deriveTurnPlans(activities)).toEqual([]); + }); + + it("drops a turn's chip when a later snapshot clears the plan", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-set", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [{ step: "Inspect code", status: "inProgress" }] }, + }), + makeActivity({ + id: "plan-clear", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [] }, + }), + ]; + expect(deriveTurnPlans(activities)).toEqual([]); + }); +}); + describe("findLatestProposedPlan", () => { it("prefers the latest proposed plan for the active turn", () => { expect( @@ -516,103 +605,6 @@ describe("hasActionableProposedPlan", () => { }); }); -describe("findSidebarProposedPlan", () => { - it("prefers the running turn source proposed plan when available on the same thread", () => { - expect( - findSidebarProposedPlan({ - threads: [ - { - id: ThreadId.make("thread-1"), - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-plan"), - planMarkdown: "# Source plan", - implementedAt: "2026-02-23T00:00:03.000Z", - implementationThreadId: ThreadId.make("thread-2"), - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }, - ], - }, - { - id: ThreadId.make("thread-2"), - proposedPlans: [ - { - id: "plan-2", - turnId: TurnId.make("turn-other"), - planMarkdown: "# Latest elsewhere", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:04.000Z", - updatedAt: "2026-02-23T00:00:05.000Z", - }, - ], - }, - ], - latestTurn: { - turnId: TurnId.make("turn-implementation"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-1"), - planId: "plan-1", - }, - }, - latestTurnSettled: false, - threadId: ThreadId.make("thread-1"), - }), - ).toEqual({ - id: "plan-1", - turnId: "turn-plan", - planMarkdown: "# Source plan", - implementedAt: "2026-02-23T00:00:03.000Z", - implementationThreadId: "thread-2", - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }); - }); - - it("falls back to the latest proposed plan once the turn is settled", () => { - expect( - findSidebarProposedPlan({ - threads: [ - { - id: ThreadId.make("thread-1"), - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-plan"), - planMarkdown: "# Older", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }, - { - id: "plan-2", - turnId: TurnId.make("turn-latest"), - planMarkdown: "# Latest", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:03.000Z", - updatedAt: "2026-02-23T00:00:04.000Z", - }, - ], - }, - ], - latestTurn: { - turnId: TurnId.make("turn-implementation"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-1"), - planId: "plan-1", - }, - }, - latestTurnSettled: true, - threadId: ThreadId.make("thread-1"), - })?.planMarkdown, - ).toBe("# Latest"); - }); -}); - describe("workEntryIndicatesToolFailure", () => { const base = { id: "w1", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 88a25920d9b3..aa0d5b7de23d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -161,6 +161,12 @@ export type TimelineEntry = createdAt: string; proposedPlan: ProposedPlan; } + | { + id: string; + kind: "turn-plan"; + createdAt: string; + turnPlan: TurnPlanEntry; + } | { id: string; kind: "work"; @@ -542,26 +548,10 @@ export function derivePendingUserInputs( ); } -export function deriveActivePlanState( - activities: ReadonlyArray, - latestTurnId: TurnId | undefined, -): ActivePlanState | null { - const ordered = [...activities].toSorted(compareActivitiesByOrder); - const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); - // Prefer plan from the current turn; fall back to the most recent plan from any turn - // so that TodoWrite tasks persist across follow-up messages. - const latest = Option.firstSomeOf([ - ...(latestTurnId - ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) - : Option.none()), - Arr.last(allPlanActivities), - ]).pipe(Option.getOrNull); - if (!latest) { - return null; - } +function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null { const payload = - latest.payload && typeof latest.payload === "object" - ? (latest.payload as Record) + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) : null; const rawPlan = payload?.plan; if (!Array.isArray(rawPlan)) { @@ -590,8 +580,8 @@ export function deriveActivePlanState( return null; } return { - createdAt: latest.createdAt, - turnId: latest.turnId, + createdAt: activity.createdAt, + turnId: activity.turnId, ...(payload && "explanation" in payload ? { explanation: payload.explanation as string | null } : {}), @@ -599,6 +589,72 @@ export function deriveActivePlanState( }; } +export function deriveActivePlanState( + activities: ReadonlyArray, + latestTurnId: TurnId | undefined, +): ActivePlanState | null { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); + // Prefer plan from the current turn; fall back to the most recent plan from any turn + // so that TodoWrite tasks persist across follow-up messages. + const latest = Option.firstSomeOf([ + ...(latestTurnId + ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) + : Option.none()), + Arr.last(allPlanActivities), + ]).pipe(Option.getOrNull); + if (!latest) { + return null; + } + return planStateFromActivity(latest); +} + +export interface TurnPlanEntry { + /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */ + id: string; + /** Anchor timestamp: the turn's FIRST plan activity, so the chip renders where planning began. */ + createdAt: string; + turnId: TurnId | null; + plan: ActivePlanState; +} + +/** + * One inline plan chip per turn that produced plan/todo steps: the latest + * snapshot for the turn, anchored at the first snapshot's timestamp. Turn-less + * plan activities collapse into a single chip keyed by thread order. + */ +export function deriveTurnPlans( + activities: ReadonlyArray, +): TurnPlanEntry[] { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const byTurn = new Map(); + for (const activity of ordered) { + if (activity.kind !== "turn.plan.updated") { + continue; + } + const plan = planStateFromActivity(activity); + const key = activity.turnId ?? "no-turn"; + if (!plan) { + // A later snapshot with no steps clears the turn's plan; keeping the + // stale entry would freeze the chip on a withdrawn plan. + byTurn.delete(key); + continue; + } + const existing = byTurn.get(key); + if (existing) { + existing.plan = plan; + } else { + byTurn.set(key, { + id: `turn-plan:${key}`, + createdAt: activity.createdAt, + turnId: activity.turnId, + plan, + }); + } + } + return [...byTurn.values()]; +} + export function findLatestProposedPlan( proposedPlans: ReadonlyArray, latestTurnId: TurnId | string | null | undefined, @@ -629,30 +685,6 @@ export function findLatestProposedPlan( return toLatestProposedPlanState(latestPlan); } -export function findSidebarProposedPlan(input: { - threads: ReadonlyArray>; - latestTurn: Pick | null; - latestTurnSettled: boolean; - threadId: ThreadId | string | null | undefined; -}): LatestProposedPlanState | null { - const activeThreadPlans = - input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? []; - - if (!input.latestTurnSettled) { - const sourceProposedPlan = input.latestTurn?.sourceProposedPlan; - if (sourceProposedPlan) { - const sourcePlan = input.threads - .find((thread) => thread.id === sourceProposedPlan.threadId) - ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId); - if (sourcePlan) { - return toLatestProposedPlanState(sourcePlan); - } - } - } - - return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null); -} - export function hasActionableProposedPlan( proposedPlan: LatestProposedPlanState | Pick | null, ): boolean { @@ -1553,6 +1585,7 @@ export function deriveTimelineEntries( messages: ReadonlyArray, proposedPlans: ReadonlyArray, workEntries: ReadonlyArray, + turnPlans: ReadonlyArray = [], ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ id: message.id, @@ -1566,13 +1599,19 @@ export function deriveTimelineEntries( createdAt: proposedPlan.createdAt, proposedPlan, })); + const turnPlanRows: TimelineEntry[] = turnPlans.map((turnPlan) => ({ + id: turnPlan.id, + kind: "turn-plan", + createdAt: turnPlan.createdAt, + turnPlan, + })); const workRows: TimelineEntry[] = workEntries.map((entry) => ({ id: entry.id, kind: "work", createdAt: entry.createdAt, entry, })); - return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => + return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt), ); } diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 3271eefd1e18..1071d8209dfe 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -33,7 +33,7 @@ interface PrimaryServerState { } const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; +export const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { config: null, latestEvent: null, diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index 37fed3b188f2..a7d5a53d10d2 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -26,3 +26,17 @@ export function readPreparedConnection(environmentId: EnvironmentId) { appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), ); } + +/** + * This client's authenticated session on one environment, as reported by that + * environment's `/api/auth/session` endpoint. `data` stays populated across + * SWR revalidations; `isPending` is only meaningful before the first resolve. + */ +export function useEnvironmentSessionState(environmentId: EnvironmentId) { + const result = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + return { + data: Option.getOrNull(AsyncResult.value(result)), + hasError: result._tag === "Failure", + isPending: result.waiting, + }; +} diff --git a/apps/web/src/test/reactElementTree.ts b/apps/web/src/test/reactElementTree.ts new file mode 100644 index 000000000000..33351c35eb17 --- /dev/null +++ b/apps/web/src/test/reactElementTree.ts @@ -0,0 +1,27 @@ +import { isValidElement, type ReactElement } from "react"; + +/** + * Depth-first search over a React element tree produced by calling a component + * as a plain function (see `reactHookHarness`). Descends through props so + * render-prop and slot-style children are reachable. Returns the first element + * the visitor accepts, or null. + */ +export function visitElements( + node: unknown, + visitor: (element: ReactElement>) => boolean, +): ReactElement> | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = visitElements(child, visitor); + if (found) return found; + } + return null; + } + if (!isValidElement>(node)) return null; + if (visitor(node)) return node; + for (const value of Object.values(node.props)) { + const found = visitElements(value, visitor); + if (found) return found; + } + return null; +} diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts new file mode 100644 index 000000000000..1b4b26fb6988 --- /dev/null +++ b/apps/web/src/test/reactHookHarness.ts @@ -0,0 +1,89 @@ +import type { Dispatch, SetStateAction } from "react"; + +/** + * Minimal React hook shim for tests that call components as plain functions + * instead of mounting a renderer. Slots are keyed by call order, mirroring + * React's own rules-of-hooks contract, and `useMemoCache` emulates the React + * Compiler runtime so compiled components can execute unmodified. + * + * This module must stay free of runtime `react` imports: it is loaded from + * inside `vi.mock("react", ...)` factories, and a value import would recurse + * into the in-progress mock. Wire it up in each test file (mock calls cannot + * live here because vitest hoists them per test module): + * + * ```ts + * import { reactHookHarness } from "~/test/reactHookHarness"; + * + * vi.mock("react", async (importOriginal) => { + * const actual = await importOriginal(); + * const { reactHookHarness } = await import("~/test/reactHookHarness"); + * return { + * ...actual, + * useCallback: reactHookHarness.useCallback, + * useMemo: reactHookHarness.useMemo, + * useRef: reactHookHarness.useRef, + * useState: reactHookHarness.useState, + * }; + * }); + * vi.mock("react/compiler-runtime", async () => { + * const { reactHookHarness } = await import("~/test/reactHookHarness"); + * return { c: reactHookHarness.useMemoCache }; + * }); + * ``` + * + * Call `beginRender()` before each component invocation and `reset()` in + * `beforeEach` to drop persisted state between tests. + */ +export function createReactHookHarness() { + let cursor = 0; + let slots: unknown[] = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + slots = []; + }, + useCallback(callback: T): T { + nextIndex(); + return callback; + }, + useMemo(factory: () => T): T { + nextIndex(); + return factory(); + }, + useMemoCache(size: number): unknown[] { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); + } + return slots[index] as unknown[]; + }, + useRef(initialValue: T): { current: T } { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = { current: initialValue }; + } + return slots[index] as { current: T }; + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const index = nextIndex(); + if (index >= slots.length) { + slots[index] = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + const setValue: Dispatch> = (nextValue) => { + const previous = slots[index] as T; + slots[index] = + typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; + }; + return [slots[index] as T, setValue]; + }, + }; +} + +/** Shared instance so `vi.mock` factories and test bodies see the same slots. */ +export const reactHookHarness = createReactHookHarness(); diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index c8a0217919f7..c734f0f9dd74 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -14,8 +14,16 @@ For the wider system diagram, see ## Application Keys -T3 Connect is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env` -or `.env.local` file: +T3 Connect is disabled in a fresh clone. To enable it for source builds against the production +deployment, copy the repository-root example file: + +```sh +cp .env.example .env +``` + +`.env.example` carries the production public identifiers (the same values baked into official +release builds). To target a different Clerk application or relay, set the values yourself in a +repository-root `.env` or `.env.local` file: ```dotenv T3CODE_CLERK_PUBLISHABLE_KEY= diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index a925859049ff..5e50c44d9610 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -248,7 +248,7 @@ describe("EnvironmentSupervisor", () => { const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt"); expect(firstAttempt).toBeDefined(); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState(supervisor.state, (state) => state.phase === "connected"); const attempts = spans.filter((span) => span.name === "relay.connection.attempt"); @@ -358,7 +358,7 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(1); - for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { + for (const [index, delay] of [3_000, 4_000, 8_000, 16_000, 16_000, 16_000].entries()) { yield* TestClock.adjust(delay); yield* eventuallyState( supervisor.state, @@ -384,7 +384,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); const retrying = yield* awaitState( supervisor.state, @@ -489,7 +489,7 @@ describe("EnvironmentSupervisor", () => { }, }); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState(supervisor.state, (state) => state.phase === "connected"); expect(yield* Ref.get(harness.prepareCount)).toBe(2); }).pipe(Effect.provide(TestClock.layer())), @@ -526,7 +526,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* eventuallyState( supervisor.state, (state) => state.phase === "backoff" && state.attempt === 2, @@ -539,7 +539,7 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(3); - yield* TestClock.adjust("999 millis"); + yield* TestClock.adjust("2999 millis"); expect(yield* Ref.get(harness.prepareCount)).toBe(3); yield* TestClock.adjust("1 milli"); yield* eventuallyState( @@ -588,7 +588,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "blocked" && state.attempt === 2, @@ -703,7 +703,7 @@ describe("EnvironmentSupervisor", () => { ); expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -728,7 +728,7 @@ describe("EnvironmentSupervisor", () => { (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -741,7 +741,7 @@ describe("EnvironmentSupervisor", () => { expect(secondFailure.retryAt).not.toBeNull(); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); expect(yield* Ref.get(harness.sessionCount)).toBe(2); yield* TestClock.adjust("1 second"); @@ -766,7 +766,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -805,7 +805,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 2, @@ -834,7 +834,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connecting" && state.attempt === 2, @@ -925,9 +925,14 @@ describe("EnvironmentSupervisor", () => { }), ); - it.effect("reconnects when the foreground liveness probe fails", () => + it.effect("reconnects immediately when the foreground liveness probe fails", () => Effect.gen(function* () { + const allowReconnect = yield* Deferred.make(); const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 2 + ? Deferred.await(allowReconnect).pipe(Effect.as(PREPARED_CONNECTION)) + : Effect.succeed(PREPARED_CONNECTION), probe: (attempt) => attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, }); @@ -937,15 +942,77 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active"); - yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); - yield* TestClock.adjust("1 second"); + const reconnecting = yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting", + ); + expect(reconnecting.attempt).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + + // No TestClock advance: a failed wake probe skips the first backoff rung. + yield* Deferred.succeed(allowReconnect, undefined); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps normal backoff when a reconnect after a failed wake probe also fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 2 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + probe: (attempt) => + attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + // The immediate follow-up attempt fails: only the first attempt after + // the wake probe skips the ladder, so this failure backs off normally. + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("2999 millis"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + yield* TestClock.adjust("1 milli"); yield* eventuallyState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, ); + expect(yield* Ref.get(harness.prepareCount)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("uses the full tolerance window for a stalled desktop foreground probe", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* TestClock.adjust("14999 millis"); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + yield* TestClock.adjust("1 milli"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); - expect(yield* Ref.get(harness.releaseCount)).toBe(1); }).pipe(Effect.provide(TestClock.layer())), ); @@ -961,15 +1028,14 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active-probe"); yield* TestClock.adjust("3 seconds"); + // The timed-out wake probe reconnects immediately without a backoff + // sleep: no further clock advance is needed. yield* awaitState( supervisor.state, - (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout", - ); - yield* TestClock.adjust("1 second"); - yield* eventuallyState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 2, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); }).pipe(Effect.provide(TestClock.layer())), ); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 2a9c7519072b..85fda10ef1a7 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -29,7 +29,7 @@ import * as RpcSession from "../rpc/session.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as ConnectionWakeups from "./wakeups.ts"; -const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; +const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const; const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; const CONNECTION_PROBE_TIMEOUT = "15 seconds"; const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds"; @@ -232,6 +232,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const intent = yield* Ref.make(initialIntent); const signals = yield* Queue.unbounded(); const resetRetryState = yield* Ref.make(false); + // Set when a foreground wake probe fails or times out: the user is actively + // returning to the app on a dead transport, so the follow-up reconnect skips + // the first backoff rung instead of sleeping. + const wakeProbeFailed = yield* Ref.make(false); const state = yield* SubscriptionRef.make( !initialIntent.desired ? availableState(initialIntent, 0) @@ -441,6 +445,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ), ); if (probeEvent._tag === "ProbeCompleted") { + if (Exit.isFailure(probeEvent.exit)) { + yield* Ref.set(wakeProbeFailed, true); + } yield* probeEvent.exit; break; } @@ -673,6 +680,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const outcome: AttemptOutcome = yield* Effect.scoped( runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), ); + // Consumed on every iteration so a stale marker can never leak into a + // later, unrelated failure. + const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false); if (outcome.established) { generation = nextGeneration; if (outcome.stable) { @@ -709,6 +719,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( continue; } + if (failedWakeProbe) { + // The wake probe found a dead transport while the user is returning to + // the app, so reconnect immediately instead of sleeping the first + // backoff rung. Only this first attempt skips the ladder; if it fails + // too, normal backoff resumes. + resetRetryLadder(); + yield* setState(connectingState(yield* Ref.get(intent), generation, 1, error)); + continue; + } + failureCount += 1; const delayMs = retryDelayMs(failureCount - 1); pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index f7868834b57c..0af5850bf6c7 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -287,6 +287,33 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("tolerates two missed pong windows before closing the session", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const closedFiber = yield* Effect.forkChild(Effect.flip(session.closed)); + const socket = yield* awaitSocket(sockets); + + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + yield* TestClock.adjust("15 seconds"); + expect(closedFiber.pollUnsafe()).toBeUndefined(); + expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([ + { _tag: "Ping" }, + { _tag: "Ping" }, + { _tag: "Ping" }, + ]); + + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(closedFiber); + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ reason: "transport" }); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.effect("reaches ready when a newer server sends unknown config members", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index e08fd9e552f2..d3bb6680208a 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -333,6 +333,7 @@ describe("environment entity projections", () => { data: Option.some(detail), status: "live", error: Option.none(), + page: Option.none(), }), ); @@ -361,6 +362,7 @@ describe("environment entity projections", () => { }), status: "live", error: Option.none(), + page: Option.none(), }), ); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 3cb62009a208..31fd297da3f0 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,14 +1,19 @@ -import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { HttpClient } from "effect/unstable/http"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import type { PreparedConnection } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./runtime.ts"; export function initialConfigOption( @@ -25,8 +30,39 @@ export function initialConfigOption( ); } +// Bounded like the snapshot fetches: a wedged environment must not pin the +// permissions check (and with it the settings UI) in a loading state for long. +const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; + +/** + * Read the granted scopes of this client's session on one environment via its + * `/api/auth/session` endpoint, authenticated with whatever credential the + * connection was prepared with (cookie, bearer, or DPoP). + */ +export const fetchEnvironmentSessionState = Effect.fn( + "clientRuntime.state.fetchEnvironmentSessionState", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/auth/session"); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl, + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS, + withEnvironmentCredentials(input.prepared.httpAuthorization, client.auth.session({ headers })), + ); +}); + export function createEnvironmentSessionAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => runtime.atom( @@ -86,10 +122,41 @@ export function createEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), ); + // Keyed on the prepared connection's identity: a reconnect (new credential, + // new base URL) swaps the prepared value, which re-runs the fetch, so scope + // changes from re-pairing are picked up without an explicit refresh. + const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => + runtime + .atom((get) => { + const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId))); + if (prepared === null) { + return Effect.never; + } + return Effect.gen(function* () { + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + return yield* fetchEnvironmentSessionState({ prepared, signer }); + }); + }) + .pipe( + Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-session-state:${environmentId}`), + ), + ); + + const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): AuthSessionState | null => + Option.getOrNull(AsyncResult.value(get(sessionStateAtom(environmentId)))) ?? null, + ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), + ); + return { initialConfigAtom, initialConfigValueAtom, preparedConnectionAtom, preparedConnectionValueAtom, + sessionStateAtom, + sessionStateValueAtom, }; } diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index e006fc3cd762..40e9bd80dc5b 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -150,34 +150,34 @@ describe("environment shell synchronization", () => { }), ); - it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () => + it.effect("requests a full socket snapshot when the HTTP refresh fails", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 5, projects: [], - threads: [{ id: "stale-thread" } as never], + threads: [{ id: "cached-thread" } as never], updatedAt: "2026-06-06T00:00:00.000Z", }; - const httpSnapshot: OrchestrationShellSnapshot = { + const resetSnapshot: OrchestrationShellSnapshot = { ...cachedSnapshot, - snapshotSequence: 9, + snapshotSequence: 9_999, threads: [], updatedAt: "2026-06-07T00:00:00.000Z", }; const events = yield* Queue.unbounded(); - const capturedAfterSequence = yield* SubscriptionRef.make(undefined); - const capturedCompletionMarker = yield* Ref.make(undefined); - const loaderCalls = yield* SubscriptionRef.make(0); + const wakeups = yield* Queue.unbounded(); + const subscribeInputs = yield* Queue.unbounded<{ + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }>(); + const loaderCalls = yield* Ref.make(0); const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; }) => Stream.unwrap( - Ref.set(capturedCompletionMarker, input.requestCompletionMarker).pipe( - Effect.andThen(SubscriptionRef.set(capturedAfterSequence, input.afterSequence)), - Effect.as(Stream.fromQueue(events)), - ), + Queue.offer(subscribeInputs, input).pipe(Effect.as(Stream.fromQueue(events))), ), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); @@ -208,57 +208,66 @@ describe("environment shell synchronization", () => { clear: () => Effect.void, }); const snapshotLoader = ShellSnapshotLoader.of({ - load: () => - SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe( - Effect.as(Option.some(httpSnapshot)), - ), + load: () => Ref.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), }); const shellState = yield* makeEnvironmentShellState().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService(ShellSnapshotLoader, snapshotLoader), + Effect.provideService( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }), + ), ); - // Wait until the subscription is established from the warm cache. - yield* SubscriptionRef.changes(capturedAfterSequence).pipe( - Stream.filter((value) => value !== undefined), - Stream.runHead, - ); - - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9); - expect(yield* Ref.get(capturedCompletionMarker)).toBe(true); - expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); + const subscribeInput = yield* Queue.take(subscribeInputs); + expect(subscribeInput.afterSequence).toBeUndefined(); + expect(subscribeInput.requestCompletionMarker).toBe(true); + expect(yield* Ref.get(loaderCalls)).toBe(1); const synchronizing = yield* SubscriptionRef.get(shellState); expect(synchronizing.status).toBe("synchronizing"); - expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); + expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(cachedSnapshot); + yield* Queue.offer(events, { kind: "snapshot", snapshot: resetSnapshot }); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((value) => value.status === "live"), Stream.runHead, ); + + const live = yield* SubscriptionRef.get(shellState); + expect(Option.getOrThrow(live.snapshot)).toEqual(resetSnapshot); + expect(yield* Ref.get(loaderCalls)).toBe(1); + + yield* Queue.offer(wakeups, "application-active"); + const resumedInput = yield* Queue.take(subscribeInputs); + expect(resumedInput.afterSequence).toBe(resetSnapshot.snapshotSequence); + expect(resumedInput.requestCompletionMarker).toBe(true); + expect(yield* Ref.get(loaderCalls)).toBe(1); }), ); - it.effect("refreshes the authoritative shell snapshot when the app becomes active", () => + it.effect("resubscribes from the in-memory shell cursor when the app becomes active", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); const wakeups = yield* Queue.unbounded(); const loaderCalls = yield* Ref.make(0); - const subscriptionCount = yield* Ref.make(0); + const capturedAfterSequences = yield* Ref.make>([]); const client = { - [ORCHESTRATION_WS_METHODS.subscribeShell]: () => + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => Stream.unwrap( - Ref.update(subscriptionCount, (count) => count + 1).pipe( - Effect.as(Stream.fromQueue(events)), - ), + Ref.update(capturedAfterSequences, (captured) => [ + ...captured, + input.afterSequence, + ]).pipe(Effect.as(Stream.fromQueue(events))), ), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make(Option.some(session(client))); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: supervisorState, - session: yield* SubscriptionRef.make(Option.some(session(client))), + session: activeSession, prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), connect: Effect.void, disconnect: Effect.void, @@ -296,54 +305,60 @@ describe("environment shell synchronization", () => { ), ); - yield* SubscriptionRef.changes(shellState).pipe( - Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 10, - ), - Stream.runHead, - ); + // A new session starts from an authoritative HTTP snapshot. + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(capturedAfterSequences)).length >= 1) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10]); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((value) => value.status === "live"), Stream.runHead, ); - yield* Queue.offer(wakeups, "application-active"); + // A newer snapshot arrives on the stream and advances the cursor. + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 40 }, + }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 20, + (value) => Option.isSome(value.snapshot) && value.snapshot.value.snapshotSequence === 40, ), Stream.runHead, ); + yield* Queue.offer(wakeups, "application-active"); for (let attempt = 0; attempt < 100; attempt += 1) { - if ((yield* Ref.get(subscriptionCount)) >= 2) break; + if ((yield* Ref.get(capturedAfterSequences)).length >= 2) break; yield* Effect.yieldNow; } - - expect(yield* Ref.get(loaderCalls)).toBe(2); - expect(yield* Ref.get(subscriptionCount)).toBe(2); + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40]); + yield* Queue.offer(events, { kind: "synchronized" }); yield* Queue.offer(wakeups, "application-active-probe"); for (let attempt = 0; attempt < 100; attempt += 1) { - if ((yield* Ref.get(subscriptionCount)) >= 3) break; + if ((yield* Ref.get(capturedAfterSequences)).length >= 3) break; yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); - expect(yield* Ref.get(subscriptionCount)).toBe(3); + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40, 40]); yield* Queue.offer(wakeups, "application-active-reconnect"); for (let attempt = 0; attempt < 10; attempt += 1) { yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); - expect(yield* Ref.get(subscriptionCount)).toBe(3); + expect((yield* Ref.get(capturedAfterSequences)).length).toBe(3); + expect(yield* Ref.get(loaderCalls)).toBe(1); + + // Replacing the session performs another authoritative refresh. + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(capturedAfterSequences)).length >= 4) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40, 40, 20]); + expect(yield* Ref.get(loaderCalls)).toBe(2); }), ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a266af5f5f4e..c150bbb75b8c 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -21,6 +21,7 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; @@ -71,6 +72,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") error: Option.none(), }); const awaitingCompletion = yield* Ref.make(false); + const lastAuthoritativeSession = yield* Ref.make(null); + const activeSubscriptionSession = yield* Ref.make(null); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentShellState.persist")(function* ( @@ -166,6 +169,12 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") status: waiting ? "synchronizing" : "live", error: Option.none(), }); + if (item.kind === "snapshot") { + const session = yield* Ref.get(activeSubscriptionSession); + if (session !== null) { + yield* Ref.set(lastAuthoritativeSession, session); + } + } yield* Queue.offer(persistence, nextSnapshot); }); @@ -180,6 +189,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeShell, Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) { + yield* Ref.set(activeSubscriptionSession, session); const supportsCompletionMarker = yield* session.initialConfig.pipe( Effect.map((config) => config.shellResumeCompletionMarker === true), Effect.orElseSucceed(() => false), @@ -187,30 +197,53 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; - const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( - Effect.flatMap( - Option.match({ - onSome: Effect.succeed, - onNone: () => - SubscriptionRef.changes(supervisor.prepared).pipe( - Stream.filter(Option.isSome), - Stream.map((value) => value.value), - Stream.runHead, - Effect.map(Option.getOrThrow), - ), - }), - ), - ); - const httpSnapshot = yield* snapshotLoader.load(prepared); - if (Option.isSome(httpSnapshot)) { - yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); - return { - afterSequence: httpSnapshot.value.snapshotSequence, - ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), - }; + // Foreground resubscriptions on the same live session can resume from + // the in-memory cursor. A new session reloads the authoritative HTTP + // snapshot so a valid cursor cannot preserve incomplete cached data. + const hasAuthoritativeSnapshot = (yield* Ref.get(lastAuthoritativeSession)) === session; + let canResume = hasAuthoritativeSnapshot; + let current = yield* SubscriptionRef.get(state); + if (!hasAuthoritativeSnapshot || Option.isNone(current.snapshot)) { + const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + }), + ), + ); + const httpSnapshot = yield* snapshotLoader.load(prepared); + if (Option.isSome(httpSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + canResume = true; + current = yield* SubscriptionRef.get(state); + } } - return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + // If the authoritative refresh failed, omit the cached cursor so the + // socket fallback sends a complete snapshot for this new session. + if (!canResume || Option.isNone(current.snapshot)) { + return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + } + if (!supportsCompletionMarker) { + // Without a completion marker there is no synchronized signal for a + // resumed subscription, so report live immediately, like threads. + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + status: "live" as const, + error: Option.none(), + })); + } + return { + afterSequence: current.snapshot.value.snapshotSequence, + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; }), { onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index c6c758511a3b..ceb40517550e 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -186,6 +186,34 @@ describe("foldSubagentActivities", () => { expect(agents[0]!.usage).toEqual({ totalTokens: 900, inputTokens: 700 }); }); + it("usage snapshots enrich an existing agent without changing its status", () => { + const [agent] = fold([ + activity("task.started", { taskId: "usage-waiting", taskType: "local_agent" }), + activity("task.progress", { taskId: "usage-waiting", status: "waiting" }), + activity("task.progress", { + taskId: "usage-waiting", + usageSnapshot: true, + typedUsage: { totalTokens: 1_200 }, + }), + ]); + + expect(agent?.status).toBe("waiting"); + expect(agent?.usage?.totalTokens).toBe(1_200); + }); + + it("a retained usage snapshot can still reconstruct a running agent", () => { + const [agent] = fold([ + activity("task.progress", { + taskId: "usage-only", + usageSnapshot: true, + typedUsage: { totalTokens: 800 }, + }), + ]); + + expect(agent?.status).toBe("running"); + expect(agent?.usage?.totalTokens).toBe(800); + }); + it("partial terminal usage preserves known breakdown fields", () => { const agents = fold([ activity("task.started", { taskId: "task-6", taskType: "local_agent" }), @@ -362,6 +390,49 @@ describe("deriveAgentPanelModel", () => { ); }); + it("keeps direct spawns in first-seen order as their activity changes", () => { + const directRoster = fold([ + activity("task.started", { taskId: "direct-a", title: "First" }, "2026-08-01T11:00:00.000Z"), + activity("task.started", { taskId: "direct-b", title: "Second" }, "2026-08-01T11:00:01.000Z"), + activity( + "task.progress", + { taskId: "direct-a", summary: "Newest activity" }, + "2026-08-01T11:00:02.000Z", + ), + ]); + + expect( + deriveAgentPanelModel({ agents: directRoster }).directAgents.map((agent) => agent.id), + ).toEqual(["direct-a", "direct-b"]); + }); + + it("keeps first-seen order after the roster retention ranking runs", () => { + const starts = Array.from({ length: 101 }, (_, index) => + activity( + "task.started", + { taskId: `capped-${index}`, title: `Agent ${index}` }, + `2026-08-01T12:${String(Math.floor(index / 60)).padStart(2, "0")}:${String( + index % 60, + ).padStart(2, "0")}.000Z`, + ), + ); + const cappedRoster = fold([ + ...starts, + activity( + "task.progress", + { taskId: "capped-0", summary: "Newest activity" }, + "2026-08-01T12:02:00.000Z", + ), + ]); + + const ids = deriveAgentPanelModel({ agents: cappedRoster }).directAgents.map( + (agent) => agent.id, + ); + expect(ids).toHaveLength(100); + expect(ids.slice(0, 3)).toEqual(["capped-0", "capped-2", "capped-3"]); + expect(ids.at(-1)).toBe("capped-100"); + }); + it("a phase with only pending members never reads as running", () => { const pendingRoster = fold([ activity("task.started", { taskId: "wf-9", taskType: "local_workflow" }), diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index c81dd6341409..e5f2b586b8c4 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -80,6 +80,8 @@ export interface RuntimeSubagent { readonly phases: ReadonlyArray; readonly runHandles: SubagentRunHandles | null; readonly recentActivity: ReadonlyArray; + /** First retained observation, used as the roster's stable display order. */ + readonly firstSeenAt: string; readonly startedAt: string | null; readonly completedAt: string | null; readonly updatedAt: string; @@ -247,6 +249,7 @@ interface MutableAgent { phases: ReadonlyArray; runHandles: SubagentRunHandles | null; recentActivity: ReadonlyArray; + firstSeenAt: string; startedAt: string | null; completedAt: string | null; updatedAt: string; @@ -300,6 +303,7 @@ function getOrCreate( phases: [], runHandles: null, recentActivity: [], + firstSeenAt: at, startedAt: null, completedAt: null, updatedAt: at, @@ -500,14 +504,19 @@ export function foldSubagentActivities( // Membership is sticky per taskId: rows after the first (terminal // rows often carry only taskId+status, no marker fields) inherit the // first row's classification instead of being re-judged. - if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break; + const existed = agents.has(taskId); + if (!existed && isBackgroundTaskActivity(payload)) break; const agent = getOrCreate(agents, taskId, payload, at); fillMetadata(agent, payload); if (agent.activationCount === 0) agent.activationCount = 1; const explicitStatus = asRuntimeStatus(payload.status); if (explicitStatus) { applyStatus(agent, explicitStatus, at); - } else if (!isTerminalSubagentStatus(agent.status) && agent.status !== "idle") { + } else if ( + (payload.usageSnapshot !== true || !existed) && + !isTerminalSubagentStatus(agent.status) && + agent.status !== "idle" + ) { applyStatus(agent, "running", at); } const summary = asString(payload.summary); @@ -726,7 +735,10 @@ export function deriveAgentPanelModel({ return EMPTY_PANEL_MODEL; } - const workflows = source.filter((agent) => agent.kind === "workflow"); + const workflows = source + .filter((agent) => agent.kind === "workflow") + .slice() + .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)); const workflowIds = new Set(workflows.map((workflow) => workflow.id)); const members = new Map(); const direct: RuntimeSubagent[] = []; @@ -827,7 +839,11 @@ export function deriveAgentPanelModel({ return { workflows: workflowGroups, - directAgents: direct.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), + // Updates and the >100-agent retention ranking must never reshuffle rows + // that remain visible. + directAgents: direct + .slice() + .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)), runningCount, waitingCount, idleCount, diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index 874bcc30ebdf..6acc3b5d8a4f 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -26,6 +26,16 @@ const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; * WebSocket subscription's first frame. The response is gzip-compressible by * the transport and keeps the (potentially multi-KB) snapshot off the socket. */ +/** + * Optional turn window for a snapshot fetch. Only send a window to servers + * that advertise `threadSnapshotPagination`; older servers reject unknown + * query parameters. + */ +export interface ThreadSnapshotWindow { + readonly turnLimit: number; + readonly beforeCursor?: string; +} + export const fetchEnvironmentThreadSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentThreadSnapshot", )(function* (input: { @@ -33,6 +43,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( readonly threadId: ThreadId; readonly signer: Option.Option; readonly timeoutMs?: number; + readonly window?: ThreadSnapshotWindow; }) { const requestUrl = environmentEndpointUrl( input.prepared.httpBaseUrl, @@ -52,6 +63,12 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( input.prepared.httpAuthorization, client.orchestration.threadSnapshot({ params: { threadId: input.threadId }, + payload: { + ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}), + ...(input.window?.beforeCursor !== undefined + ? { beforeCursor: input.window.beforeCursor } + : {}), + }, headers, }), ), @@ -72,6 +89,7 @@ export class ThreadSnapshotLoader extends Context.Service< readonly load: ( prepared: PreparedConnection, threadId: ThreadId, + window?: ThreadSnapshotWindow, ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {} @@ -89,8 +107,13 @@ export const threadSnapshotLoaderLayer: Layer.Layer< // connections work without one). const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); return ThreadSnapshotLoader.of({ - load: (prepared: PreparedConnection, threadId: ThreadId) => - fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe( + load: (prepared: PreparedConnection, threadId: ThreadId, window?: ThreadSnapshotWindow) => + fetchEnvironmentThreadSnapshot({ + prepared, + threadId, + signer, + ...(window !== undefined ? { window } : {}), + }).pipe( Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), // A genuinely missing thread (404) is expected — the socket diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 89be139e9256..8ba9696ec576 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -3,14 +3,38 @@ import * as Option from "effect/Option"; export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; +/** + * Pagination state for a windowed thread. Present only when the loaded thread + * is a window (the server returned `page` metadata); absent means the thread is + * fully loaded — either the server predates pagination or the window reached + * the top. + */ +export interface EnvironmentThreadPageState { + /** Opaque exclusive cursor for the next older slice; null when fully loaded. */ + readonly beforeCursor: string | null; + readonly hasMore: boolean; + /** True while an older page fetch is in flight. */ + readonly loadingOlder: boolean; +} + export interface EnvironmentThreadState { readonly data: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; + readonly page: Option.Option; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { data: Option.none(), status: "empty", error: Option.none(), + page: Option.none(), }; + +/** Whether the thread has older turns that can be loaded with more pages. */ +export function threadHasOlderTurns(state: EnvironmentThreadState): boolean { + return Option.match(state.page, { + onNone: () => false, + onSome: (page) => page.hasMore, + }); +} diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts new file mode 100644 index 000000000000..62cad18f89e0 --- /dev/null +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -0,0 +1,543 @@ +import { + EnvironmentId, + EventId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationMessage, + type OrchestrationThread, + type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; +import type { ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; +import { + INITIAL_THREAD_USER_TURN_LIMIT, + makeEnvironmentThreadState, + requestOlderThreadTurns, + ThreadSnapshotLoader, + type EnvironmentThreadState, +} from "./threads.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const THREAD_ID = ThreadId.make("thread-1"); +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: TARGET.wsBaseUrl, + httpAuthorization: null, + target: TARGET, +}; + +function message(id: string, turnId: string, createdAt: string): OrchestrationMessage { + return { + id: id as OrchestrationMessage["id"], + role: "assistant", + text: `text of ${id}`, + turnId: TurnId.make(turnId), + streaming: false, + createdAt, + updatedAt: createdAt, + }; +} + +const OLDER_MESSAGE = message("message-old", "turn-1", "2026-04-01T00:00:00.000Z"); +const RECENT_MESSAGE = message("message-recent", "turn-2", "2026-04-01T01:00:00.000Z"); + +// Reverts retain turns via checkpoints with checkpointTurnCount <= the revert's +// turnCount, so both fixture turns carry one: reverting to turnCount 1 keeps +// turn-1 (the older page's turn) and discards turn-2 (the loaded window's). +function checkpoint(turnId: string, turnCount: number): OrchestrationThread["checkpoints"][number] { + return { + turnId: TurnId.make(turnId), + checkpointTurnCount: turnCount, + checkpointRef: + `checkpoint-${turnCount}` as OrchestrationThread["checkpoints"][number]["checkpointRef"], + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-04-01T01:00:00.000Z", + }; +} + +const BASE_THREAD: OrchestrationThread = { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Windowed thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [RECENT_MESSAGE], + proposedPlans: [], + activities: [], + checkpoints: [checkpoint("turn-2", 2)], + session: null, +}; + +const WINDOWED_SNAPSHOT: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 10, + thread: BASE_THREAD, + page: { beforeCursor: "cursor-1", hasMore: true, snapshotSequence: 10 }, +}; + +const OLDER_PAGE: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 10, + thread: { + ...BASE_THREAD, + messages: [OLDER_MESSAGE], + checkpoints: [checkpoint("turn-1", 1)], + }, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 10 }, +}; + +type LoaderResponse = Option.Option; + +const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (options?: { + readonly paginationCapability?: boolean; + readonly initialResponse?: LoaderResponse; + /** Cached snapshot returned by the cache store (simulates a warm cache). */ + readonly cached?: OrchestrationThreadDetailSnapshot; +}) { + const inputs = yield* Queue.unbounded(); + const observed = yield* Queue.unbounded(); + const loaderWindows = yield* Ref.make>([]); + const lastSubscribeInput = yield* Ref.make | undefined>(undefined); + const savedThreads = yield* Ref.make>([]); + // Older-page responses resolve through deferreds so tests can interleave + // live events with an in-flight page fetch. + const pendingPageResponses = yield* Queue.unbounded>(); + const supervisorState = yield* SubscriptionRef.make( + AVAILABLE_CONNECTION_STATE, + ); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: Record) => + Stream.unwrap(Ref.set(lastSubscribeInput, input).pipe(Effect.as(Stream.fromQueue(inputs)))), + } as unknown as WsRpcProtocolClient; + const session: RpcSession.RpcSession = { + client, + initialConfig: Effect.succeed({ + threadSnapshotPagination: options?.paginationCapability !== false, + } as never), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + const supervisorSession = yield* SubscriptionRef.make>( + Option.some(session), + ); + const prepared = yield* SubscriptionRef.make>( + Option.some(PREPARED), + ); + const snapshotLoader = ThreadSnapshotLoader.of({ + load: (_prepared, _threadId, window) => + Ref.update(loaderWindows, (current) => [...current, window]).pipe( + Effect.andThen( + window?.beforeCursor === undefined + ? Effect.succeed( + options?.initialResponse ?? Option.none(), + ) + : Deferred.make().pipe( + Effect.tap((deferred) => Queue.offer(pendingPageResponses, deferred)), + Effect.flatMap(Deferred.await), + ), + ), + ), + }); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: supervisorSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => + Effect.succeed(options?.cached !== undefined ? Option.some(options.cached) : Option.none()), + saveThread: (_environmentId, thread) => + Ref.update(savedThreads, (current) => [...current, thread]), + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ThreadSnapshotLoader, snapshotLoader), + ); + yield* SubscriptionRef.changes(threadState).pipe( + Stream.runForEach((state) => Queue.offer(observed, state)), + Effect.forkScoped, + ); + + const awaitState = (predicate: (state: EnvironmentThreadState) => boolean) => + Queue.take(observed).pipe(Effect.repeat({ until: predicate })); + const resolveNextPage = (response: LoaderResponse) => + Queue.take(pendingPageResponses).pipe( + Effect.flatMap((deferred) => Deferred.succeed(deferred, response)), + ); + + return { + inputs, + observed, + awaitState, + resolveNextPage, + loaderWindows, + lastSubscribeInput, + savedThreads, + threadState, + }; +}); + +const hasMessage = (state: EnvironmentThreadState, id: string): boolean => + Option.match(state.data, { + onNone: () => false, + onSome: (thread) => thread.messages.some((entry) => entry.id === id), + }); + +const titleEvent = (title: string, sequence: number): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make(`event-title-${sequence}`), + sequence, + occurredAt: "2026-04-01T01:30:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + title, + updatedAt: "2026-04-01T01:30:00.000Z", + }, + }, +}); + +// Reverting to turnCount 1 retains only turns whose checkpoint count is <= 1: +// turn-1 survives, turn-2 (the loaded window's newest turn) is discarded. +const revertEvent = (sequence: number): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make(`event-revert-${sequence}`), + sequence, + occurredAt: "2026-04-01T02:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.reverted", + payload: { + threadId: THREAD_ID, + turnCount: 1, + }, + }, +}); + +describe("thread pagination state", () => { + it.effect("windows the initial load when the server advertises pagination", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + const state = yield* harness.awaitState((value) => Option.isSome(value.page)); + expect(Option.getOrThrow(state.page)).toEqual({ + beforeCursor: "cursor-1", + hasMore: true, + loadingOlder: false, + }); + const windows = yield* Ref.get(harness.loaderWindows); + expect(windows[0]?.turnLimit).toBe(INITIAL_THREAD_USER_TURN_LIMIT); + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBe(INITIAL_THREAD_USER_TURN_LIMIT); + }), + ); + + it.effect("does not send a window to servers without the capability", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + paginationCapability: false, + initialResponse: Option.some({ snapshotSequence: 10, thread: BASE_THREAD }), + }); + const state = yield* harness.awaitState((value) => Option.isSome(value.data)); + expect(Option.isNone(state.page)).toBe(true); + const windows = yield* Ref.get(harness.loaderWindows); + expect(windows[0]).toBeUndefined(); + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBeUndefined(); + }), + ); + + it.effect("merges an older page below the loaded window and clears the cursor", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + expect(requestOlderThreadTurns(TARGET.environmentId, THREAD_ID)).toBe(true); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => hasMessage(value, "message-old")); + const thread = Option.getOrThrow(state.data); + // Older rows land before the loaded window's rows. + expect(thread.messages.map((entry) => entry.id)).toEqual(["message-old", "message-recent"]); + expect(Option.getOrThrow(state.page)).toEqual({ + beforeCursor: null, + hasMore: false, + loadingOlder: false, + }); + }), + ); + + it.effect("discards an in-flight older page when a revert rewrites history", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // Revert lands while the page fetch is in flight and removes turn-2. + yield* Queue.offer(harness.inputs, revertEvent(11)); + yield* harness.awaitState((value) => !hasMessage(value, "message-recent")); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + // The stale page was dropped: no resurrected rows, cursor unchanged. + expect(hasMessage(state, "message-old")).toBe(false); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + }), + ); + + it.effect("discards an in-flight older page when a fresh snapshot replaces the thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* Queue.offer(harness.inputs, { + kind: "snapshot", + snapshot: { + snapshotSequence: 20, + thread: { ...BASE_THREAD, title: "Replaced thread" }, + page: { beforeCursor: "cursor-2", hasMore: true, snapshotSequence: 20 }, + }, + }); + yield* harness.awaitState((value) => + Option.match(value.data, { + onNone: () => false, + onSome: (thread) => thread.title === "Replaced thread", + }), + ); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + expect(hasMessage(state, "message-old")).toBe(false); + // The replacement snapshot's cursor wins over the discarded page's. + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-2"); + }), + ); + + it.effect("discards an older page read from a projection behind the loaded state", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* harness.resolveNextPage(Option.some({ ...OLDER_PAGE, snapshotSequence: 5 })); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + expect(hasMessage(state, "message-old")).toBe(false); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + }), + ); + + it.effect("a merged history page never advances the live-event dedupe sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // The page was captured at a newer projection sequence (12) than the + // loaded state (10); merging it must not swallow events 11-12. + yield* harness.resolveNextPage( + Option.some({ + ...OLDER_PAGE, + snapshotSequence: 12, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 12 }, + }), + ); + yield* harness.awaitState((value) => hasMessage(value, "message-old")); + + // Event at sequence 11 must still apply after the merge: the revert + // discards turn-2, so the loaded window's row disappears while the + // merged older turn-1 row survives. If the merge had advanced the + // dedupe sequence to the page's 12, this event would be swallowed. + yield* Queue.offer(harness.inputs, revertEvent(11)); + const state = yield* harness.awaitState( + (value) => !hasMessage(value, "message-recent") && hasMessage(value, "message-old"), + ); + expect(hasMessage(state, "message-old")).toBe(true); + }), + ); + + it.effect("parks a page read ahead of the live state until events catch up", () => + Effect.gen(function* () { + // A page whose thread watermark is ahead of the loaded state may + // contain streaming content the subscription has not delivered yet + // (e.g. an out-of-window subagent turn mid-stream); merging it + // immediately and then replaying those deltas would duplicate text. + // The page parks until the live state reaches the watermark. + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // Page watermark 11 > loaded sequence 10: must park, not merge. + yield* harness.resolveNextPage( + Option.some({ + ...OLDER_PAGE, + snapshotSequence: 11, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 11, threadSequence: 11 }, + }), + ); + + // A live event at sequence 11 arrives; only then does the page merge. + yield* Queue.offer(harness.inputs, titleEvent("Advanced past watermark", 11)); + const state = yield* harness.awaitState((value) => hasMessage(value, "message-old")); + expect(hasMessage(state, "message-recent")).toBe(true); + expect(Option.getOrThrow(state.page).loadingOlder).toBe(false); + }), + ); + + it.effect("a revert keeps the page cursor and triggers no refresh fetch", () => + Effect.gen(function* () { + // Cursors are an (anchor, turnId) keyset derived from event content, so + // they survive the revert projector's row rewrite: the machine keeps + // the stored cursor and performs no snapshot re-fetch. The revert + // reducer's turn filtering alone handles loaded history. + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + yield* Queue.offer(harness.inputs, revertEvent(11)); + const state = yield* harness.awaitState((value) => !hasMessage(value, "message-recent")); + + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + const windows = yield* Ref.get(harness.loaderWindows); + // Only the initial load hit the loader — no post-revert refresh fetch. + expect(windows.length).toBe(1); + }), + ); + + it.effect("drops a windowed cache when the server lacks the pagination capability", () => + Effect.gen(function* () { + // Resuming a windowed cache via afterSequence against a pre-pagination + // server would render only the window forever with no way to load the + // rest: the machine must discard the cache and take a full snapshot. + const fullSnapshot: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 20, + thread: { ...BASE_THREAD, title: "Full reload" }, + }; + const harness = yield* makeHarness({ + paginationCapability: false, + cached: WINDOWED_SNAPSHOT, + initialResponse: Option.some(fullSnapshot), + }); + + const state = yield* harness.awaitState((value) => + Option.match(value.data, { + onNone: () => false, + onSome: (thread) => thread.title === "Full reload", + }), + ); + expect(Option.isNone(state.page)).toBe(true); + // The subscription resumed from the fresh full snapshot, not the + // discarded windowed cache's watermark, and sent no window fields. + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBeUndefined(); + expect(subscribeInput?.afterSequence).toBe(20); + }), + ); + + it.effect("keeps a windowed cache when the server supports pagination", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: WINDOWED_SNAPSHOT }); + const state = yield* harness.awaitState((value) => Option.isSome(value.page)); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + // Wait for the subscription (recorded when the WS method is invoked) + // before asserting its input. + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput).pipe( + Effect.repeat({ until: (input) => input !== undefined }), + ); + expect(subscribeInput?.afterSequence).toBe(10); + }), + ); +}); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 06b5428ca58d..4ba5a0e9df18 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -2,15 +2,18 @@ import { ORCHESTRATION_WS_METHODS, type EnvironmentId as EnvironmentIdType, type OrchestrationThread, + type OrchestrationThreadDetailPage, type OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, type ThreadId as ThreadIdType, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; @@ -21,13 +24,14 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; -import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +import { ThreadSnapshotLoader, type ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadPageState, type EnvironmentThreadState, type EnvironmentThreadStatus, } from "./threadState.ts"; @@ -36,6 +40,85 @@ function statusWithoutLiveData(data: Option.Option): Enviro return Option.isSome(data) ? "cached" : "empty"; } +/** + * Turn window sizes for paginated thread loads: the initial page covers the + * last 10 user-anchored turns (subagent/fan-out turns ride along), each + * "load earlier" tap fetches 20 more. Sized so first paint on the heaviest + * observed threads stays around 100K gzipped while median threads load fully. + */ +export const INITIAL_THREAD_USER_TURN_LIMIT = 10; +export const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20; + +function pageStateFromSnapshot( + page: OrchestrationThreadDetailPage | undefined, +): Option.Option { + return page === undefined + ? Option.none() + : Option.some({ + beforeCursor: page.beforeCursor, + hasMore: page.hasMore, + loadingOlder: false, + }); +} + +interface ThreadOlderTurnRequestRegistry { + /** + * Registers the live state machine for a thread. Returns the deregistration + * cleanup; registration lives exactly as long as the machine's scope, and a + * successor machine for the same thread simply replaces the entry. + */ + readonly register: (key: string, handler: () => void) => () => void; + readonly request: (key: string) => boolean; +} + +function makeThreadOlderTurnRequestRegistry(): ThreadOlderTurnRequestRegistry { + const handlers = new Map void>(); + return { + register: (key, handler) => { + handlers.set(key, handler); + return () => { + if (handlers.get(key) === handler) { + handlers.delete(key); + } + }; + }, + request: (key) => { + const handler = handlers.get(key); + if (handler === undefined) { + return false; + } + handler(); + return true; + }, + }; +} + +const defaultOlderTurnRequestRegistry = makeThreadOlderTurnRequestRegistry(); + +/** + * Channel from UI actions to the live per-thread state machines. The machines + * resolve it from the Effect environment (overridable in tests); the default + * instance is shared with the sync `requestOlderThreadTurns` entry point so + * the apps get working wiring without providing anything. + */ +export class ThreadOlderTurnRequests extends Context.Reference( + "@t3tools/client-runtime/state/threads/ThreadOlderTurnRequests", + { defaultValue: () => defaultOlderTurnRequestRegistry }, +) {} + +/** + * Asks the live state machine for `threadId` to fetch the next older page. + * Returns false when no machine is live or no fetch was started (no cursor, + * already loading); callers render from `EnvironmentThreadState.page` and can + * treat false as "nothing to do". + */ +export function requestOlderThreadTurns( + environmentId: EnvironmentIdType, + threadId: ThreadIdType, +): boolean { + return defaultOlderTurnRequestRegistry.request(threadKey({ environmentId, threadId })); +} + function formatThreadError(cause: Cause.Cause): string { const error = Cause.squash(cause); return error instanceof Error && error.message.trim().length > 0 @@ -73,6 +156,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make data: cachedThread, status: statusWithoutLiveData(cachedThread), error: Option.none(), + // A cached windowed snapshot restores its page cursor so "load earlier" + // works while rendering from cache; a cached full snapshot has no page. + page: Option.flatMap(cached, (snapshot) => pageStateFromSnapshot(snapshot.page)), }); // Seed the resume cursor from the cached snapshot so a warm cache can catch up // via `afterSequence` instead of re-downloading the full thread body. @@ -80,6 +166,25 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); const awaitingCompletion = yield* Ref.make(false); + // Bumped whenever loaded history may have been rewritten out from under an + // in-flight older-page fetch (snapshot replacement, revert, deletion). A + // page response captured under an older epoch is discarded, not merged. + const historyEpoch = yield* Ref.make(0); + // Serializes stream-item application against older-page staleness checks + + // merges. Without it, a revert or snapshot processed between loadOlderTurns' + // epoch check and its merge could still slip resurrected history in. + const applyLock = yield* Semaphore.make(1); + // Whether the connected server accepts windowed reads; set per subscription + // from the session config. Gates loadOlderTurns so a reconnect to a + // pre-pagination server never sends unsupported window parameters. + const paginationSupported = yield* Ref.make(false); + // An older page whose thread watermark is ahead of the live state, parked + // until the subscription catches up (see mergeOlderPage's caller). At most + // one can exist because loadOlderTurns no-ops while loadingOlder is true. + const pendingOlderPage = yield* Ref.make<{ + readonly snapshot: OrchestrationThreadDetailSnapshot; + readonly epoch: number; + } | null>(null); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( @@ -124,6 +229,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); const setDisconnected = Effect.gen(function* () { yield* Ref.set(awaitingCompletion, false); + // The capability belongs to the session that advertised it. During a + // reconnect, a new prepared connection can exist before the new session's + // config arrives; leaving the old value would let loadOlderTurns send + // window parameters to a server that may not accept them (review + // finding). makeSubscribeInput re-sets it from the next session's config. + yield* Ref.set(paginationSupported, false); yield* SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), @@ -143,28 +254,51 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( thread: OrchestrationThread, + // "keep" preserves the current page state (live events touch only loaded + // recent turns); a snapshot or merged page passes its own page state. + page: Option.Option | "keep", ) { const waiting = yield* Ref.get(awaitingCompletion); - yield* SubscriptionRef.set(state, { + yield* SubscriptionRef.update(state, (current) => ({ data: Option.some(thread), - status: waiting ? "synchronizing" : "live", + status: waiting ? ("synchronizing" as const) : ("live" as const), error: Option.none(), - }); + page: page === "keep" ? current.page : page, + })); // Active threads can update many times per second and retain large tool // payloads. The server remains the source of truth while a turn is active; // persist once it settles so cache encoding stays off the streaming path. if (shouldPersistThread(thread)) { const snapshotSequence = yield* SubscriptionRef.get(lastSequence); - yield* Queue.offer(persistence, { snapshotSequence, thread }); + const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page)); + yield* Queue.offer(persistence, { + snapshotSequence, + thread, + // Persist the window boundary with the window's content so a cache + // restore can keep paging from where the loaded history ends. + ...Option.match(currentPage, { + onNone: () => ({}), + onSome: (value) => + ({ + page: { + beforeCursor: value.beforeCursor, + hasMore: value.hasMore, + snapshotSequence, + }, + }) as const, + }), + }); } }); const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); yield* SubscriptionRef.set(state, { data: Option.none(), status: "deleted", error: Option.none(), + page: Option.none(), }); yield* cache.removeThread(environmentId, threadId).pipe( Effect.catch((error) => @@ -179,7 +313,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); }); - const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( + // Body of applyItem, running under applyLock. + const applyItemLocked = Effect.fn("EnvironmentThreadState.applyItemLocked")(function* ( item: OrchestrationThreadStreamItem, ) { if (item.kind === "synchronized") { @@ -193,8 +328,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } if (item.kind === "snapshot") { + // A fresh snapshot replaces all loaded history, including older + // pages: a turn reverted while disconnected would otherwise survive + // in the preserved history with no event left to remove it. The + // epoch bump discards any older-page fetch racing this snapshot. + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); - yield* setThread(item.snapshot.thread); + yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page)); return; } @@ -211,12 +351,184 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } return; } + if (item.event.type === "thread.reverted") { + // A revert rewrites loaded history (whole turns disappear), so an + // older-page fetch in flight may straddle the removed range; the epoch + // bump discards it. The stored page cursor stays valid: cursors are an + // (anchor, turnId) keyset derived from event content, which survives + // the revert projector's row rewrite, so no refresh is needed — the + // revert reducer's turn filtering fully handles loaded history. + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + } const result = applyThreadDetailEvent(current.data.value, item.event); if (result.kind === "updated") { - yield* setThread(result.thread); + yield* setThread(result.thread, "keep"); } else if (result.kind === "deleted") { yield* setDeleted(); } + // The event may have advanced the live state past a parked page's + // watermark; merge it as soon as that happens. + yield* tryMergePendingOlderPage(); + }); + + // Merges a parked older page once the live state has caught up to the + // page's thread watermark, or discards it if history was rewritten + // (epoch advanced) while it waited. Must run under applyLock. + const tryMergePendingOlderPage = Effect.fn("EnvironmentThreadState.tryMergePendingOlderPage")( + function* () { + const pending = yield* Ref.get(pendingOlderPage); + if (pending === null) { + return; + } + const epochNow = yield* Ref.get(historyEpoch); + if (epochNow !== pending.epoch) { + yield* Ref.set(pendingOlderPage, null); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })), + })); + return; + } + const watermark = pending.snapshot.page?.threadSequence; + const loadedSequence = yield* SubscriptionRef.get(lastSequence); + if (watermark !== undefined && watermark > loadedSequence) { + return; + } + yield* Ref.set(pendingOlderPage, null); + yield* mergeOlderPage(pending.snapshot); + }, + ); + + const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( + item: OrchestrationThreadStreamItem, + ) { + yield* applyLock.withPermits(1)(applyItemLocked(item)); + }); + + // Merges an older disjoint page below the currently loaded window. All four + // windowed collections prepend; identity dedupe guards the (server-bug or + // cursor-misuse) case of overlapping pages so a row never renders twice. + const mergeOlderPage = Effect.fn("EnvironmentThreadState.mergeOlderPage")(function* ( + snapshot: OrchestrationThreadDetailSnapshot, + ) { + // The merge is built inside the update callback so it composes with + // whatever thread value is current at commit time. The applyLock already + // serializes this against event application; the atomic build is defense + // in depth against future callers outside the lock. + let merged: OrchestrationThread | null = null; + yield* SubscriptionRef.update(state, (value) => { + if (Option.isNone(value.data)) { + return value; + } + const loaded = value.data.value; + const older = snapshot.thread; + const mergeById = ( + olderRows: ReadonlyArray, + loadedRows: ReadonlyArray, + ): ReadonlyArray => { + const seen = new Set(loadedRows.map((row) => row.id)); + return [...olderRows.filter((row) => !seen.has(row.id)), ...loadedRows]; + }; + const seenCheckpoints = new Set(loaded.checkpoints.map((row) => row.turnId)); + merged = { + // Thread metadata stays the loaded (newer) snapshot's; only the + // windowed collections gain rows from the older page. + ...loaded, + messages: mergeById(older.messages, loaded.messages), + activities: mergeById(older.activities, loaded.activities), + proposedPlans: mergeById(older.proposedPlans, loaded.proposedPlans), + checkpoints: [ + ...older.checkpoints.filter((row) => !seenCheckpoints.has(row.turnId)), + ...loaded.checkpoints, + ], + }; + return { + ...value, + data: Option.some(merged), + page: pageStateFromSnapshot(snapshot.page), + }; + }); + // Persist the widened window under the *loaded* watermark: the merged + // content is only known consistent with the state it merged into, not + // with the page's own (possibly newer) sequence. + if (merged !== null && shouldPersistThread(merged)) { + const snapshotSequence = yield* SubscriptionRef.get(lastSequence); + yield* Queue.offer(persistence, { + snapshotSequence, + thread: merged, + ...(snapshot.page === undefined ? {} : { page: { ...snapshot.page, snapshotSequence } }), + }); + } + }); + + const loadOlderTurns = Effect.fn("EnvironmentThreadState.loadOlderTurns")(function* () { + // Gated on the connected server's capability: a reconnect to a + // pre-pagination server must never receive window parameters. + if (!(yield* Ref.get(paginationSupported))) { + return; + } + const current = yield* SubscriptionRef.get(state); + const page = Option.getOrNull(current.page); + if (page === null || page.loadingOlder || !page.hasMore || page.beforeCursor === null) { + return; + } + const prepared = Option.getOrNull(yield* SubscriptionRef.get(supervisor.prepared)); + if (prepared === null) { + return; + } + const epochAtStart = yield* Ref.get(historyEpoch); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: true })), + })); + const window: ThreadSnapshotWindow = { + turnLimit: OLDER_THREAD_PAGE_USER_TURN_LIMIT, + beforeCursor: page.beforeCursor, + }; + const response = yield* snapshotLoader.load(prepared, threadId, window); + // Staleness check and merge run under the same lock as stream-item + // application, so a revert/snapshot cannot land between them (TOCTOU + // review finding) — anything that rewrites history bumps the epoch + // before this permit is acquired. + yield* applyLock.withPermits(1)( + Effect.gen(function* () { + const epochNow = yield* Ref.get(historyEpoch); + const loadedSequence = yield* SubscriptionRef.get(lastSequence); + // A page carrying a sequence older than the loaded state was read + // from a projection behind what we render; merging it could + // resurrect turns a newer snapshot or revert already removed. + const stale = + epochNow !== epochAtStart || + Option.match(response, { + onNone: () => false, + onSome: (snapshot) => snapshot.snapshotSequence < loadedSequence, + }); + if (Option.isNone(response) || stale) { + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })), + })); + return; + } + // A page read AHEAD of the live state may include content (e.g. + // streaming deltas of an out-of-window turn) the subscription has + // not delivered yet; merging now and then replaying those events + // would duplicate them. Park the page until the live state reaches + // the page's thread-scoped watermark; loadingOlder stays true so + // the UI shows progress and no second fetch starts. Pages from + // pre-watermark servers (threadSequence absent) merge immediately, + // preserving the old behavior. + const watermark = response.value.page?.threadSequence; + if (watermark !== undefined && watermark > loadedSequence) { + yield* Ref.set(pendingOlderPage, { + snapshot: response.value, + epoch: epochNow, + }); + return; + } + yield* mergeOlderPage(response.value); + }), + ); }); yield* SubscriptionRef.changes(supervisor.state).pipe( @@ -244,14 +556,40 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeThread, Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) { - const supportsCompletionMarker = yield* session.initialConfig.pipe( - Effect.map((config) => config.threadResumeCompletionMarker === true), - Effect.orElseSucceed(() => false), + const config = yield* session.initialConfig.pipe( + Effect.orElseSucceed( + () => + ({}) as { + threadResumeCompletionMarker?: boolean; + threadSnapshotPagination?: boolean; + }, + ), ); + const supportsCompletionMarker = config.threadResumeCompletionMarker === true; + // Windowed loads are gated on the server capability: pre-pagination + // servers reject unknown query params, and a windowed WS fallback to + // such a server would silently hide history. + const supportsPagination = config.threadSnapshotPagination === true; + yield* Ref.set(paginationSupported, supportsPagination); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; let current = yield* SubscriptionRef.get(state); + // A windowed cache resuming against a server without pagination is a + // trap: afterSequence resume keeps only the window, and the missing + // older turns can never be loaded (the server has no cursor reads). + // Drop the window marker and treat the data as needing a full reload. + if (!supportsPagination && Option.isSome(current.page)) { + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + data: Option.none(), + status: value.status === "deleted" ? value.status : ("empty" as const), + page: Option.none(), + })); + yield* SubscriptionRef.set(lastSequence, 0); + current = yield* SubscriptionRef.get(state); + } if (Option.isNone(current.data) && current.status !== "deleted") { const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( Effect.flatMap( @@ -267,7 +605,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }), ), ); - const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); + const httpSnapshot = yield* snapshotLoader.load( + prepared, + threadId, + supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : undefined, + ); if (Option.isSome(httpSnapshot)) { yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); current = yield* SubscriptionRef.get(state); @@ -288,6 +630,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make threadId, ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + // The WS fallback snapshot (sent when afterSequence is missing or + // the gap is too large) should be windowed the same as the HTTP + // path; without this a resume failure re-downloads the full thread. + ...(supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : {}), }; }), { @@ -298,13 +644,47 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ).pipe(Stream.runForEach(applyItem)), ); + // Expose loadOlderTurns to UI actions through the request registry. + // Requests funnel through a sliding queue drained serially, so mashing + // "load earlier" coalesces (loadOlderTurns itself no-ops while a fetch is + // in flight). + const olderTurnRequestRegistry = yield* ThreadOlderTurnRequests; + const olderTurnRequests = yield* Queue.sliding(1); + yield* Stream.fromQueue(olderTurnRequests).pipe( + Stream.runForEach(() => loadOlderTurns()), + Effect.forkScoped, + ); + const deregister = olderTurnRequestRegistry.register( + threadKey({ environmentId, threadId }), + () => { + Queue.offerUnsafe(olderTurnRequests, undefined); + }, + ); + yield* Effect.addFinalizer(() => Effect.sync(deregister)); + yield* Effect.addFinalizer(() => Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe( Effect.flatMap(([current, snapshotSequence]) => Option.match(current.data, { onNone: () => Effect.void, onSome: (thread) => - shouldPersistThread(thread) ? persist({ snapshotSequence, thread }) : Effect.void, + shouldPersistThread(thread) + ? persist({ + snapshotSequence, + thread, + ...Option.match(current.page, { + onNone: () => ({}), + onSome: (page) => + ({ + page: { + beforeCursor: page.beforeCursor, + hasMore: page.hasMore, + snapshotSequence, + }, + }) as const, + }), + }) + : Effect.void, }), ), ), diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc4..f385a2eff2c9 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -457,6 +457,16 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +// Query-string window for windowed thread snapshots (GET payloads must encode +// to strings). Both fields optional: omitting them keeps the full-snapshot +// behavior, so pagination stays opt-in per request. +const EnvironmentOrchestrationThreadSnapshotQuery = { + turnLimit: Schema.optional( + Schema.FiniteFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)), + ), + beforeCursor: Schema.optional(TrimmedNonEmptyString), +}; + export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", { @@ -476,6 +486,7 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr HttpApiEndpoint.get("threadSnapshot", "/api/orchestration/threads/:threadId", { headers: OptionalBearerHeaders, params: EnvironmentOrchestrationThreadSnapshotParams, + payload: EnvironmentOrchestrationThreadSnapshotQuery, success: OrchestrationThreadDetailSnapshot, error: EnvironmentOrchestrationThreadSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7a8fc657fb2e..c753d53be303 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -12,6 +12,7 @@ import { IsoDateTime, MessageId, NonNegativeInt, + PositiveInt, ProjectId, ProviderItemId, ThreadId, @@ -465,6 +466,20 @@ export const OrchestrationThreadShell = Schema.Struct({ * live work. Optional so old servers/clients interop; absent = none. */ backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))), + /** + * Current plan step while a turn runs, for the Working indicators + * (sidebar row, in-chat working line). Cleared when the turn settles — + * never persists as stale UI. Optional so old servers/clients interop. + */ + planProgress: Schema.optional( + Schema.NullOr( + Schema.Struct({ + step: TrimmedNonEmptyString, + completedSteps: NonNegativeInt, + totalSteps: NonNegativeInt, + }), + ), + ), }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; @@ -545,12 +560,62 @@ export const OrchestrationSubscribeThreadInput = Schema.Struct({ * snapshot or catch-up replay and before it begins emitting live events. */ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * When provided, the fallback snapshot frame (sent when `afterSequence` is + * missing or the catch-up gap is too large) is windowed to the last + * `turnLimit` user-anchored turns and carries `page` metadata. Absent means + * the fallback snapshot is the full thread, preserving pre-pagination client + * behavior. Live events are unaffected either way. + */ + turnLimit: Schema.optionalKey(PositiveInt), }); export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; +/** + * Bounds a thread detail read to a window of recent turns. `turnLimit` counts + * turns with a user pending message (subagent/fan-out turns between them ride + * along), so the window always contains the last N user prompts. `beforeCursor` + * requests the disjoint page of older turns strictly before a previously + * returned cursor. Requests without a window get the full thread; pagination is + * strictly opt-in so older clients keep today's behavior on both HTTP and the + * WebSocket fallback snapshot. + */ +export const OrchestrationThreadDetailWindow = Schema.Struct({ + turnLimit: Schema.optionalKey(PositiveInt), + beforeCursor: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type OrchestrationThreadDetailWindow = typeof OrchestrationThreadDetailWindow.Type; + +/** + * Page metadata for a windowed thread detail read. `beforeCursor` is opaque and + * exclusive: passing it back returns the adjacent disjoint slice of older + * turns. `null` means the thread is fully loaded below this page. The + * `snapshotSequence` mirrors the top-level snapshot sequence so history pages + * can be sequence-checked against live state before merging. + */ +export const OrchestrationThreadDetailPage = Schema.Struct({ + beforeCursor: Schema.NullOr(TrimmedNonEmptyString), + hasMore: Schema.Boolean, + snapshotSequence: NonNegativeInt, + /** + * Highest event sequence applied to THIS thread at page read time. The + * global `snapshotSequence` advances with every thread's events, so a + * client cannot wait for it via its per-thread subscription; this + * thread-scoped watermark is reachable. A client merging an older page + * must first have applied live events up to it — otherwise a streaming + * turn outside the loaded window could have deltas replayed on top of + * page content that already includes them, duplicating text. + */ + threadSequence: Schema.optionalKey(NonNegativeInt), +}); +export type OrchestrationThreadDetailPage = typeof OrchestrationThreadDetailPage.Type; + export const OrchestrationThreadDetailSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, thread: OrchestrationThread, + // Present only on windowed responses. Absent on full snapshots (and from + // pre-pagination servers), which clients treat as fully loaded. + page: Schema.optional(OrchestrationThreadDetailPage), }); export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 20b40dffa755..d7bc4c5c1898 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -434,6 +434,12 @@ export const ServerConfig = Schema.Struct({ shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * Whether thread detail reads accept a turn window (`turnLimit`/ + * `beforeCursor`) and return `page` metadata. Clients must not send window + * fields to servers that don't advertise this. + */ + threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), }); export type ServerConfig = typeof ServerConfig.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9a020ce1bbda..f5d50e9f3472 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -111,7 +111,6 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ - autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -168,6 +167,10 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + // Legacy plan mode. The composer's Build/Plan toggle was removed from the + // default UI; this beta flag restores it (plus the /plan and /default slash + // commands) for users who still rely on the old workflow. + planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -944,7 +947,6 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ - autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), @@ -980,6 +982,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + planModeEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 4bd1070bf1f1..c58395393d37 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -21,6 +21,7 @@ import { makeTraceSink, type TraceRecord, type TraceSinkFlushStats, + truncateTraceAttributes, } from "./observability.ts"; describe("errorTag", () => { @@ -111,6 +112,31 @@ const makeTestLayer = (tracePath: string) => const nodeServicesIt = it.layer(NodeServices.layer); +describe("truncateTraceAttributes", () => { + it("clamps oversized strings at any depth without mutating the input", () => { + const stack = "s".repeat(2_000); + const attributes = { + "db.query.text": "q".repeat(2_000), + short: "ok", + error: { name: "Error", stack, nested: ["a".repeat(2_000)] }, + }; + const truncated = truncateTraceAttributes(attributes); + + assert.equal((truncated["db.query.text"] as string).length, 200 + "…[truncated]".length); + assert.equal(truncated["short"], "ok"); + const error = truncated["error"] as { stack: string; nested: Array }; + assert.equal(error.stack.length, 500 + "…[truncated]".length); + assert.equal(error.nested[0]?.length, 500 + "…[truncated]".length); + // Input is untouched: the live span's attributes are shared. + assert.equal(attributes.error.stack, stack); + }); + + it("returns the same reference when nothing exceeds the limits", () => { + const attributes = { short: "ok", nested: { fine: "also ok" } }; + assert.equal(truncateTraceAttributes(attributes), attributes); + }); +}); + describe("observability", () => { it("normalizes circular arrays, maps, and sets without recursing forever", () => { const array: Array = ["alpha"]; diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index e0a7595865d9..67057c548806 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -248,6 +248,61 @@ function formatTraceExit(exit: Exit.Exit): EffectTraceRecord[" }; } +const TRACE_ATTRIBUTE_MAX_LENGTH = 500; +const TRACE_ATTRIBUTE_TRUNCATED_LENGTH = 200; +const TRACE_ATTRIBUTE_TRUNCATION_SUFFIX = "…[truncated]"; +const ALWAYS_TRUNCATED_TRACE_ATTRIBUTES: ReadonlySet = new Set(["db.query.text"]); + +// Clamps strings nested inside already-normalized attribute values (arrays and +// plain objects from normalizeJsonValue, e.g. an Error's `stack`). Returns the +// input reference when nothing was clamped. +function truncateNestedValue(value: unknown): unknown { + if (typeof value === "string") { + return value.length <= TRACE_ATTRIBUTE_MAX_LENGTH + ? value + : `${value.slice(0, TRACE_ATTRIBUTE_MAX_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; + } + if (Array.isArray(value)) { + const truncated = value.map(truncateNestedValue); + return truncated.some((entry, index) => entry !== value[index]) ? truncated : value; + } + if (isPlainObject(value)) { + let truncated: Record | undefined; + for (const [key, entry] of Object.entries(value)) { + const next = truncateNestedValue(entry); + if (next === entry) continue; + truncated ??= { ...value }; + truncated[key] = next; + } + return truncated ?? value; + } + return value; +} + +/** + * Clamps oversized attribute values on the serialized trace record so the file + * sink stays small, including strings nested inside arrays and objects (e.g. + * error stacks). Returns a new record when anything was clamped; never + * mutates the input (the live span's attributes are shared with other tracers). + */ +export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttributes { + let truncated: Record | undefined; + for (const [key, value] of Object.entries(attributes)) { + if (typeof value === "string" && ALWAYS_TRUNCATED_TRACE_ATTRIBUTES.has(key)) { + if (value.length <= TRACE_ATTRIBUTE_TRUNCATED_LENGTH) continue; + truncated ??= { ...attributes }; + truncated[key] = + `${value.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; + continue; + } + const next = truncateNestedValue(value); + if (next === value) continue; + truncated ??= { ...attributes }; + truncated[key] = next; + } + return truncated ?? attributes; +} + export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { const status = span.status as Extract; const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; @@ -263,16 +318,18 @@ export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { startTimeUnixNano: String(status.startTime), endTimeUnixNano: String(status.endTime), durationMs: Number(status.endTime - status.startTime) / 1_000_000, - attributes: compactTraceAttributes(Object.fromEntries(span.attributes)), + attributes: truncateTraceAttributes( + compactTraceAttributes(Object.fromEntries(span.attributes)), + ), events: span.events.map(([name, startTime, attributes]) => ({ name, timeUnixNano: String(startTime), - attributes: compactTraceAttributes(attributes), + attributes: truncateTraceAttributes(compactTraceAttributes(attributes)), })), links: span.links.map((link) => ({ traceId: link.span.traceId, spanId: link.span.spanId, - attributes: compactTraceAttributes(link.attributes), + attributes: truncateTraceAttributes(compactTraceAttributes(link.attributes)), })), exit: formatTraceExit(status.exit), }; diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 4888192098e1..756f8bd2d30d 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,6 +3,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -499,6 +500,54 @@ function resolveCommandCandidates( return Array.from(new Set(candidates)); } +// Session bootstrap resolves the same commands over and over, each PATH scan +// costing hundreds of 'shell.isExecutableFile' filesystem probes (tens of +// thousands per connect). Memoize the scan outcome per +// (platform, PATH, PATHEXT, command) for a short window: repeat scans hit the +// cache while any change to the search environment invalidates immediately. +// Explicit-path resolution is never cached - callers probe paths they have +// just written (e.g. managed binary installs). A "not-found" outcome is also +// cached for the TTL, so a just-installed binary can stay invisible for up to +// 30s unless resolved by explicit path. +// TTL expiry uses the monotonic clock (Clock.currentTimeNanos) so backward +// wall-clock adjustments cannot keep expired entries alive. +const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n; +const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512; +const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0); + +interface CommandResolutionCacheEntry { + readonly resolvedPath: string | null; + readonly expiresAtNanos: bigint; +} + +// The cache lives in the Effect environment (like HostProcessPlatform above) +// so tests and embedders can provide an isolated instance; the default is a +// single process-wide map shared by all consumers. +export const CommandResolutionCache = Context.Reference>( + "@t3tools/shared/shell/CommandResolutionCache", + { + defaultValue: () => new Map(), + }, +); + +function cacheCommandResolution( + cache: Map, + cacheKey: string, + resolvedPath: string | null, + nowNanos: bigint, +): void { + if (cache.size >= COMMAND_RESOLUTION_CACHE_MAX_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + cache.set(cacheKey, { + resolvedPath, + expiresAtNanos: nowNanos + COMMAND_RESOLUTION_CACHE_TTL_NANOS, + }); +} + const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( filePath: string, platform: NodeJS.Platform, @@ -546,6 +595,20 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat if (pathValue.length === 0) { return yield* new CommandResolutionError({ command, reason: "not-found" }); } + + const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join( + COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR, + ); + const cache = yield* CommandResolutionCache; + const nowNanos = yield* Clock.currentTimeNanos; + const cached = cache.get(cacheKey); + if (cached !== undefined && cached.expiresAtNanos > nowNanos) { + if (cached.resolvedPath === null) { + return yield* new CommandResolutionError({ command, reason: "not-found" }); + } + return cached.resolvedPath; + } + const pathEntries: string[] = []; for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { const pathEntry = stripWrappingQuotes(entry.trim()); @@ -558,10 +621,12 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat for (const candidate of commandCandidates) { const candidatePath = path.join(pathEntry, candidate); if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos); return candidatePath; } } } + cacheCommandResolution(cache, cacheKey, null, nowNanos); return yield* new CommandResolutionError({ command, reason: "not-found" }); }); diff --git a/patches/effect@4.0.0-beta.103.patch b/patches/effect@4.0.0-beta.103.patch index 561db6f52630..a46ccf9c9764 100644 --- a/patches/effect@4.0.0-beta.103.patch +++ b/patches/effect@4.0.0-beta.103.patch @@ -278,32 +278,43 @@ index b536d0a..12ffac0 100644 }).pipe(Effect.flatMap(() => Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 -@@ -687,20 +716,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun +@@ -687,20 +716,28 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun }; })); const defaultRetryPolicy = /*#__PURE__*/Schedule.min([/*#__PURE__*/Schedule.exponential(500, 1.5), /*#__PURE__*/Schedule.spaced(5000)]); -const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) { +const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) { let recievedPong = true; ++ let missedPongs = 0; const latch = Latch.makeUnsafe(); const reset = () => { recievedPong = true; ++ missedPongs = 0; latch.closeUnsafe(); }; - const onPong = () => { -+ const onPong = Effect.sync(() => { - recievedPong = true; +- recievedPong = true; - }; ++ const onPong = Effect.sync(() => { ++ recievedPong = true; ++ missedPongs = 0; + }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void)); yield* Effect.suspend(() => { - if (!recievedPong) return latch.open; - recievedPong = false; +- if (!recievedPong) return latch.open; +- recievedPong = false; - return writePing; ++ if (!recievedPong) { ++ missedPongs += 1; ++ if (missedPongs >= 3) return latch.open; ++ return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); ++ } ++ recievedPong = false; ++ missedPongs = 0; + return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); return { timeout: latch.await, -@@ -843,6 +872,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun +@@ -843,6 +880,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun * @since 4.0.0 */ export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PURE__*/Layer.effect(Protocol)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8f4ef7bf33b..50de49d96941 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,7 +76,7 @@ patchedDependencies: '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 - effect@4.0.0-beta.103: a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9 + effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 @@ -116,7 +116,7 @@ importers: version: 0.0.3 '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -134,7 +134,7 @@ importers: version: link:../../packages/tailscale effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) electron: specifier: 41.5.0 version: 41.5.0 @@ -153,7 +153,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -202,7 +202,7 @@ importers: version: 4.1.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.3)(scheduler@0.27.0) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 @@ -277,7 +277,7 @@ importers: version: 8.0.3 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) expo: specifier: ~56.0.12 version: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -425,7 +425,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -452,16 +452,16 @@ importers: version: 1.0.26 '@effect/platform-bun': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@factory/droid-sdk': specifier: ^0.2.0 version: 0.2.0 @@ -482,7 +482,7 @@ importers: version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -492,7 +492,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -549,7 +549,7 @@ importers: version: 3.2.2(react@19.2.6) '@effect/atom-react': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.6)(scheduler@0.27.0) '@formkit/auto-animate': specifier: ^0.9.0 version: 0.9.0 @@ -588,7 +588,7 @@ importers: version: 0.7.1 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) ghostty-web: specifier: ^0.4.0 version: 0.4.0 @@ -631,10 +631,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) @@ -682,7 +682,7 @@ importers: version: 3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -700,23 +700,23 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.65 - version: 2.0.0-beta.65(a455401069e1fee89f31a277c51247f6) + version: 2.0.0-beta.65(75567d8add6e3362e26fe00f83a97bee) drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -734,17 +734,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -759,11 +759,11 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -772,11 +772,11 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -785,17 +785,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -807,17 +807,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -838,7 +838,7 @@ importers: version: link:../contracts effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) jose: specifier: 'catalog:' version: 6.2.2 @@ -848,10 +848,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -869,14 +869,14 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -888,17 +888,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/shared': specifier: workspace:* version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -910,7 +910,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -922,7 +922,7 @@ importers: version: link:../packages/tailscale effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) pngjs: specifier: 7.0.0 version: 7.0.0 @@ -932,7 +932,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -11848,24 +11848,24 @@ snapshots: '@cursor/sdk-linux-x64': 1.0.26 '@cursor/sdk-win32-x64': 1.0.26 - '@distilled.cloud/aws@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/aws@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/util': 5.2.0 '@aws-sdk/credential-providers': 3.1062.0 '@aws-sdk/types': 3.973.10 - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@smithy/shared-ini-file-loader': 4.5.6 '@smithy/types': 4.14.3 '@smithy/util-base64': 4.4.6 aws4fetch: 1.0.20 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) fast-xml-parser: 5.8.0 - '@distilled.cloud/axiom@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/axiom@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@distilled.cloud/cloudflare-rolldown-plugin@0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1)': dependencies: @@ -11878,48 +11878,48 @@ snapshots: transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@alchemy.run/node-utils': 0.0.5 - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) workerd: 1.20260704.1 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@distilled.cloud/cloudflare-vite-plugin@0.13.10(8bf551e378e9cbc11f8bf1003fbc14a8)': + '@distilled.cloud/cloudflare-vite-plugin@0.13.10(f97c3167f1a1990dddb83bff73e575e5)': dependencies: - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - rolldown - workerd - '@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/core@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/core@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/neon@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/neon@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/planetscale@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/planetscale@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: @@ -11955,47 +11955,47 @@ snapshots: '@drizzle-team/brocli@0.12.0': {} - '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.3)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) react: 19.2.3 scheduler: 0.27.0 - '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.6)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/openapi-generator@4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) swagger2openapi: 7.0.8 transitivePeerDependencies: - encoding - '@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6)': + '@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) ioredis: 5.11.0 mime: 4.1.0 undici: 8.9.0 @@ -12003,14 +12003,14 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@cloudflare/workers-types': 5.20260726.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) pg: 8.22.0 pg-connection-string: 2.14.0 pg-cursor: 2.21.0(pg@8.22.0) @@ -12019,9 +12019,9 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@effect/tsgo-darwin-arm64@0.13.2': optional: true @@ -12054,9 +12054,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/vitest@4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@egjs/hammerjs@2.0.17': dependencies: @@ -15637,22 +15637,22 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.65(a455401069e1fee89f31a277c51247f6): + alchemy@2.0.0-beta.65(75567d8add6e3362e26fe00f83a97bee): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 '@clack/prompts': 0.11.0 - '@distilled.cloud/aws': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/aws': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(8bf551e378e9cbc11f8bf1003fbc14a8) - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/vitest': 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(f97c3167f1a1990dddb83bff73e575e5) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/vitest': 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@octokit/webhooks': 14.2.0 @@ -15662,7 +15662,7 @@ snapshots: '@types/aws-lambda': 8.10.161 aws4fetch: 1.0.20 capnweb: 0.6.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) fast-glob: 3.3.3 fast-xml-parser: 5.8.0 ink: 6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) @@ -15678,11 +15678,11 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) drizzle-kit: 1.0.0-rc.4 - drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -16705,15 +16705,15 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-sqlite-bun': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-sqlite-bun': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) expo-sqlite: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) mysql2: 3.22.4(@types/node@24.12.4) pg: 8.21.0 @@ -16733,7 +16733,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9): + effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6): dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0