Compare commits

..

No commits in common. "main" and "5.1.1" have entirely different histories.
main ... 5.1.1

259 changed files with 2972 additions and 5835 deletions

View file

@ -1,6 +0,0 @@
## Changes
- Thread rename and auto-name controls now prevent conflicting edits while work is in progress, and cancelling auto-name returns to the existing draft.
- Actions with known unmet prerequisites are now disabled before execution, including unavailable auto-name and selection rewrite actions.
- Thread operation errors no longer appear as entries in navigation lists.
- Resumed conversations now hide Codex Panel's internal context metadata while preserving their context attachments.

View file

@ -1,4 +0,0 @@
## Changes
- Thread references created with `/refer` now remain as readable, clickable links in conversation history and open the referenced thread in an available Codex Panel.
- Opening an existing thread in a new panel is now faster because resume no longer waits for background metadata and thread-list refreshes.

View file

@ -1,5 +0,0 @@
## Changes
- Updated compatibility for Codex CLI 0.145.0.
- Subagent conversations that support replies can now be continued directly from their panel.
- Fixed malformed links in exported thread archives.

View file

@ -2,7 +2,7 @@
Codex Panel brings Codex into an Obsidian sidebar. It keeps Codex threads beside your notes, helps you add vault context to prompts, and lets you handle approvals and file changes without switching windows.
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup.
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup. The plugin handles the Obsidian side of the workflow, while models, credentials, sandboxing, tools, hooks, and thread state stay with Codex.
![Codex Panel](assets/screenshot.webp)
@ -48,7 +48,7 @@ If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executab
A panel is an independent working surface with its own active thread and draft. Open multiple panels when different tasks should stay visible and separate. Regular Codex threads remain available after a panel closes; return to them from **Codex Panel: Open thread...**, panel history, or the Threads view.
For a one-off question alongside the current thread, use `/btw` to open a temporary side chat. To carry context between regular threads without merging their histories, use `/refer <thread> <message>`. The submitted message keeps a normal link to the referenced thread; clicking it opens the thread in an available Codex Panel, creating one when needed.
For a one-off question alongside the current thread, use `/btw` to open a temporary side chat. To carry context between regular threads without merging their histories, use `/refer <thread> <message>`.
### Bring in the right context
@ -56,6 +56,9 @@ The composer lets you point Codex to relevant material without pasting it into t
When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` resolve through its folder and date format. Paste or drop files to save them in the configured attachment folder and reference them from the same prompt. For material outside the vault, `/web <url> [message]` fetches readable page content and attaches it to the next turn without creating a note.
These references tell Codex what is relevant to the request; they do not change its vault-root working directory or its permissions.
Large transient references are bounded before they reach Codex, and the panel marks a web or thread reference when it had to truncate it.
### Guide a running turn
As a turn runs, the panel keeps the conversation together with requests that need your attention. You can answer questions, approve or reject actions, steer the turn with another message, or interrupt it. Plans, goals, tool activity, and agent work remain available when you need to inspect how the task is progressing.
@ -74,7 +77,7 @@ Use `/help` for the current slash command list.
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------- |
| `manifest.minAppVersion` | `1.12.0` | Minimum Obsidian desktop version declared for plugin loading. |
| `obsidian` API types | `1.12.3` | TypeScript API package used for compile-time checks; kept in the same minor as `manifest` baseline. |
| `codexAppServer.testedCliVersion` | `0.145.0` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
| `codexAppServer.testedCliVersion` | `0.144.5` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
Codex Panel depends on the experimental `codex app-server` API.

View file

@ -14,9 +14,7 @@ The panel may provide a narrow management surface when direct configuration of C
The panel may acquire prompt context through an explicit user action. `/web` fetches the requested page through Obsidian and attaches the extracted content as untrusted turn context; it is not a search surface or agent network policy.
Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context without adding Panel-owned metadata envelopes to Codex history. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer. Keep read-only compatibility for envelopes written by older Panel versions isolated at history projection boundaries.
Vault file references are prompt handoff data, not durable thread metadata. Keep their display state local; ordinary wikilinks remain represented by the user-authored message text. Thread references persist as ordinary, human-readable `codex://threads/` Markdown links, while their bounded transcript payload remains ephemeral turn context.
Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context, while persisting only a tightly bounded, context-body-free metadata descriptor needed to reconstruct panel display and archive metadata. Treat that fixed-schema envelope as inert reference metadata rather than user instructions. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer.
Panel settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it.
@ -56,8 +54,6 @@ Chat-visible state should have one authoritative owner. Components should consum
TanStack Query is the single panel-side owner of cached app-server resources. Features may project authoritative event results into that state, but should not introduce parallel cache or synchronization mechanisms. Partial read models are reconciled at explicit lifecycle boundaries rather than kept globally and continuously consistent.
Thread lifecycle changes should be projected from completed operation facts into the shared read model. When one user action changes multiple visible projections, publish them coherently without delaying live operational state or coupling independent panels.
Reads with different completeness requirements have separate lifecycles. Complete operation-local reads must not replace bounded shared history, and transient activity should come from its owning live state rather than forcing history refreshes.
Imperative UI bridges are allowed only where the host platform requires them. They should remain narrow boundary adapters, not a second UI composition system inside Preact-owned surfaces.
@ -72,9 +68,7 @@ Coordinate conflicting work at the narrowest shared semantic owner. Independent
Foreground reveal and focus are the narrow workspace-wide exception: the latest user intent wins without serializing unrelated panel or app-server work. Cleanup obligations created by a committed state transition must survive the UI action that initiated them.
Do not use explanatory copy as the default remedy for an unclear or incoherent interface. Before adding instructions, warnings, status text, validation messages, confirmations, or diagnostics, determine why the interface does not communicate the necessary behavior through its structure and state. Prefer improving information hierarchy, labels, defaults, available choices, affordances, validation timing, progress indication, and feedback. Add text only when irreducible information remains and it materially helps the user decide, act, or recover; do not expose implementation concepts merely because they explain the current behavior.
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Use the app-server direct-input capability when available, falling back to read-only subagent panels for older servers, while preserving parent and agent provenance for other specialized behavior.
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Treat their panels as read-only conversation surfaces while preserving their parent and agent provenance for future specialized behavior.
Mode-derived restrictions for the active panel thread must remain consistent across actions and UI. Keep connection state, turn busy state, and operations targeting another listed thread in their owning workflows.

View file

@ -1,7 +1,7 @@
{
"id": "codex-panel",
"name": "Codex Panel",
"version": "5.2.1",
"version": "5.1.1",
"minAppVersion": "1.12.0",
"description": "Codex in your sidebar.",
"author": "murashit",

430
package-lock.json generated
View file

@ -1,23 +1,19 @@
{
"name": "codex-panel",
"version": "5.2.1",
"version": "5.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-panel",
"version": "5.2.1",
"version": "5.1.1",
"license": "Apache-2.0",
"dependencies": {
"@tanstack/query-core": "^5.101.2",
"defuddle": "^0.19.1",
"diff": "^9.0.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-to-markdown": "^2.1.2",
"micromark": "^4.0.2",
"obsidian-daily-notes-interface": "^0.9.5",
"preact": "^10.29.7",
"unist-util-visit": "^5.1.0"
"preact": "^10.29.7"
},
"devDependencies": {
"@biomejs/biome": "^2.5.3",
@ -25,10 +21,9 @@
"@commitlint/config-conventional": "^21.2.0",
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"@types/mdast": "^4.0.4",
"@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10",
"concurrently": "^9.2.4",
"concurrently": "^10.0.3",
"conventional-changelog-conventionalcommits": "^10.2.1",
"conventional-commits-parser": "^7.1.0",
"esbuild": "^0.28.0",
@ -1932,9 +1927,9 @@
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3899,15 +3894,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@ -3933,12 +3919,6 @@
"@types/estree": "*"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
@ -4681,9 +4661,9 @@
"optional": true
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4932,131 +4912,56 @@
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
"integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"chalk": "5.6.2",
"rxjs": "7.8.2",
"shell-quote": "1.9.0",
"supports-color": "8.1.1",
"shell-quote": "1.8.4",
"supports-color": "10.2.2",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
"yargs": "18.0.0"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
"conc": "dist/bin/index.js",
"concurrently": "dist/bin/index.js"
},
"engines": {
"node": ">=18"
"node": ">=22"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/concurrently/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"node_modules/concurrently/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/concurrently/node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/concurrently/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/concurrently/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/concurrently/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/concurrently/node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/concurrently/node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/conventional-changelog-angular": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz",
@ -5444,15 +5349,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
@ -6104,9 +6000,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6170,9 +6066,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-n/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6394,9 +6290,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-obsidianmd/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -7720,16 +7616,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@ -8100,9 +7986,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [
{
@ -8722,16 +8608,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
"integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -8813,78 +8689,6 @@
"@xmldom/xmldom": "^0.9.10"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
"integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
"decode-named-character-reference": "^1.0.0",
"devlop": "^1.0.0",
"mdast-util-to-string": "^4.0.0",
"micromark": "^4.0.0",
"micromark-util-decode-numeric-character-reference": "^2.0.0",
"micromark-util-decode-string": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0",
"unist-util-stringify-position": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-phrasing": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
"integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-to-markdown": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
"integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
"longest-streak": "^3.0.0",
"mdast-util-phrasing": "^4.0.0",
"mdast-util-to-string": "^4.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-decode-string": "^2.0.0",
"unist-util-visit": "^5.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-to-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
"integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
@ -9167,28 +8971,6 @@
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-decode-string": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
"integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"decode-named-character-reference": "^1.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-decode-numeric-character-reference": "^2.0.0",
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-encode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
@ -10164,16 +9946,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -10484,9 +10256,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -10653,51 +10425,6 @@
"node": ">= 0.4"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@ -11344,61 +11071,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
"integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit-parents": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@ -12009,16 +11681,6 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "codex-panel",
"version": "5.2.1",
"version": "5.1.1",
"description": "Codex in your Obsidian sidebar.",
"main": "main.js",
"author": "murashit",
@ -46,10 +46,9 @@
"@commitlint/config-conventional": "^21.2.0",
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"@types/mdast": "^4.0.4",
"@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10",
"concurrently": "^9.2.4",
"concurrently": "^10.0.3",
"conventional-changelog-conventionalcommits": "^10.2.1",
"conventional-commits-parser": "^7.1.0",
"esbuild": "^0.28.0",
@ -66,12 +65,8 @@
"dependencies": {
"@tanstack/query-core": "^5.101.2",
"defuddle": "^0.19.1",
"diff": "^9.0.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-to-markdown": "^2.1.2",
"micromark": "^4.0.2",
"obsidian-daily-notes-interface": "^0.9.5",
"preact": "^10.29.7",
"unist-util-visit": "^5.1.0"
"preact": "^10.29.7"
}
}

View file

@ -30,6 +30,7 @@ import type { ThreadItemsListResponse } from "../../generated/app-server/v2/Thre
import type { ThreadListResponse } from "../../generated/app-server/v2/ThreadListResponse";
import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse";
import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse";
import type { ThreadRollbackResponse } from "../../generated/app-server/v2/ThreadRollbackResponse";
import type { ThreadSetNameResponse } from "../../generated/app-server/v2/ThreadSetNameResponse";
import type { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse";
import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse";
@ -86,6 +87,7 @@ export interface ClientResponseByMethod {
"thread/delete": ThreadDeleteResponse;
"thread/unsubscribe": ThreadUnsubscribeResponse;
"thread/unarchive": ThreadUnarchiveResponse;
"thread/rollback": ThreadRollbackResponse;
"thread/name/set": ThreadSetNameResponse;
"thread/settings/update": ThreadSettingsUpdateResponse;
"thread/turns/list": ThreadTurnsListResponse;

View file

@ -1,6 +1,6 @@
{
"codexAppServer": {
"testedCliVersion": "0.145.0",
"testedCliVersion": "0.144.5",
"typeGeneration": {
"arguments": ["app-server", "generate-ts", "--experimental"]
},

View file

@ -1,4 +0,0 @@
export interface AppServerExecutionContext {
readonly codexPath: string;
readonly vaultPath: string;
}

View file

@ -1,6 +1,12 @@
import { splitUtf8Context } from "../../domain/chat/context-budget";
import type { CodexInputItem } from "../../domain/chat/input";
import { turnContextSubmissionId } from "../../domain/chat/submission-id";
import { splitUtf8Context, utf8ByteLength } from "../../domain/chat/context-budget";
import {
boundedTurnContextManifest,
type TurnContextManifest,
type TurnContextManifestEntry,
turnContextManifestText,
turnContextSubmissionId,
} from "../../domain/chat/context-manifest";
import type { CodexInputItem, VaultFileReference } from "../../domain/chat/input";
type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original";
@ -23,12 +29,20 @@ export interface AppServerTurnInput {
const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800;
const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
export function toAppServerUserInput(input: readonly CodexInputItem[]): AppServerUserInput[] {
return input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
export function toAppServerUserInput(input: readonly CodexInputItem[], manifest: TurnContextManifest | null = null): AppServerUserInput[] {
const userInput = input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
if (manifest) {
userInput.push({ type: "text", text: `\n${turnContextManifestText(manifest)}`, text_elements: [] });
}
return userInput;
}
export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[], submissionId: string): AppServerTurnInput {
const additionalContext: Record<string, AppServerAdditionalContextEntry> = {};
const manifest: TurnContextManifestEntry[] = [];
const fileReferences = input.flatMap((item): VaultFileReference[] =>
item.type === "fileReference" && item.name && item.path ? [{ name: item.name, path: item.path }] : [],
);
const contexts = input.filter(
(item): item is Extract<CodexInputItem, { type: "additionalContext" }> =>
item.type === "additionalContext" && Boolean(item.key) && Boolean(item.value),
@ -39,6 +53,7 @@ export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[
const partAllocations = allocatedPartCounts(contexts);
for (const [contextIndex, item] of contexts.entries()) {
const id = `${turnContextSubmissionId(submissionId)}.${String(contextIndex).padStart(2, "0")}`;
const sourceBytes = utf8ByteLength(item.value);
const split = splitUtf8Context(item.value, ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES, partAllocations[contextIndex] ?? 1);
const partCount = split.parts.length;
split.parts.forEach((part, partIndex) => {
@ -58,9 +73,13 @@ export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[
].join("\n"),
};
});
if (item.attachment || split.includedBytes < sourceBytes) {
manifest.push(manifestEntry(item, id, partCount, sourceBytes, split.includedBytes));
}
}
const persistedManifest = boundedTurnContextManifest(submissionId, manifest, fileReferences);
return {
input: toAppServerUserInput(input),
input: toAppServerUserInput(input, persistedManifest),
...(Object.keys(additionalContext).length > 0 ? { additionalContext } : {}),
};
}
@ -96,6 +115,36 @@ function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServ
}
}
function manifestEntry(
item: Extract<CodexInputItem, { type: "additionalContext" }>,
id: string,
parts: number,
sourceBytes: number,
includedBytes: number,
): TurnContextManifestEntry {
const common = {
kind: item.attachment?.kind ?? "obsidian",
id,
parts,
sourceBytes,
includedBytes,
truncated: includedBytes < sourceBytes,
} as const;
if (item.attachment?.kind === "obsidian") {
return { ...common, kind: "obsidian", inlineExcerpts: item.attachment.inlineExcerpts };
}
if (item.attachment?.kind !== "referencedThread") return common;
return {
...common,
kind: "referencedThread",
threadId: item.attachment.threadId,
includedTurns: item.attachment.includedTurns,
turnLimit: item.attachment.turnLimit,
omittedTurns: item.attachment.omittedTurns,
truncated: common.truncated || item.attachment.truncated,
};
}
function safeKeyPart(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
return safe || "context";

View file

@ -21,7 +21,6 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived
archived: options.archived ?? false,
createdAt: finiteTimestamp(thread.createdAt),
updatedAt: finiteTimestamp(thread.updatedAt),
canAcceptDirectInput: typeof thread.canAcceptDirectInput === "boolean" ? thread.canAcceptDirectInput : null,
provenance: threadProvenance(thread),
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}),
};

View file

@ -1,3 +1,4 @@
import { fileReferencesFromManifest, referencedThreadFromManifest, userMessageContextProjection } from "../../domain/chat/context-manifest";
import type { VaultFileReference } from "../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
import {
@ -9,11 +10,6 @@ import {
import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem";
import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn";
import { legacyPanelUserMessageProjection } from "./legacy-panel-user-message";
import {
fileReferencesFromLegacyManifest,
legacyTurnContextProjection,
referencedThreadFromLegacyManifest,
} from "./legacy-turn-context-manifest";
export type TurnItem = GeneratedThreadItem;
export type TurnRecord = GeneratedTurn;
@ -57,17 +53,11 @@ export interface TurnUserItemProjection {
text: string;
referencedThread: ReferencedThreadMetadata | null;
fileReferences: VaultFileReference[];
contexts: TurnUserContextMetadata[];
}
interface TurnUserContextMetadata {
kind: "web" | "obsidian";
truncated: boolean;
inlineExcerpts?: number;
manifest: ReturnType<typeof userMessageContextProjection>["manifest"];
}
export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMessage" }>): TurnUserItemProjection {
const projected = legacyTurnContextProjection(item.content, item.clientId);
const projected = userMessageContextProjection(item.content, item.clientId);
if (!projected.manifest) {
const legacy = legacyPanelUserMessageProjection({
content: item.content,
@ -78,16 +68,16 @@ export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMess
text: [legacy.text, supplementalText].filter(Boolean).join("\n"),
referencedThread: legacy.referencedThread,
fileReferences: legacy.fileReferences,
contexts: [],
manifest: null,
};
}
const supplementalText = nonTextUserInputText(item.content, projected.text);
const text = [projected.text, supplementalText].filter(Boolean).join("\n");
return {
text,
referencedThread: referencedThreadFromLegacyManifest(projected.manifest),
fileReferences: fileReferencesFromLegacyManifest(projected.manifest),
contexts: legacyContextMetadata(projected.manifest.contexts),
referencedThread: referencedThreadFromManifest(projected.manifest),
fileReferences: fileReferencesFromManifest(projected.manifest),
manifest: projected.manifest,
};
}
@ -106,7 +96,10 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
if (item.type === "userMessage") {
const projection = turnUserItemProjection(item);
const text = projection.text.trim();
const contexts = projection.contexts.map((context) => ({ kind: context.kind, truncated: context.truncated }));
const contexts =
projection.manifest?.contexts
.filter((context) => context.kind === "web" || context.kind === "obsidian")
.map((context) => ({ kind: context.kind as "web" | "obsidian", truncated: context.truncated })) ?? [];
return text
? [
{
@ -130,21 +123,6 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
return [];
}
function legacyContextMetadata(
contexts: readonly { kind: "referencedThread" | "web" | "obsidian"; truncated: boolean; inlineExcerpts?: number }[],
): TurnUserContextMetadata[] {
return contexts.flatMap((context) => {
if (context.kind === "referencedThread") return [];
return [
{
kind: context.kind,
truncated: context.truncated,
...(context.inlineExcerpts === undefined ? {} : { inlineExcerpts: context.inlineExcerpts }),
},
];
});
}
function nonTextUserInputText(
content: Extract<TurnItem, { type: "userMessage" }>["content"],
visibleText: string,

View file

@ -20,10 +20,8 @@ import {
} from "../../domain/server/diagnostics";
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
import type { Thread } from "../../domain/threads/model";
import { StaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
import type { AppServerClient } from "../connection/client";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../connection/client-access";
import type { AppServerExecutionContext } from "../connection/execution-context";
import type { AppServerClientAccessOptions } from "../connection/client-access";
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
import { listModelMetadata } from "../services/catalog";
import { readEffectiveConfig } from "../services/runtime-metadata";
@ -36,20 +34,27 @@ import {
applyActiveThreadMutation,
recentActiveThreadsFromData,
} from "./active-thread-inventory";
import {
type AppServerQueryContext,
activeThreadSearchInventoryQueryKey,
activeThreadsQueryKey,
appServerModelsQueryKey,
appServerPermissionProfilesQueryKey,
appServerRateLimitsQueryKey,
appServerRuntimeConfigQueryKey,
appServerSkillsQueryKey,
archivedThreadsQueryKey,
} from "./keys";
import { readPermissionProfileMetadataProbe, readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
import type { ObservedPaginatedResult, ObservedPaginatedResultListener, ObservedResult, ObservedResultListener } from "./observed-result";
import { cloneModelMetadata, cloneSharedServerMetadata, cloneSharedServerMetadataResource, cloneThreads } from "./snapshots";
import { applyThreadListMutation, type ThreadListKind, type ThreadListMutation } from "./thread-list-mutation";
const MODELS_STALE_TIME_MS = 60_000;
const ACTIVE_THREADS_QUERY_KEY = ["threads", "active"] as const;
const ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY = ["threads", "active-search-inventory"] as const;
const ARCHIVED_THREADS_QUERY_KEY = ["threads", "archived"] as const;
const MODELS_QUERY_KEY = ["models"] as const;
const RUNTIME_CONFIG_QUERY_KEY = ["runtime-config"] as const;
const SKILLS_QUERY_KEY = ["skills"] as const;
const PERMISSION_PROFILES_QUERY_KEY = ["permission-profiles"] as const;
const RATE_LIMITS_QUERY_KEY = ["rate-limits"] as const;
export interface AppServerQueryClientRunner {
runWithClient<T>(operation: (client: AppServerClient) => Promise<T>, options?: AppServerClientAccessOptions): Promise<T>;
}
interface AppServerQueryOptions<T> {
readonly queryKey: readonly unknown[];
@ -57,7 +62,7 @@ interface AppServerQueryOptions<T> {
readonly staleTime?: number;
}
type ActiveThreadsQueryKey = typeof ACTIVE_THREADS_QUERY_KEY;
type ActiveThreadsQueryKey = ReturnType<typeof activeThreadsQueryKey>;
interface MetadataResourceSnapshot<T> {
readonly value: T;
@ -68,36 +73,33 @@ type MetadataResourceKind = "skills" | "permissionProfiles" | "rateLimits";
type MetadataResourceValue = readonly SkillMetadata[] | readonly RuntimePermissionProfileSummary[] | RateLimitSnapshot | null;
export class AppServerQueryCache {
private readonly context: Readonly<AppServerExecutionContext>;
private readonly context: Readonly<AppServerQueryContext>;
private readonly client: QueryClient;
private readonly clientAccess: AppServerClientAccess;
private readonly observerUnsubscribes = new Set<() => void>();
private readonly clientRunner: AppServerQueryClientRunner;
private disposed = false;
constructor(context: AppServerExecutionContext, clientAccess: AppServerClientAccess) {
constructor(context: AppServerQueryContext, options: { client?: QueryClient; clientRunner: AppServerQueryClientRunner }) {
this.context = Object.freeze({ ...context });
this.client = createAppServerQueryClient();
this.clientAccess = clientAccess;
this.client = options.client ?? createAppServerQueryClient();
this.clientRunner = options.clientRunner;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
for (const unsubscribe of [...this.observerUnsubscribes]) unsubscribe();
this.observerUnsubscribes.clear();
this.client.clear();
}
activeThreadsSnapshot(): readonly Thread[] | null {
if (this.disposed) return null;
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
const data = this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey(this.context));
const threads = activeThreadsFromData(data);
return threads ? cloneThreads(threads) : null;
}
recentActiveThreadsSnapshot(): readonly Thread[] | null {
if (this.disposed) return null;
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
const data = this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey(this.context));
const threads = recentActiveThreadsFromData(data);
return threads ? cloneThreads(threads) : null;
}
@ -117,14 +119,14 @@ export class AppServerQueryCache {
enabled: false,
});
const emit = (result: InfiniteQueryObserverResult<ActiveThreadData>): void => {
if (!this.disposed) listener(this.projectObservedActiveThreadsResult(result));
listener(this.projectObservedActiveThreadsResult(result));
};
const unsubscribe = observer.subscribe(emit);
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
return this.trackObserver(() => {
return () => {
unsubscribe();
observer.destroy();
});
};
}
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options: { emitCurrent?: boolean } = {}): () => void {
@ -132,83 +134,79 @@ export class AppServerQueryCache {
return this.observeQueryResult(this.archivedThreadsQueryOptions(), cloneThreads, listener, options);
}
fetchActiveThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
const key = ACTIVE_THREADS_QUERY_KEY;
if (options.force) {
if (this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward") {
await this.client.cancelQueries({ queryKey: key, exact: true });
}
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
this.assertUsable();
async fetchActiveThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
this.assertUsable();
const key = activeThreadsQueryKey(this.context);
if (options.force) {
if (this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward") {
await this.client.cancelQueries({ queryKey: key, exact: true });
}
return this.readThroughQueryCancellation(
async () => {
const data = await this.client.fetchInfiniteQuery(this.activeThreadsQueryOptions());
return cloneThreads(activeThreadsFromData(data) ?? []);
},
() => this.activeThreadsSnapshot(),
);
});
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
this.assertUsable();
}
return this.readThroughQueryCancellation(
async () => {
const data = await this.client.fetchInfiniteQuery(this.activeThreadsQueryOptions());
return cloneThreads(activeThreadsFromData(data) ?? []);
},
() => this.activeThreadsSnapshot(),
);
}
refreshActiveThreads(): Promise<readonly Thread[]> {
async refreshActiveThreads(): Promise<readonly Thread[]> {
return this.fetchActiveThreads({ force: true });
}
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
const key = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
const options = {
queryKey: key,
queryFn: ({ signal }: { signal: AbortSignal }) =>
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { signal })),
};
return this.readFreshThroughQueryCancellation(key, async () => cloneThreads(await this.client.fetchQuery(options)));
});
async fetchActiveThreadSearchInventory(): Promise<readonly Thread[]> {
this.assertUsable();
const key = activeThreadSearchInventoryQueryKey(this.context);
const options = {
queryKey: key,
queryFn: ({ signal }: { signal: AbortSignal }) =>
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { signal })),
};
return this.readFreshThroughQueryCancellation(key, async () => cloneThreads(await this.client.fetchQuery(options)));
}
hasMoreActiveThreads(): boolean {
if (this.disposed) return false;
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY));
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey(this.context)));
}
loadMoreActiveThreads(): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
const current = this.activeThreadsSnapshot() ?? (await this.fetchActiveThreads());
const observer = new InfiniteQueryObserver(this.client, {
...this.activeThreadsQueryOptions(),
enabled: false,
});
try {
if (!observer.getCurrentResult().hasNextPage) return current;
const result = await observer.fetchNextPage({ cancelRefetch: false, throwOnError: true });
this.assertUsable();
return result.data ? cloneThreads(activeThreadsFromData(result.data) ?? []) : current;
} catch (error) {
if (error instanceof CancelledError) return this.activeThreadsSnapshot() ?? current;
throw error;
} finally {
observer.destroy();
}
async loadMoreActiveThreads(): Promise<readonly Thread[]> {
this.assertUsable();
const current = this.activeThreadsSnapshot() ?? (await this.fetchActiveThreads());
const observer = new InfiniteQueryObserver(this.client, {
...this.activeThreadsQueryOptions(),
enabled: false,
});
try {
if (!observer.getCurrentResult().hasNextPage) return current;
const result = await observer.fetchNextPage({ cancelRefetch: false, throwOnError: true });
this.assertUsable();
return result.data ? cloneThreads(activeThreadsFromData(result.data) ?? []) : current;
} catch (error) {
if (error instanceof CancelledError) return this.activeThreadsSnapshot() ?? current;
throw error;
} finally {
observer.destroy();
}
}
refreshArchivedThreads(): Promise<readonly Thread[]> {
async refreshArchivedThreads(): Promise<readonly Thread[]> {
return this.fetchArchivedThreads({ force: true });
}
fetchArchivedThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
const key = ARCHIVED_THREADS_QUERY_KEY;
if (options.force) {
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
this.assertUsable();
}
return this.readFreshThroughQueryCancellation(key, async () =>
cloneThreads(await this.client.fetchQuery(this.archivedThreadsQueryOptions())),
);
});
async fetchArchivedThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
this.assertUsable();
const key = archivedThreadsQueryKey(this.context);
if (options.force) {
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
this.assertUsable();
}
return this.readFreshThroughQueryCancellation(key, async () =>
cloneThreads(await this.client.fetchQuery(this.archivedThreadsQueryOptions())),
);
}
applyThreadListMutations(mutations: readonly ThreadListMutation[]): void {
@ -216,7 +214,7 @@ export class AppServerQueryCache {
if (mutations.length === 0) return;
const activeMutations = mutations.filter((mutation) => mutation.list === "active");
if (activeMutations.length > 0) {
const key = ACTIVE_THREADS_QUERY_KEY;
const key = activeThreadsQueryKey(this.context);
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
const fetchIsNextPage = this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward";
void this.client.cancelQueries({ queryKey: key, exact: true });
@ -224,7 +222,7 @@ export class AppServerQueryCache {
const after = activeMutations.reduce<ActiveThreadData | undefined>(applyActiveThreadMutation, before);
if (after !== before) this.client.setQueryData(key, after);
void this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
const searchKey = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
const searchKey = activeThreadSearchInventoryQueryKey(this.context);
void this.client.cancelQueries({ queryKey: searchKey, exact: true });
void this.client.invalidateQueries({ queryKey: searchKey, refetchType: "none" });
@ -238,7 +236,7 @@ export class AppServerQueryCache {
const archivedMutations = mutations.filter((mutation) => mutation.list === "archived");
if (archivedMutations.length > 0) {
const key = ARCHIVED_THREADS_QUERY_KEY;
const key = archivedThreadsQueryKey(this.context);
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
void this.client.cancelQueries({ queryKey: key, exact: true });
const before = this.archivedThreadsSnapshot();
@ -257,13 +255,13 @@ export class AppServerQueryCache {
private threadListSnapshot(kind: ThreadListKind): readonly Thread[] | null {
if (kind === "active") return this.activeThreadsSnapshot();
const threads = this.client.getQueryData<readonly Thread[]>(ARCHIVED_THREADS_QUERY_KEY);
const threads = this.client.getQueryData<readonly Thread[]>(archivedThreadsQueryKey(this.context));
return threads ? cloneThreads(threads) : null;
}
appServerMetadataSnapshot(): SharedServerMetadata | null {
if (this.disposed) return null;
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(RUNTIME_CONFIG_QUERY_KEY);
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(appServerRuntimeConfigQueryKey(this.context));
if (!runtimeConfig) return null;
const skills = this.metadataResourceState("skills");
const permissionProfiles = this.metadataResourceState("permissionProfiles");
@ -286,9 +284,9 @@ export class AppServerQueryCache {
options: { emitCurrent?: boolean } = {},
): () => void {
this.assertUsable();
let unsubscribed = false;
let disposed = false;
const emit = (resource: SharedServerMetadataResource): void => {
if (!this.disposed && !unsubscribed) listener(cloneSharedServerMetadataResource(resource));
if (!disposed) listener(cloneSharedServerMetadataResource(resource));
};
const runtimeConfig = new QueryObserver(this.client, { ...this.runtimeConfigQueryOptions(), enabled: false });
@ -339,54 +337,46 @@ export class AppServerQueryCache {
emitPermissionProfiles(permissionProfiles.getCurrentResult(), true);
emitRateLimits(rateLimits.getCurrentResult(), true);
}
return this.trackObserver(() => {
unsubscribed = true;
return () => {
disposed = true;
for (const unsubscribe of unsubscribers) unsubscribe();
runtimeConfig.destroy();
models.destroy();
skills.destroy();
permissionProfiles.destroy();
rateLimits.destroy();
});
};
}
refreshAppServerMetadata(): Promise<void> {
return this.runWhileActive(async () => {
const runtimeResult = this.fetchRuntimeConfig().then(
() => ({ ok: true as const }),
(error: unknown) => ({ ok: false as const, error }),
);
const [, runtime] = await Promise.all([
Promise.allSettled([
this.fetchMetadataResource("skills"),
this.fetchMetadataResource("permissionProfiles"),
this.fetchMetadataResource("rateLimits"),
this.fetchModels({ force: true }),
]),
runtimeResult,
]);
this.assertUsable();
if (!runtime.ok) throw runtime.error;
});
async refreshAppServerMetadata(): Promise<void> {
this.assertUsable();
const runtimeResult = this.fetchRuntimeConfig().then(
() => ({ ok: true as const }),
(error: unknown) => ({ ok: false as const, error }),
);
const [, runtime] = await Promise.all([
Promise.allSettled([
this.fetchMetadataResource("skills"),
this.fetchMetadataResource("permissionProfiles"),
this.fetchMetadataResource("rateLimits"),
this.fetchModels({ force: true }),
]),
runtimeResult,
]);
this.assertUsable();
if (!runtime.ok) throw runtime.error;
}
refreshSkills(): Promise<void> {
return this.runWhileActive(async () => {
await this.refreshNotifiedMetadataResource("skills");
this.assertUsable();
});
async refreshSkills(): Promise<void> {
this.assertUsable();
await this.refreshNotifiedMetadataResource("skills");
this.assertUsable();
}
refreshRateLimits(): Promise<void> {
return this.runWhileActive(async () => {
await this.refreshNotifiedMetadataResource("rateLimits");
this.assertUsable();
});
async refreshRateLimits(): Promise<void> {
this.assertUsable();
await this.refreshNotifiedMetadataResource("rateLimits");
this.assertUsable();
}
modelsSnapshot(): readonly ModelMetadata[] | null {
if (this.disposed) return null;
const models = this.client.getQueryData<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
const models = this.client.getQueryData<readonly ModelMetadata[]>(appServerModelsQueryKey(this.context));
return models ? cloneModelMetadata(models) : null;
}
@ -395,20 +385,19 @@ export class AppServerQueryCache {
return this.observeQueryResult(this.modelsQueryOptions(), cloneModelMetadata, listener, options);
}
fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
return this.runWhileActive(async () => {
const key = MODELS_QUERY_KEY;
if (options.force) {
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
this.assertUsable();
}
const models = await this.client.fetchQuery(this.modelsQueryOptions());
async fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
this.assertUsable();
const key = appServerModelsQueryKey(this.context);
if (options.force) {
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
this.assertUsable();
return cloneModelMetadata(models);
});
}
const models = await this.client.fetchQuery(this.modelsQueryOptions());
this.assertUsable();
return cloneModelMetadata(models);
}
refreshModels(): Promise<readonly ModelMetadata[]> {
async refreshModels(): Promise<readonly ModelMetadata[]> {
return this.fetchModels({ force: true });
}
@ -420,7 +409,7 @@ export class AppServerQueryCache {
ActiveThreadCursor
> {
return {
queryKey: ACTIVE_THREADS_QUERY_KEY,
queryKey: activeThreadsQueryKey(this.context),
queryFn: async ({ pageParam, signal }) => {
signal.throwIfAborted();
const page = await this.runWithClient((client) =>
@ -437,7 +426,7 @@ export class AppServerQueryCache {
private archivedThreadsQueryOptions(): AppServerQueryOptions<readonly Thread[]> {
return {
queryKey: ARCHIVED_THREADS_QUERY_KEY,
queryKey: archivedThreadsQueryKey(this.context),
queryFn: ({ signal }: { signal: AbortSignal }) =>
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { archived: true, signal })).then(cloneThreads),
staleTime: Number.POSITIVE_INFINITY,
@ -446,7 +435,7 @@ export class AppServerQueryCache {
private runtimeConfigQueryOptions(): AppServerQueryOptions<RuntimeConfigSnapshot> {
return {
queryKey: RUNTIME_CONFIG_QUERY_KEY,
queryKey: appServerRuntimeConfigQueryKey(this.context),
queryFn: async (): Promise<RuntimeConfigSnapshot> =>
this.runWithClient(async (client) =>
runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, this.context.vaultPath)),
@ -456,7 +445,7 @@ export class AppServerQueryCache {
private skillsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly SkillMetadata[]>> {
return {
queryKey: SKILLS_QUERY_KEY,
queryKey: appServerSkillsQueryKey(this.context),
queryFn: async () =>
this.runWithClient(async (client) => successfulMetadataResource(await readSkillMetadataProbe(client, this.context.vaultPath))),
};
@ -464,7 +453,7 @@ export class AppServerQueryCache {
private permissionProfilesQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>> {
return {
queryKey: PERMISSION_PROFILES_QUERY_KEY,
queryKey: appServerPermissionProfilesQueryKey(this.context),
queryFn: async () =>
this.runWithClient(async (client) =>
successfulMetadataResource(await readPermissionProfileMetadataProbe(client, this.context.vaultPath)),
@ -474,7 +463,7 @@ export class AppServerQueryCache {
private rateLimitsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<RateLimitSnapshot | null>> {
return {
queryKey: RATE_LIMITS_QUERY_KEY,
queryKey: appServerRateLimitsQueryKey(this.context),
queryFn: async () => this.runWithClient(async (client) => successfulMetadataResource(await readRateLimitMetadataProbe(client))),
};
}
@ -506,7 +495,7 @@ export class AppServerQueryCache {
}
private async refreshNotifiedMetadataResource(resource: "skills" | "rateLimits"): Promise<void> {
const queryKey = resource === "skills" ? SKILLS_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
const queryKey = resource === "skills" ? appServerSkillsQueryKey(this.context) : appServerRateLimitsQueryKey(this.context);
await this.client.cancelQueries({ queryKey, exact: true });
this.assertUsable();
try {
@ -524,7 +513,11 @@ export class AppServerQueryCache {
private metadataResourceState(resource: "rateLimits"): { value: RateLimitSnapshot | null; probe: DiagnosticProbeResult };
private metadataResourceState(resource: MetadataResourceKind): { value: MetadataResourceValue; probe: DiagnosticProbeResult } {
const key =
resource === "skills" ? SKILLS_QUERY_KEY : resource === "permissionProfiles" ? PERMISSION_PROFILES_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
resource === "skills"
? appServerSkillsQueryKey(this.context)
: resource === "permissionProfiles"
? appServerPermissionProfilesQueryKey(this.context)
: appServerRateLimitsQueryKey(this.context);
const state = this.client.getQueryState<MetadataResourceSnapshot<MetadataResourceValue>>(key);
const failedProbe = diagnosticProbeFromError(state?.error);
return {
@ -534,7 +527,7 @@ export class AppServerQueryCache {
}
private modelsProbe(): DiagnosticProbeResult {
const state = this.client.getQueryState<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
const state = this.client.getQueryState<readonly ModelMetadata[]>(appServerModelsQueryKey(this.context));
return (
diagnosticProbeFromError(state?.error) ??
(state?.data
@ -545,7 +538,7 @@ export class AppServerQueryCache {
private modelsQueryOptions(): AppServerQueryOptions<readonly ModelMetadata[]> {
return {
queryKey: MODELS_QUERY_KEY,
queryKey: appServerModelsQueryKey(this.context),
queryFn: async (): Promise<readonly ModelMetadata[]> => {
try {
return cloneModelMetadata(
@ -572,14 +565,11 @@ export class AppServerQueryCache {
enabled: false,
});
const emit = (result: QueryObserverResult<TQuery>): void => {
if (!this.disposed) listener(this.projectObservedResult(result, project));
listener(this.projectObservedResult(result, project));
};
const unsubscribe = observer.subscribe(emit);
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
return this.trackObserver(() => {
unsubscribe();
observer.destroy();
});
return unsubscribe;
}
private projectObservedResult<TQuery, TValue>(
@ -608,7 +598,7 @@ export class AppServerQueryCache {
private runWithClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
this.assertUsable();
return this.clientAccess.withClient(operation, options);
return this.clientRunner.runWithClient(operation, options);
}
private async readThroughQueryCancellation<T>(read: () => Promise<T>, fallback?: () => T | null): Promise<T> {
@ -635,33 +625,7 @@ export class AppServerQueryCache {
}
private assertUsable(): void {
if (this.disposed) throw new StaleExecutionRuntimeError();
}
private runWhileActive<T>(operation: () => Promise<T>): Promise<T> {
this.assertUsable();
return (async () => {
try {
const result = await operation();
this.assertUsable();
return result;
} catch (error) {
if (this.disposed) throw new StaleExecutionRuntimeError();
throw error;
}
})();
}
private trackObserver(unsubscribe: () => void): () => void {
let subscribed = true;
const trackedUnsubscribe = (): void => {
if (!subscribed) return;
subscribed = false;
this.observerUnsubscribes.delete(trackedUnsubscribe);
unsubscribe();
};
this.observerUnsubscribes.add(trackedUnsubscribe);
return trackedUnsubscribe;
if (this.disposed) throw new Error("Codex app-server query cache was disposed.");
}
}

View file

@ -0,0 +1,50 @@
export interface AppServerQueryContext {
readonly codexPath: string;
readonly vaultPath: string;
}
type AppServerQueryScope = readonly ["app-server", string, string];
export type AppServerActiveThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "active"];
export type AppServerActiveThreadSearchInventoryQueryKey = readonly [...AppServerQueryScope, "threads", "active-search-inventory"];
export type AppServerArchivedThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "archived"];
export type AppServerModelsQueryKey = readonly [...AppServerQueryScope, "models"];
export type AppServerRuntimeConfigQueryKey = readonly [...AppServerQueryScope, "runtime-config"];
export type AppServerSkillsQueryKey = readonly [...AppServerQueryScope, "skills"];
export type AppServerPermissionProfilesQueryKey = readonly [...AppServerQueryScope, "permission-profiles"];
export type AppServerRateLimitsQueryKey = readonly [...AppServerQueryScope, "rate-limits"];
function appServerQueryScope(context: AppServerQueryContext): AppServerQueryScope {
return ["app-server", context.codexPath, context.vaultPath];
}
export function activeThreadsQueryKey(context: AppServerQueryContext): AppServerActiveThreadsQueryKey {
return [...appServerQueryScope(context), "threads", "active"];
}
export function activeThreadSearchInventoryQueryKey(context: AppServerQueryContext): AppServerActiveThreadSearchInventoryQueryKey {
return [...appServerQueryScope(context), "threads", "active-search-inventory"];
}
export function archivedThreadsQueryKey(context: AppServerQueryContext): AppServerArchivedThreadsQueryKey {
return [...appServerQueryScope(context), "threads", "archived"];
}
export function appServerModelsQueryKey(context: AppServerQueryContext): AppServerModelsQueryKey {
return [...appServerQueryScope(context), "models"];
}
export function appServerRuntimeConfigQueryKey(context: AppServerQueryContext): AppServerRuntimeConfigQueryKey {
return [...appServerQueryScope(context), "runtime-config"];
}
export function appServerSkillsQueryKey(context: AppServerQueryContext): AppServerSkillsQueryKey {
return [...appServerQueryScope(context), "skills"];
}
export function appServerPermissionProfilesQueryKey(context: AppServerQueryContext): AppServerPermissionProfilesQueryKey {
return [...appServerQueryScope(context), "permission-profiles"];
}
export function appServerRateLimitsQueryKey(context: AppServerQueryContext): AppServerRateLimitsQueryKey {
return [...appServerQueryScope(context), "rate-limits"];
}

View file

@ -0,0 +1,166 @@
import type { ModelMetadata } from "../../domain/catalog/metadata";
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
import type { Thread } from "../../domain/threads/model";
import { AppServerQueryCache, type AppServerQueryClientRunner } from "./cache";
import type { AppServerQueryContext } from "./keys";
import type { ObservedPaginatedResultListener, ObservedResultListener } from "./observed-result";
import type { ThreadListMutation } from "./thread-list-mutation";
export interface AppServerResourceStoreOptions {
context: AppServerQueryContext;
clientRunner: AppServerQueryClientRunner;
}
export class StaleAppServerResourceContextError extends Error {
constructor() {
super("Codex app-server resource context changed while loading.");
this.name = "StaleAppServerResourceContextError";
}
}
export function isStaleAppServerResourceContextError(error: unknown): error is StaleAppServerResourceContextError {
return error instanceof StaleAppServerResourceContextError;
}
export class AppServerResourceStore {
private readonly cache: AppServerQueryCache;
private readonly observerUnsubscribes = new Set<() => void>();
private disposed = false;
constructor(options: AppServerResourceStoreOptions) {
const context = Object.freeze({ ...options.context });
this.cache = new AppServerQueryCache(context, { clientRunner: options.clientRunner });
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
for (const unsubscribe of this.observerUnsubscribes) unsubscribe();
this.observerUnsubscribes.clear();
this.cache.dispose();
}
activeThreadsSnapshot(): readonly Thread[] | null {
return this.cache.activeThreadsSnapshot();
}
recentActiveThreadsSnapshot(): readonly Thread[] | null {
return this.cache.recentActiveThreadsSnapshot();
}
archivedThreadsSnapshot(): readonly Thread[] | null {
return this.cache.archivedThreadsSnapshot();
}
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]> {
return this.runForCurrentContext((cache) => cache.fetchActiveThreadSearchInventory());
}
hasMoreActiveThreads(): boolean {
return this.cache.hasMoreActiveThreads();
}
loadMoreActiveThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((cache) => cache.loadMoreActiveThreads());
}
fetchActiveThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((cache) => cache.fetchActiveThreads());
}
refreshActiveThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((cache) => cache.refreshActiveThreads());
}
refreshArchivedThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((cache) => cache.refreshArchivedThreads());
}
applyThreadListMutations(mutations: readonly ThreadListMutation[]): void {
this.assertActive();
this.cache.applyThreadListMutations(mutations);
}
observeActiveThreadsResult(
listener: ObservedPaginatedResultListener<readonly Thread[]>,
options?: { emitCurrent?: boolean },
): () => void {
return this.observe((contextListener) => this.cache.observeActiveThreadsResult(contextListener, options), listener);
}
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
return this.observe((contextListener) => this.cache.observeArchivedThreadsResult(contextListener, options), listener);
}
appServerMetadataSnapshot(): SharedServerMetadata | null {
return this.cache.appServerMetadataSnapshot();
}
refreshAppServerMetadata(): Promise<void> {
return this.runForCurrentContext((cache) => cache.refreshAppServerMetadata());
}
refreshSkills(): Promise<void> {
return this.runForCurrentContext((cache) => cache.refreshSkills());
}
refreshRateLimits(): Promise<void> {
return this.runForCurrentContext((cache) => cache.refreshRateLimits());
}
observeAppServerMetadataResources(
listener: (resource: SharedServerMetadataResource) => void,
options?: { emitCurrent?: boolean },
): () => void {
return this.observe((contextListener) => this.cache.observeAppServerMetadataResources(contextListener, options), listener);
}
modelsSnapshot(): readonly ModelMetadata[] | null {
return this.cache.modelsSnapshot();
}
fetchModels(): Promise<readonly ModelMetadata[]> {
return this.runForCurrentContext((cache) => cache.fetchModels());
}
refreshModels(): Promise<readonly ModelMetadata[]> {
return this.runForCurrentContext((cache) => cache.refreshModels());
}
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void {
return this.observe((contextListener) => this.cache.observeModelsResult(contextListener, options), listener);
}
private runForCurrentContext<T>(operation: (cache: AppServerQueryCache) => Promise<T>): Promise<T> {
this.assertActive();
return this.runWhileActive(() => operation(this.cache));
}
private async runWhileActive<T>(operation: () => Promise<T>): Promise<T> {
let result: T;
try {
result = await operation();
} catch (error) {
if (this.disposed) throw new StaleAppServerResourceContextError();
throw error;
}
if (this.disposed) throw new StaleAppServerResourceContextError();
return result;
}
private observe<T>(subscribe: (listener: (value: T) => void) => () => void, listener: (value: T) => void): () => void {
this.assertActive();
const unsubscribe = subscribe((value) => {
if (!this.disposed) listener(value);
});
this.observerUnsubscribes.add(unsubscribe);
return () => {
if (!this.observerUnsubscribes.delete(unsubscribe)) return;
unsubscribe();
};
}
private assertActive(): void {
if (this.disposed) throw new StaleAppServerResourceContextError();
}
}

View file

@ -6,6 +6,7 @@ import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../doma
import type { ThreadActivationSnapshot } from "../../domain/threads/activation";
import type { ArchiveThreadInput } from "../../domain/threads/archive-markdown";
import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal";
import type { HistoricalTurn } from "../../domain/threads/history";
import type { Thread } from "../../domain/threads/model";
import { REFERENCED_THREAD_TURN_LIMIT } from "../../domain/threads/reference";
import type { TurnTranscriptSummary } from "../../domain/threads/transcript";
@ -186,61 +187,44 @@ export async function readReferencedThreadTurnTranscriptSummaries(
return chronologicalTurnTranscriptSummariesFromTurnRecords(response.data);
}
type ThreadForkPosition =
| { readonly kind: "through-turn"; readonly turnId: string }
| { readonly kind: "before-turn"; readonly turnId: string };
export interface ThreadRollbackSnapshot {
thread: Thread;
turns: readonly HistoricalTurn[];
}
interface AppServerForkThreadOptions {
readonly position?: ThreadForkPosition;
readonly deferGoalContinuation?: boolean;
readonly runtime?: {
readonly model?: string;
readonly reasoningEffort?: string | null;
readonly serviceTier?: ServiceTier | null;
readonly approvalPolicy?: RuntimePermissionState["approvalPolicy"];
readonly approvalsReviewer?: ApprovalsReviewer;
readonly permissions?: string;
readonly sandboxPolicy?: RuntimePermissionState["sandboxPolicy"];
interface ThreadRollbackResult {
readonly thread: ThreadRecord & {
readonly turns: readonly HistoricalTurn[];
};
}
function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackResult): ThreadRollbackSnapshot {
return {
thread: threadFromThreadRecord(response.thread),
turns: response.thread.turns,
};
}
export async function rollbackThread(client: AppServerRequestClient, threadId: string, numTurns?: number): Promise<ThreadRollbackSnapshot> {
const response = await client.request("thread/rollback", { threadId, numTurns: numTurns ?? 1 });
return threadRollbackSnapshotFromAppServerResponse(response);
}
export async function forkThread(
client: AppServerRequestClient,
threadId: string,
cwd: string,
options: AppServerForkThreadOptions = {},
lastTurnId: string | null = null,
): Promise<Thread> {
const position = options.position;
const runtime = options.runtime;
const permissions = runtime?.permissions;
const sandbox = permissions === undefined ? appServerSandboxMode(runtime?.sandboxPolicy) : undefined;
const response = await client.request("thread/fork", {
threadId,
cwd,
excludeTurns: true,
...(position?.kind === "through-turn" ? { lastTurnId: position.turnId } : {}),
...(position?.kind === "before-turn" ? { beforeTurnId: position.turnId } : {}),
...(options.deferGoalContinuation ? { deferGoalContinuation: true } : {}),
...(runtime?.model !== undefined ? { model: runtime.model } : {}),
...(runtime?.serviceTier !== undefined ? { serviceTier: runtime.serviceTier } : {}),
...(runtime?.approvalPolicy !== undefined && runtime.approvalPolicy !== null ? { approvalPolicy: runtime.approvalPolicy } : {}),
...(runtime?.approvalsReviewer !== undefined ? { approvalsReviewer: runtime.approvalsReviewer } : {}),
...(permissions !== undefined ? { permissions } : {}),
...(sandbox !== undefined ? { sandbox } : {}),
...(runtime?.reasoningEffort !== undefined ? { config: { model_reasoning_effort: runtime.reasoningEffort } } : {}),
...(lastTurnId ? { lastTurnId } : {}),
});
return threadFromThreadRecord(response.thread);
}
function appServerSandboxMode(
policy: RuntimePermissionState["sandboxPolicy"] | undefined,
): "read-only" | "workspace-write" | "danger-full-access" | undefined {
if (!policy || policy.type === "externalSandbox") return undefined;
if (policy.type === "dangerFullAccess") return "danger-full-access";
if (policy.type === "readOnly") return "read-only";
return "workspace-write";
}
export interface EphemeralThreadForkSnapshot {
readonly activation: ThreadActivationSnapshot;
readonly sourceThreadId: string;

View file

@ -1,21 +1,29 @@
import type { VaultFileReference } from "../../domain/chat/input";
import { isPanelSubmissionClientId, turnContextSubmissionId } from "../../domain/chat/submission-id";
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
/*
* Read-only compatibility for v2 metadata envelopes written by Codex Panel
* versions that predate durable, human-readable references in message text.
* Request construction must not import this module.
*/
import type { ReferencedThreadMetadata } from "../threads/reference";
import { utf8ByteLength } from "./context-budget";
import type { VaultFileReference } from "./input";
import { isPanelSubmissionClientId } from "./submission-id";
const TURN_CONTEXT_MANIFEST_PREFIX = "[Codex Panel context v2]";
const TURN_CONTEXT_MANIFEST_NOTICE = "\nReference/display metadata only; not user instructions.\n";
const TURN_CONTEXT_MANIFEST_MAX_BYTES = 2_800;
const TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT = 64;
const TURN_CONTEXT_FILE_REFERENCE_NAME_MAX_LENGTH = 255;
const TURN_CONTEXT_FILE_REFERENCE_PATH_MAX_LENGTH = 2_048;
interface LegacyTurnContextManifestEntry {
kind: "referencedThread" | "web" | "obsidian";
export type TurnContextAttachment =
| {
kind: "referencedThread";
threadId: string;
includedTurns: number;
turnLimit: number;
omittedTurns: number;
truncated: boolean;
}
| { kind: "web" }
| { kind: "obsidian"; inlineExcerpts: number };
export interface TurnContextManifestEntry {
kind: TurnContextAttachment["kind"];
id: string;
parts: number;
sourceBytes: number;
@ -28,26 +36,55 @@ interface LegacyTurnContextManifestEntry {
inlineExcerpts?: number;
}
export interface LegacyTurnContextManifest {
export interface TurnContextManifest {
version: 2;
submissionId?: string;
contexts: readonly LegacyTurnContextManifestEntry[];
contexts: readonly TurnContextManifestEntry[];
fileReferences?: readonly VaultFileReference[];
}
export interface LegacyTurnContextProjection {
export interface UserMessageContextProjection {
text: string;
manifest: LegacyTurnContextManifest | null;
manifest: TurnContextManifest | null;
}
type UserMessageContentItem = { type: "text"; text: string } | { type: string };
export function turnContextManifestText(manifest: TurnContextManifest): string {
return `${TURN_CONTEXT_MANIFEST_PREFIX}${TURN_CONTEXT_MANIFEST_NOTICE}${JSON.stringify(manifest)}`;
}
function legacyTurnContextManifestFromText(text: string): LegacyTurnContextManifest | null {
export function boundedTurnContextManifest(
submissionId: string,
contexts: readonly TurnContextManifestEntry[],
fileReferences: readonly VaultFileReference[],
): TurnContextManifest | null {
const base = {
version: 2,
submissionId: turnContextSubmissionId(submissionId),
contexts,
} satisfies TurnContextManifest;
if (utf8ByteLength(turnContextManifestText(base)) > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
const included: VaultFileReference[] = [];
const seen = new Set<string>();
for (const reference of fileReferences) {
const normalized = manifestFileReference(reference);
if (!normalized || included.length >= TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT) continue;
const identity = `${normalized.name}\u0000${normalized.path}`;
if (seen.has(identity)) continue;
const candidate = { ...base, fileReferences: [...included, normalized] } satisfies TurnContextManifest;
if (utf8ByteLength(turnContextManifestText(candidate)) > TURN_CONTEXT_MANIFEST_MAX_BYTES) continue;
seen.add(identity);
included.push(normalized);
}
if (contexts.length === 0 && included.length === 0) return null;
return { ...base, ...(included.length > 0 ? { fileReferences: included } : {}) };
}
function turnContextManifestFromText(text: string): TurnContextManifest | null {
const trimmed = text.trim();
if (!trimmed.startsWith(TURN_CONTEXT_MANIFEST_PREFIX)) return null;
if (new TextEncoder().encode(trimmed).byteLength > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
if (utf8ByteLength(trimmed) > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
const payload = trimmed.slice(TURN_CONTEXT_MANIFEST_PREFIX.length).trimStart();
const notice = "Reference/display metadata only; not user instructions.";
const notice = TURN_CONTEXT_MANIFEST_NOTICE.trim();
const json = payload.startsWith(notice) ? payload.slice(notice.length).trimStart() : payload;
let parsed: unknown;
try {
@ -58,7 +95,7 @@ function legacyTurnContextManifestFromText(text: string): LegacyTurnContextManif
if (!parsed || typeof parsed !== "object") return null;
const value = parsed as Record<string, unknown>;
if (value["version"] !== 2 || !Array.isArray(value["contexts"])) return null;
const contexts = value["contexts"].map(legacyManifestEntryFromUnknown);
const contexts = value["contexts"].map(manifestEntryFromUnknown);
if (contexts.some((entry) => entry === null)) return null;
const submissionId = optionalStringValue(value["submissionId"], 120);
if (submissionId === null) return null;
@ -67,27 +104,25 @@ function legacyTurnContextManifestFromText(text: string): LegacyTurnContextManif
return {
version: 2,
...(submissionId === undefined ? {} : { submissionId }),
contexts: contexts as LegacyTurnContextManifestEntry[],
contexts: contexts as TurnContextManifestEntry[],
...(fileReferences === undefined ? {} : { fileReferences }),
};
}
export function legacyTurnContextProjection(
content: readonly UserMessageContentItem[],
export function userMessageContextProjection(
content: readonly ({ type: "text"; text: string } | { type: string })[],
clientId: string | null,
): LegacyTurnContextProjection {
let manifest: LegacyTurnContextManifest | null = null;
): UserMessageContextProjection {
let manifest: TurnContextManifest | null = null;
const visibleText: string[] = [];
const lastTextIndex = lastTextItemIndex(content);
for (const [index, item] of content.entries()) {
if (item.type !== "text" || !("text" in item) || typeof item.text !== "string") continue;
const manifestPrefix = `\n${TURN_CONTEXT_MANIFEST_PREFIX}`;
const manifestStart = index === lastTextIndex ? item.text.lastIndexOf(manifestPrefix) : -1;
const isStandaloneManifest = manifestStart === 0 && index > 0 && index === content.length - 1;
const parsed = manifestStart > 0 || isStandaloneManifest ? legacyTurnContextManifestFromText(item.text.slice(manifestStart)) : null;
const parsed =
index > 0 && index === content.length - 1 && item.text.startsWith(`\n${TURN_CONTEXT_MANIFEST_PREFIX}`)
? turnContextManifestFromText(item.text)
: null;
if (parsed && manifestMatchesClientId(parsed, clientId)) {
manifest = parsed;
if (manifestStart > 0) visibleText.push(item.text.slice(0, manifestStart));
continue;
}
visibleText.push(item.text);
@ -95,14 +130,12 @@ export function legacyTurnContextProjection(
return { text: visibleText.join("\n"), manifest };
}
function lastTextItemIndex(content: readonly UserMessageContentItem[]): number {
for (let index = content.length - 1; index >= 0; index -= 1) {
if (content[index]?.type === "text") return index;
}
return -1;
export function turnContextSubmissionId(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
return safe || "context";
}
function manifestMatchesClientId(manifest: LegacyTurnContextManifest, clientId: string | null): boolean {
function manifestMatchesClientId(manifest: TurnContextManifest, clientId: string | null): boolean {
if (!isPanelSubmissionClientId(clientId)) return false;
const submissionId = turnContextSubmissionId(clientId);
if (manifest.submissionId !== undefined && manifest.submissionId !== submissionId) return false;
@ -118,7 +151,7 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function referencedThreadFromLegacyManifest(manifest: LegacyTurnContextManifest | null): ReferencedThreadMetadata | null {
export function referencedThreadFromManifest(manifest: TurnContextManifest | null): ReferencedThreadMetadata | null {
const context = manifest?.contexts.find((entry) => entry.kind === "referencedThread");
if (!context?.threadId || context.includedTurns === undefined || context.turnLimit === undefined || context.omittedTurns === undefined) {
return null;
@ -133,11 +166,11 @@ export function referencedThreadFromLegacyManifest(manifest: LegacyTurnContextMa
};
}
export function fileReferencesFromLegacyManifest(manifest: LegacyTurnContextManifest | null): VaultFileReference[] {
export function fileReferencesFromManifest(manifest: TurnContextManifest | null): VaultFileReference[] {
return manifest?.fileReferences ? [...manifest.fileReferences] : [];
}
function legacyManifestEntryFromUnknown(input: unknown): LegacyTurnContextManifestEntry | null {
function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | null {
if (!input || typeof input !== "object") return null;
const value = input as Record<string, unknown>;
const kind = contextKind(value["kind"]);
@ -181,7 +214,7 @@ function legacyManifestEntryFromUnknown(input: unknown): LegacyTurnContextManife
};
}
function contextKind(value: unknown): LegacyTurnContextManifestEntry["kind"] | null {
function contextKind(value: unknown): TurnContextAttachment["kind"] | null {
return value === "referencedThread" || value === "web" || value === "obsidian" ? value : null;
}

View file

@ -14,6 +14,7 @@ export interface RequestAdditionalContext {
key: string;
value: string;
kind: "untrusted" | "application";
attachment?: TurnContextAttachment;
}
export type CodexInputItem =
@ -27,6 +28,7 @@ export type CodexInputItem =
key: string;
value: string;
kind: RequestAdditionalContext["kind"];
attachment?: TurnContextAttachment;
};
export type CodexInput = CodexInputItem[];
@ -51,6 +53,7 @@ export function codexTextInputWithReferences(
key: context.key,
value: context.value,
kind: context.kind,
...(context.attachment ? { attachment: context.attachment } : {}),
})),
];
}
@ -58,3 +61,5 @@ export function codexTextInputWithReferences(
export function codexTextInputWithAttachments(text: string, input: readonly CodexInputItem[]): CodexInput {
return [...codexTextInput(text), ...input.filter((item) => item.type !== "text")];
}
import type { TurnContextAttachment } from "./context-manifest";

View file

@ -3,8 +3,3 @@ const PANEL_SUBMISSION_CLIENT_ID_PATTERN = /^local-(?:user|steer)-\d+-[A-Za-z0-9
export function isPanelSubmissionClientId(value: string | null | undefined): value is string {
return typeof value === "string" && PANEL_SUBMISSION_CLIENT_ID_PATTERN.test(value);
}
export function turnContextSubmissionId(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
return safe || "context";
}

View file

@ -1,19 +0,0 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import { visit } from "unist-util-visit";
export type MarkdownCodeRange = readonly [start: number, end: number];
export function markdownCodeRanges(markdown: string): MarkdownCodeRange[] {
const ranges: MarkdownCodeRange[] = [];
visit(fromMarkdown(markdown), (node) => {
if (node.type !== "code" && node.type !== "inlineCode") return;
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) ranges.push([start, end]);
});
return ranges;
}
export function markdownCodeRangeContainsOffset(ranges: readonly MarkdownCodeRange[], offset: number): boolean {
return ranges.some(([start, end]) => offset >= start && offset < end);
}

View file

@ -0,0 +1,7 @@
export function yamlFrontmatterString(value: string): string {
return JSON.stringify(value);
}
export function yamlFrontmatterInlineList(values: readonly string[]): string {
return `[${values.map(yamlFrontmatterString).join(", ")}]`;
}

View file

@ -1,21 +1,20 @@
import type { Link, Nodes } from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
import { toMarkdown } from "mdast-util-to-markdown";
import { visit } from "unist-util-visit";
import { parseFileHref } from "../vault/file-hrefs";
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../markdown/frontmatter";
import { isExternalFileHref, parseFileHref } from "../vault/file-hrefs";
import { isFilesystemAbsolutePath, isVaultConfigPath, normalizeFilePath, vaultRelativePath } from "../vault/paths";
import type { Thread } from "./model";
import { threadArchiveTitle } from "./title";
import type { ThreadTranscriptEntry } from "./transcript";
interface MarkdownSourceReplacement {
start: number;
interface ParsedMarkdownLink {
raw: string;
label: string;
href: string;
end: number;
value: string;
}
export interface ArchiveMarkdownOptions {
export interface ArchiveExportSettings {
archiveExportFolderTemplate: string;
archiveExportFilenameTemplate: string;
archiveExportTags?: string;
vaultPath?: string;
vaultConfigDir?: string;
@ -25,31 +24,56 @@ export interface ArchiveThreadInput extends Thread {
transcriptEntries: readonly ThreadTranscriptEntry[];
}
export function archivedThreadMarkdown(thread: ArchiveThreadInput, exportedAt = new Date(), settings: ArchiveMarkdownOptions = {}): string {
export function archivedThreadMarkdown(
thread: ArchiveThreadInput,
exportedAt = new Date(),
settings?: Partial<ArchiveExportSettings>,
): string {
const title = threadArchiveTitle(thread);
const tags = normalizedArchiveTags(settings.archiveExportTags ?? "");
const frontmatter = [
const tags = normalizedArchiveTags(settings?.archiveExportTags ?? "");
const lines = [
"---",
`title: ${yamlFrontmatterString(title)}`,
`thread_id: ${yamlFrontmatterString(thread.id)}`,
`created: ${yamlFrontmatterString(formatDate(exportedAt))}`,
...frontmatterTagsLines(tags),
"---",
].join("\n");
const body = `${trimTrailingBlankLines([`# ${title}`, "", ...transcriptMarkdownLines(thread.transcriptEntries)]).join("\n")}\n`;
const normalizedBody = settings.vaultPath ? normalizeExportedMarkdownLinks(body, settings.vaultPath, settings.vaultConfigDir) : body;
return `${frontmatter}\n\n${normalizedBody}`;
"",
`# ${title}`,
"",
...transcriptMarkdownLines(thread.transcriptEntries),
];
const markdown = `${trimTrailingBlankLines(lines).join("\n")}\n`;
return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath, settings.vaultConfigDir) : markdown;
}
function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
const replacements: MarkdownSourceReplacement[] = [];
visit(fromMarkdown(markdown), "link", (link) => {
const replacement = normalizedExportedMarkdownLink(link, vaultPath, vaultConfigDir);
if (replacement) replacements.push(replacement);
});
let output = markdown;
for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
const lines = markdown.split("\n");
let inFence = false;
return lines
.map((line) => {
if (line.trimStart().startsWith("```")) {
inFence = !inFence;
return line;
}
return inFence ? line : normalizeExportedMarkdownLinksInLine(line, vaultPath, vaultConfigDir);
})
.join("\n");
}
function normalizeExportedMarkdownLinksInLine(line: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
let output = "";
let index = 0;
while (index < line.length) {
const parsed = parseMarkdownLinkAt(line, index);
if (!parsed || isInsideInlineCode(line, index)) {
output += line[index] ?? "";
index += 1;
continue;
}
output += normalizedExportedMarkdownLink(parsed, vaultPath, vaultConfigDir);
index = parsed.end;
}
return output;
}
@ -139,45 +163,67 @@ function stripMatchingQuotes(value: string): string {
return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value;
}
function normalizedExportedMarkdownLink(
link: Link,
vaultPath: string,
vaultConfigDir: string | null | undefined,
): MarkdownSourceReplacement | null {
const start = link.position?.start.offset;
const end = link.position?.end.offset;
if (start === undefined || end === undefined || !link.url) return null;
function unwrappedMarkdownHref(value: string): string {
if (value.startsWith("<") && value.endsWith(">")) return value.slice(1, -1);
return value;
}
const parsed = parseFileHref(link.url);
if (!parsed) return null;
const vaultRelative = vaultRelativePath(vaultPath, parsed.path);
if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) {
return {
start,
end,
value: markdownNodeSource({ ...link, url: `${vaultRelative}${parsed.subpath}` }),
};
}
function parseMarkdownLinkAt(line: string, start: number): ParsedMarkdownLink | null {
if (line[start] !== "[" || line[start - 1] === "!") return null;
if (!isFilesystemAbsolutePath(normalizeFilePath(parsed.path))) return null;
const labelEnd = line.indexOf("]", start + 1);
if (labelEnd === -1 || line[labelEnd + 1] !== "(") return null;
const hrefStart = labelEnd + 2;
const hrefEnd = line[hrefStart] === "<" ? line.indexOf(">)", hrefStart + 1) : line.indexOf(")", hrefStart);
if (hrefEnd === -1) return null;
const end = line[hrefStart] === "<" ? hrefEnd + 2 : hrefEnd + 1;
return {
start,
raw: line.slice(start, end),
label: line.slice(start + 1, labelEnd),
href: line.slice(hrefStart, hrefEnd + (line[hrefStart] === "<" ? 1 : 0)),
end,
value: markdownNodeSource({
type: "paragraph",
children: [...link.children, { type: "text", value: " (" }, { type: "inlineCode", value: link.url }, { type: "text", value: ")" }],
}),
};
}
function markdownNodeSource(node: Nodes): string {
return toMarkdown(node, { unsafe: [{ character: "|", inConstruct: ["label", "phrasing"] }] }).trimEnd();
function normalizedExportedMarkdownLink(link: ParsedMarkdownLink, vaultPath: string, vaultConfigDir: string | null | undefined): string {
const href = unwrappedMarkdownHref(link.href.trim());
if (!href || isExternalFileHref(href)) return link.raw;
const parsed = parseFileHref(href);
if (!parsed) return link.raw;
const vaultRelative = vaultRelativePath(vaultPath, parsed.path);
if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) {
return `[${link.label}](${markdownHref(`${vaultRelative}${parsed.subpath}`)})`;
}
return isFilesystemAbsolutePath(normalizeFilePath(parsed.path)) ? `${link.label} (${markdownCodeSpan(href)})` : link.raw;
}
function archiveExportShouldKeepAbsolute(vaultRelativePath: string, vaultConfigDir: string | null | undefined): boolean {
return typeof vaultConfigDir === "string" && vaultConfigDir.length > 0 && isVaultConfigPath(vaultRelativePath, vaultConfigDir);
}
function markdownHref(value: string): string {
return /[\s()]/.test(value) ? `<${value}>` : value;
}
function markdownCodeSpan(value: string): string {
let fenceLength = 1;
for (const match of value.matchAll(/`+/g)) fenceLength = Math.max(fenceLength, match[0].length + 1);
const fence = "`".repeat(fenceLength);
return fenceLength === 1 ? `${fence}${value}${fence}` : `${fence} ${value} ${fence}`;
}
function isInsideInlineCode(line: string, offset: number): boolean {
let inCode = false;
for (let index = 0; index < offset; index += 1) {
if (line[index] === "`") inCode = !inCode;
}
return inCode;
}
function formatDate(date: Date): string {
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
@ -191,11 +237,3 @@ function trimTrailingBlankLines(lines: string[]): string[] {
while (result[result.length - 1] === "") result.pop();
return result;
}
function yamlFrontmatterString(value: string): string {
return JSON.stringify(value);
}
function yamlFrontmatterInlineList(values: readonly string[]): string {
return `[${values.map(yamlFrontmatterString).join(", ")}]`;
}

View file

@ -1,29 +0,0 @@
import type { Thread } from "./model";
import { threadCommandDisplayTitle } from "./title";
const CODEX_THREAD_LINK_PREFIX = "codex://threads/";
const MAX_THREAD_ID_LENGTH = 160;
export function codexThreadHref(threadId: string): string {
return `${CODEX_THREAD_LINK_PREFIX}${encodeURIComponent(threadId)}`;
}
export function codexThreadIdFromHref(href: string): string | null {
if (!href.startsWith(CODEX_THREAD_LINK_PREFIX)) return null;
const encoded = href.slice(CODEX_THREAD_LINK_PREFIX.length);
if (!encoded || encoded.includes("/") || encoded.includes("?") || encoded.includes("#")) return null;
try {
const threadId = decodeURIComponent(encoded);
return threadId.length > 0 && threadId.length <= MAX_THREAD_ID_LENGTH ? threadId : null;
} catch {
return null;
}
}
export function threadReferenceMarkdown(thread: Thread): string {
return `[${markdownLinkLabel(threadCommandDisplayTitle(thread))}](${codexThreadHref(thread.id)})`;
}
function markdownLinkLabel(value: string): string {
return value.replace(/[\\[\]]/g, "\\$&");
}

View file

@ -6,11 +6,6 @@ export interface Thread {
readonly createdAt: number;
readonly updatedAt: number;
readonly recencyAt?: number | null;
/**
* Whether the loaded app-server thread accepts direct turn input.
* `null` means the capability is unavailable, so panel mode policy decides.
*/
readonly canAcceptDirectInput?: boolean | null;
readonly provenance: ThreadProvenance;
}

View file

@ -1,5 +1,6 @@
import { truncateUtf8, utf8ByteLength } from "../chat/context-budget";
import type { Thread } from "./model";
import { threadDisplayTitle } from "./title";
import type { TurnTranscriptSummary } from "./transcript";
export const REFERENCED_THREAD_TURN_LIMIT = 20;
@ -13,12 +14,27 @@ export interface ReferencedThreadMetadata {
truncated?: boolean;
}
export interface ReferencedThreadContextBundle {
value: string;
referencedThread: ReferencedThreadMetadata;
}
const REFERENCED_THREAD_CONTEXT_MAX_BYTES = 18_000;
export function referencedThreadContext(thread: Thread, turns: readonly TurnTranscriptSummary[]): string {
function referencedThreadMetadata(thread: Thread, count: number): ReferencedThreadMetadata {
return {
threadId: thread.id,
title: threadDisplayTitle(thread),
includedTurns: count,
turnLimit: REFERENCED_THREAD_TURN_LIMIT,
};
}
export function referencedThreadContextBundle(thread: Thread, turns: readonly TurnTranscriptSummary[]): ReferencedThreadContextBundle {
const rendered = turns.map((turn, index) => renderedReferenceTurn(turn, index + 1));
const included: string[] = [];
let bytes = 0;
let truncatedTurn = false;
for (let index = rendered.length - 1; index >= 0; index -= 1) {
const value = rendered[index];
if (value === undefined) continue;
@ -27,6 +43,7 @@ export function referencedThreadContext(thread: Thread, turns: readonly TurnTran
if (included.length === 0) {
const turn = turns[index];
if (turn) included.unshift(truncatedReferenceTurn(turn, index + 1, REFERENCED_THREAD_CONTEXT_MAX_BYTES));
truncatedTurn = true;
}
break;
}
@ -34,14 +51,22 @@ export function referencedThreadContext(thread: Thread, turns: readonly TurnTran
bytes += nextBytes;
}
const omittedTurns = turns.length - included.length;
return [
"Referenced thread context for the current user input:",
`Thread: ${thread.id}`,
`Included turns: ${String(included.length)}`,
`Omitted turns: ${String(omittedTurns)}`,
"",
...included,
].join("\n");
const reference = {
...referencedThreadMetadata(thread, included.length),
omittedTurns,
truncated: omittedTurns > 0 || truncatedTurn,
};
return {
value: [
"Referenced thread context for the current user input:",
`Thread: ${thread.id}`,
`Included turns: ${String(reference.includedTurns)}`,
`Omitted turns: ${String(omittedTurns)}`,
"",
...included,
].join("\n"),
referencedThread: reference,
};
}
function renderedReferenceTurn(turn: TurnTranscriptSummary, index: number): string {

View file

@ -1,25 +1,15 @@
import type { ThreadTitleContext } from "./title-generation-model";
type ThreadRenameAutoNameState = { kind: "checking" } | { kind: "unavailable" } | { kind: "ready"; context: ThreadTitleContext };
export type ThreadRenameLifecycleState =
| { kind: "idle" }
| { kind: "editing"; draft: string; autoName: ThreadRenameAutoNameState }
| { kind: "saving"; draft: string; autoName: ThreadRenameAutoNameState; saveToken: number }
| { kind: "generating"; draft: string; autoName: Extract<ThreadRenameAutoNameState, { kind: "ready" }>; generationToken: number };
| { kind: "editing"; draft: string }
| { kind: "generating"; draft: string; originalDraft: string; generationToken: number };
export type ThreadRenameActiveState = Exclude<ThreadRenameLifecycleState, { kind: "idle" }>;
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
type ThreadRenameSavingState = Extract<ThreadRenameLifecycleState, { kind: "saving" }>;
export type ThreadRenameLifecycleEvent =
| { type: "started"; draft: string }
| { type: "draft-updated"; draft: string }
| { type: "auto-name-context-resolved"; context: ThreadTitleContext | null }
| { type: "cancelled" }
| { type: "save-started"; saveToken: number }
| { type: "save-failed"; saveToken: number }
| { type: "save-succeeded"; saveToken: number }
| { type: "generation-started"; generationToken: number }
| { type: "generation-succeeded"; generationToken: number; draft: string }
| { type: "generation-finished"; generationToken: number }
@ -35,40 +25,25 @@ export function transitionThreadRenameLifecycleState(
): ThreadRenameLifecycleState {
switch (event.type) {
case "started":
if (state.kind === "saving") return state;
return { kind: "editing", draft: event.draft, autoName: { kind: "checking" } };
return { kind: "editing", draft: event.draft };
case "draft-updated":
return state.kind === "editing" ? { ...state, draft: event.draft } : state;
case "auto-name-context-resolved":
if (state.kind !== "editing" && state.kind !== "saving") return state;
return {
...state,
autoName: event.context ? { kind: "ready", context: event.context } : { kind: "unavailable" },
};
return state.kind === "idle" ? state : { ...state, draft: event.draft };
case "cancelled":
return state.kind === "saving" || state.kind === "idle" ? state : initialThreadRenameLifecycleState();
case "save-started":
return state.kind === "editing" ? { ...state, kind: "saving", saveToken: event.saveToken } : state;
case "save-failed":
return threadRenameSaveStillActive(state, event.saveToken)
? { kind: "editing", draft: state.draft, autoName: state.autoName }
: state;
case "save-succeeded":
return threadRenameSaveStillActive(state, event.saveToken) ? initialThreadRenameLifecycleState() : state;
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
case "generation-started":
if (state.kind !== "editing" || state.autoName.kind !== "ready") return state;
if (state.kind !== "editing") return state;
return {
kind: "generating",
draft: state.draft,
autoName: state.autoName,
originalDraft: state.draft,
generationToken: event.generationToken,
};
case "generation-succeeded":
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
if (!threadRenameGenerationStillActive(state, event.generationToken) || state.draft !== state.originalDraft) return state;
return { ...state, draft: event.draft };
case "generation-finished":
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
return { kind: "editing", draft: state.draft, autoName: state.autoName };
return { kind: "editing", draft: state.draft };
case "cleared":
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
default:
@ -83,10 +58,6 @@ export function threadRenameGenerationStillActive(
return state.kind === "generating" && state.generationToken === generationToken;
}
export function threadRenameSaveStillActive(state: ThreadRenameLifecycleState, saveToken: number): state is ThreadRenameSavingState {
return state.kind === "saving" && state.saveToken === saveToken;
}
function unhandledThreadRenameLifecycleEvent(event: never): never {
throw new Error(`Unhandled thread rename lifecycle event: ${String(event)}`);
}

View file

@ -6,6 +6,9 @@ const DEFAULT_CONTEXT_PAGE_LIMIT = 20;
const DEFAULT_CONTEXT_MAX_PAGES = 5;
export const THREAD_TITLE_MAX_CHARS = 40;
export const THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE =
"Auto-name needs completed history or visible resumed history with both user and assistant text.";
export interface ThreadTitleContext {
userRequest: string;
assistantResponse: string;

View file

@ -5,7 +5,6 @@ const MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH = 96;
const MAX_THREAD_COMMAND_DISPLAY_TITLE_LENGTH = 96;
const UNTITLED_THREAD_TITLE = "Untitled thread";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
export function threadMeaningfulTitle(thread: Thread): string | null {
for (const value of [thread.name, thread.preview]) {
@ -53,10 +52,6 @@ function normalizeThreadTitleText(value: string | null | undefined): string {
}
function truncateThreadDisplayTitle(title: string, maxLength: number): string {
const graphemes = Array.from(GRAPHEME_SEGMENTER.segment(title), ({ segment }) => segment);
if (graphemes.length <= maxLength) return title;
return `${graphemes
.slice(0, maxLength - 3)
.join("")
.trimEnd()}...`;
if (title.length <= maxLength) return title;
return `${title.slice(0, maxLength - 3).trimEnd()}...`;
}

View file

@ -2,9 +2,9 @@ import type { App } from "obsidian";
import type { AppServerClient } from "./app-server/connection/client";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access";
import type { AppServerExecutionContext } from "./app-server/connection/execution-context";
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
import { AppServerQueryCache } from "./app-server/query/cache";
import type { AppServerQueryContext } from "./app-server/query/keys";
import { AppServerResourceStore, StaleAppServerResourceContextError } from "./app-server/query/resource-store";
import {
type EphemeralStructuredTurnClient,
type EphemeralStructuredTurnRunner,
@ -16,28 +16,22 @@ import { createAppServerSelectionRewriteTransport } from "./features/selection-r
import type { SelectionRewriteTransport } from "./features/selection-rewrite/transport";
import { openThreadPicker, type ThreadPickerController } from "./features/thread-picker/modal.obsidian";
import { createThreadOperationsTransport, createThreadTitleTransport } from "./features/threads/app-server/workflow-transports";
import type { ThreadCatalog } from "./features/threads/catalog/thread-catalog";
import {
createThreadOperationCoordinator,
type ThreadOperationCoordinator,
} from "./features/threads/workflows/thread-operation-coordinator";
import type { ThreadLifecycleEvent } from "./features/threads/workflows/thread-operation-event";
import { projectThreadListChanges } from "./features/threads/workflows/thread-read-model-projection";
import { createThreadCatalog, type ThreadCatalog, type ThreadCatalogEvent } from "./features/threads/catalog/thread-catalog";
import { createThreadNameMutationCoordinator } from "./features/threads/workflows/thread-name-mutation-coordinator";
import type { ThreadsViewHost, ThreadsViewSettingsAccess } from "./features/threads-view/session";
import type { ThreadsViewPanelActivity } from "./features/threads-view/state";
import type { ThreadsRuntimeView } from "./features/threads-view/view.obsidian";
import { createSettingsAppServerDynamicData } from "./settings/app-server-dynamic-data";
import type { SettingsDynamicDataAccess } from "./settings/dynamic-data";
import type { CodexPanelSettings } from "./settings/model";
import { StaleExecutionRuntimeError } from "./shared/runtime/execution-runtime-lifetime";
import { createKeyedOperationQueue } from "./shared/runtime/keyed-operation-queue";
export interface CodexExecutionRuntimeOptions {
app: App;
context: AppServerExecutionContext;
context: AppServerQueryContext;
settings: () => CodexPanelSettings;
workspace: WorkspacePanels;
onThreadLifecycleEvents(events: readonly ThreadLifecycleEvent[]): void;
onThreadCatalogEvent(event: ThreadCatalogEvent): void;
openNewPanel(): Promise<unknown>;
openThreadInCurrentView(threadId: string): Promise<void>;
openThreadInAvailableView(threadId: string): Promise<void>;
@ -50,12 +44,11 @@ export interface ExecutionRuntimeViews {
}
export class CodexExecutionRuntime implements AppServerClientAccess {
private readonly context: Readonly<AppServerExecutionContext>;
private readonly appServerQueries: AppServerQueryCache;
private readonly threadCatalog: ThreadCatalog;
private readonly threadOperationCoordinator: ThreadOperationCoordinator;
readonly context: Readonly<AppServerQueryContext>;
readonly resourceStore: AppServerResourceStore;
readonly threadCatalog: ThreadCatalog;
readonly settingsDynamicData: SettingsDynamicDataAccess;
private readonly threadNameMutations = createKeyedOperationQueue<string>();
private readonly threadNameMutations = createThreadNameMutationCoordinator();
private readonly threadGoalOperations = createThreadGoalOperationCoordinator();
private readonly runtimeSettingsCommitQueue = createKeyedOperationQueue<string>();
private readonly shortLivedClients = new Set<AppServerClient>();
@ -68,31 +61,38 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
constructor(private readonly options: CodexExecutionRuntimeOptions) {
this.context = Object.freeze({ ...options.context });
this.appServerQueries = new AppServerQueryCache(this.context, this);
this.threadCatalog = this.appServerQueries;
this.threadOperationCoordinator = createThreadOperationCoordinator((events) => {
this.threadCatalog.applyThreadListMutations(projectThreadListChanges(this.threadCatalog, events));
options.onThreadLifecycleEvents(events);
this.resourceStore = new AppServerResourceStore({
context: this.context,
clientRunner: {
runWithClient: (operation, clientOptions) => this.runWithAppServerClient(operation, clientOptions),
},
});
this.threadCatalog = createThreadCatalog({
store: this.resourceStore,
onEventApplied: (event) => {
options.onThreadCatalogEvent(event);
},
});
this.settingsDynamicData = createSettingsAppServerDynamicData({
vaultPath: this.context.vaultPath,
clientAccess: this,
appServerQueries: this.appServerQueries,
appServerQueries: this.resourceStore,
threadCatalog: this.threadCatalog,
threadEvents: this.threadOperationCoordinator,
});
}
private chatHost(): CodexChatHost {
chatHost(): CodexChatHost {
this.assertActive();
return {
appServerClientAccess: this,
appServerContext: this.context,
settings: this.chatSettings(),
settingsRef: {
settings: this.chatSettings(),
vaultPath: this.context.vaultPath,
},
workspace: this.options.workspace,
appServerQueries: this.appServerQueries,
appServerQueries: this.resourceStore,
threadCatalog: this.threadCatalog,
threadOperationCoordinator: this.threadOperationCoordinator,
threadNameMutations: this.threadNameMutations,
threadTitleTransport: this.threadTitleTransport(),
threadGoalOperations: this.threadGoalOperations,
@ -100,13 +100,12 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
};
}
private threadsHost(): ThreadsViewHost {
threadsHost(): ThreadsViewHost {
this.assertActive();
return {
settings: this.threadsSettings(),
vaultPath: this.context.vaultPath,
threadCatalog: this.threadCatalog,
threadEvents: this.threadOperationCoordinator,
threadNameMutations: this.threadNameMutations,
threadOperationsTransport: createThreadOperationsTransport(this),
threadTitleTransport: this.threadTitleTransport(),
@ -264,7 +263,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
});
this.shortLivedClients.clear();
this.tryCleanup(() => {
this.appServerQueries.dispose();
this.resourceStore.dispose();
});
return views;
}
@ -278,25 +277,20 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
this.assertActive();
return operation(client);
};
try {
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
created: (client) => {
if (this.disposed) {
client.disconnect();
throw new StaleExecutionRuntimeError();
}
this.shortLivedClients.add(client);
},
disposed: (client) => {
this.shortLivedClients.delete(client);
},
});
this.assertActive();
return result;
} catch (error) {
if (this.disposed) throw new StaleExecutionRuntimeError();
throw error;
}
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
created: (client) => {
if (this.disposed) {
client.disconnect();
throw new StaleAppServerResourceContextError();
}
this.shortLivedClients.add(client);
},
disposed: (client) => {
this.shortLivedClients.delete(client);
},
});
this.assertActive();
return result;
}
private structuredTurnRunner(): EphemeralStructuredTurnRunner {
@ -317,7 +311,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
created: (client) => {
if (this.disposed) {
client.disconnect();
throw new StaleExecutionRuntimeError();
throw new StaleAppServerResourceContextError();
}
this.structuredTurnClients.add(client);
},
@ -346,7 +340,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
}
private assertActive(): void {
if (this.disposed) throw new StaleExecutionRuntimeError();
if (this.disposed) throw new StaleAppServerResourceContextError();
}
private tryCleanup(operation: () => void): boolean {
@ -368,15 +362,21 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,
archiveExportTags: this.options.settings().archiveExportTags,
}),
codexPath: () => this.context.codexPath,
scrollThreadFromComposerEdges: () => this.options.settings().scrollThreadFromComposerEdges,
sendShortcut: () => this.options.settings().sendShortcut,
showToolbar: () => this.options.settings().showToolbar,
threadNamingEffort: () => this.options.settings().threadNamingEffort,
threadNamingModel: () => this.options.settings().threadNamingModel,
};
}
private threadsSettings(): ThreadsViewSettingsAccess {
return {
archiveExportEnabled: () => this.options.settings().archiveExportEnabled,
codexPath: () => this.context.codexPath,
threadNamingModel: () => this.options.settings().threadNamingModel,
threadNamingEffort: () => this.options.settings().threadNamingEffort,
archiveExportSettings: () => ({
archiveExportFolderTemplate: this.options.settings().archiveExportFolderTemplate,
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,

View file

@ -14,7 +14,7 @@ import {
type PendingUserInput,
} from "../../../../domain/pending-requests/model";
import type { TurnTranscriptSummary } from "../../../../domain/threads/transcript";
import type { ThreadOperationEvent } from "../../../threads/workflows/thread-operation-event";
import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
import type { LocalIdSource } from "../../application/local-id-source";
import { activeThreadId, type ChatAction, type ChatState } from "../../application/state/root-reducer";
@ -34,7 +34,7 @@ export interface ChatInboundHandlerActions {
refreshServerDiagnostics: (options?: { forceResourceProbes?: boolean }) => void;
applyAppServerResourceEvent: (event: AppServerResourceEvent) => void;
maybeNameThread: (threadId: string, turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null) => void;
applyThreadOperationEvent: (event: ThreadOperationEvent) => void;
applyThreadCatalogEvent: (event: ThreadCatalogEvent) => void;
respondToServerRequest: (requestId: RequestId, result: unknown) => boolean;
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
}
@ -248,8 +248,8 @@ function runNotificationEffect(context: ChatInboundHandlerContext, effect: ChatN
case "maybe-name-thread":
context.actions.maybeNameThread(effect.threadId, effect.turnId, effect.completedTurnTranscriptSummary);
return;
case "apply-thread-operation-event":
context.actions.applyThreadOperationEvent(effect.event);
case "apply-thread-catalog-event":
context.actions.applyThreadCatalogEvent(effect.event);
return;
}
}

View file

@ -2,7 +2,7 @@ import type { ServerNotification } from "../../../../app-server/connection/rpc-m
import { threadFromAppServerRecord } from "../../../../app-server/services/threads";
import { threadTokenUsageFromRuntimeUsage } from "../../../../domain/runtime/metrics";
import { normalizeExplicitThreadName } from "../../../../domain/threads/model";
import type { ThreadOperationEvent } from "../../../threads/workflows/thread-operation-event";
import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
import { activeThreadSettingsAppliedAction } from "../../application/state/actions";
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../../application/state/root-reducer";
@ -22,7 +22,7 @@ export type ChatNotificationEffect =
}
| { type: "refresh-server-diagnostics"; forceResourceProbes?: boolean }
| { type: "apply-app-server-resource-event"; event: AppServerResourceEvent }
| { type: "apply-thread-operation-event"; event: ThreadOperationEvent };
| { type: "apply-thread-catalog-event"; event: ThreadCatalogEvent };
type TurnRuntimeCompletedTurnTranscriptSummary = TurnRuntimeOutcome["completedTurnTranscriptSummary"];
@ -291,23 +291,17 @@ function planThreadLifecycle(
case "thread/started":
return threadStartedPlan(state, notification);
case "thread/archived":
return effectPlan({
type: "apply-thread-operation-event",
event: { type: "thread-archived", threadId: notification.params.threadId },
});
return effectPlan({ type: "apply-thread-catalog-event", event: { type: "thread-archived", threadId: notification.params.threadId } });
case "thread/deleted":
return effectPlan({
type: "apply-thread-operation-event",
event: { type: "thread-deleted", threadId: notification.params.threadId },
});
return effectPlan({ type: "apply-thread-catalog-event", event: { type: "thread-deleted", threadId: notification.params.threadId } });
case "thread/unarchived":
return effectPlan({
type: "apply-thread-operation-event",
type: "apply-thread-catalog-event",
event: { type: "thread-unarchived", threadId: notification.params.threadId },
});
case "thread/name/updated":
return effectPlan({
type: "apply-thread-operation-event",
type: "apply-thread-catalog-event",
event: {
type: "thread-renamed",
threadId: notification.params.threadId,
@ -339,12 +333,8 @@ function threadStartedPlan(
? []
: [
{
type: "apply-thread-operation-event",
event: {
type: "thread-upserted",
thread,
forkedFromThreadId: notification.params.thread.forkedFromId,
},
type: "apply-thread-catalog-event",
event: { type: "thread-upserted", thread },
},
];
return { actions: trackAction, effects };

View file

@ -105,9 +105,6 @@ const IGNORED_SERVER_NOTIFICATION_METHODS = [
"thread/status/changed",
"thread/closed",
"rawResponseItem/completed",
"rawResponse/completed",
"thread/environment/connected",
"thread/environment/disconnected",
"command/exec/outputDelta",
"process/outputDelta",
"process/exited",
@ -160,7 +157,6 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
"item/autoApprovalReview/completed": threadTurnNotificationScope,
"item/completed": threadTurnNotificationScope,
"rawResponseItem/completed": threadTurnNotificationScope,
"rawResponse/completed": threadTurnNotificationScope,
"item/agentMessage/delta": threadTurnNotificationScope,
"item/plan/delta": threadTurnNotificationScope,
"command/exec/outputDelta": unscopedNotificationScope,
@ -204,8 +200,6 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
"windows/worldWritableWarning": unscopedNotificationScope,
"windowsSandbox/setupCompleted": unscopedNotificationScope,
"account/login/completed": unscopedNotificationScope,
"thread/environment/connected": threadOnlyNotificationScope,
"thread/environment/disconnected": threadOnlyNotificationScope,
};
export function routeServerNotification(notification: ServerNotification, scope: ActiveRouteScope): ServerNotificationRoute {

View file

@ -1,4 +1,5 @@
import { type ExecutionState, RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/items";
import { RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/execution-state";
import type { ExecutionState } from "../../../domain/thread-stream/items";
export { RUNNING_EXECUTION_STATE };

View file

@ -7,7 +7,7 @@ import {
import { jsonPreview } from "../../../../../domain/display/json-preview";
import type { HistoricalTurn } from "../../../../../domain/threads/history";
import type { TurnTranscriptSummary } from "../../../../../domain/threads/transcript";
import { contextAttachmentsFromHistoryContexts } from "../../../domain/thread-stream/format/context-attachments";
import { contextAttachmentsFromManifest } from "../../../domain/thread-stream/format/context-attachments";
import { threadStreamFileReferences } from "../../../domain/thread-stream/format/file-references";
import { normalizeProposedPlanMarkdown } from "../../../domain/thread-stream/format/proposed-plan";
import { userMessageDisplayText } from "../../../domain/thread-stream/format/user-message-text";
@ -134,7 +134,7 @@ function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStr
const text = projection.text;
const referencedThread = projection.referencedThread;
const referencedFiles = threadStreamFileReferences(projection.fileReferences);
const contextAttachments = contextAttachmentsFromHistoryContexts(projection.contexts, text);
const contextAttachments = contextAttachmentsFromManifest(projection.manifest, text);
if (referencedThread) {
return {
...turnItemSourceFields(item, turnId),

View file

@ -1,10 +1,9 @@
import type { AppServerRequestClient } from "../../../app-server/services/request-client";
import { readReferencedThreadTurnTranscriptSummaries } from "../../../app-server/services/threads";
import { type CodexInput, codexTextInputWithAttachments } from "../../../domain/chat/input";
import { threadReferenceMarkdown } from "../../../domain/threads/deep-link";
import { shortThreadId } from "../../../domain/threads/id";
import type { Thread } from "../../../domain/threads/model";
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadContext } from "../../../domain/threads/reference";
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadContextBundle } from "../../../domain/threads/reference";
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
import type { ThreadReferenceInput } from "../application/turns/slash-command-execution";
@ -41,20 +40,28 @@ async function referencedThreadInput(
return null;
}
const messageInput = host.prepareInput(message, snapshot);
const reference = referencedThreadContext(thread, turns);
const visibleText = `${threadReferenceMarkdown(thread)}\n\n${messageInput.text}`;
const reference = referencedThreadContextBundle(thread, turns);
host.setStatus(`Referencing ${shortThreadId(thread.id)} (${String(turns.length)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`);
return {
text: visibleText,
input: codexTextInputWithAttachments(visibleText, [
text: messageInput.text,
input: codexTextInputWithAttachments(messageInput.text, [
{
type: "additionalContext",
key: "codex_panel_referenced_thread",
kind: "untrusted",
value: reference,
value: reference.value,
attachment: {
kind: "referencedThread",
threadId: reference.referencedThread.threadId,
includedTurns: reference.referencedThread.includedTurns,
turnLimit: reference.referencedThread.turnLimit,
omittedTurns: reference.referencedThread.omittedTurns ?? 0,
truncated: reference.referencedThread.truncated ?? false,
},
},
...messageInput.input,
]),
referencedThread: reference.referencedThread,
};
} catch (error) {
if (host.currentClient() !== client) return null;

View file

@ -10,6 +10,7 @@ import {
readThreadGoal,
recordThreadGoalUserMessage,
resumeThread,
rollbackThread,
setThreadGoal,
startThread,
threadActivationSnapshotFromAppServerResponse,
@ -29,7 +30,7 @@ import type {
ThreadResumeSnapshot,
ThreadResumeTransport,
} from "../../application/threads/thread-loading-transport";
import type { ThreadMutationTransport } from "../../application/threads/thread-mutation-transport";
import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport";
import type { ThreadStartTransport } from "../../application/threads/thread-start-transport";
import type { ThreadSubscriptionTransport } from "../../application/threads/thread-subscription-transport";
import type { ChatTurnTransport } from "../../application/turns/turn-transport";
@ -165,8 +166,16 @@ function createChatThreadMutationTransport(host: ChatAppServerTransportHost): Th
runCurrentChatAppServerEffect(host, async (client) => {
await compactThread(client, threadId);
}),
forkThread: (threadId, options) =>
runCurrentChatAppServerEffect(host, (client) => forkThread(client, threadId, host.vaultPath, options)),
forkThread: (threadId, lastTurnId = null) =>
runCurrentChatAppServerEffect(host, (client) => forkThread(client, threadId, host.vaultPath, lastTurnId)),
rollbackThread: (threadId) =>
runCurrentChatAppServerEffect(host, async (client): Promise<ThreadRollbackSnapshot> => {
const snapshot = await rollbackThread(client, threadId);
return {
thread: snapshot.thread,
items: threadStreamItemsFromTurns(snapshot.turns),
};
}),
};
}

View file

@ -0,0 +1,7 @@
export interface DailyNoteReferenceCandidate {
keyword: "today" | "tomorrow" | "yesterday";
display: string;
name: string;
path: string;
linktext: string;
}

View file

@ -1,28 +1,6 @@
import type { VaultFileReference } from "../../../../domain/chat/input";
export interface DailyNoteReferenceCandidate {
keyword: "today" | "tomorrow" | "yesterday";
display: string;
name: string;
path: string;
linktext: string;
}
export interface NoteHeadingCandidate {
heading: string;
linkHeading: string;
level: number;
}
export interface NoteCandidate {
basename: string;
displayName: string;
path: string;
mtime: number;
linktext: string;
headings: NoteHeadingCandidate[];
recentIndex: number | null;
}
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
import type { NoteCandidate } from "./suggestions";
export interface NoteCandidateProvider {
candidates(sourcePath: string): readonly NoteCandidate[];

View file

@ -19,7 +19,7 @@ import {
type SelectionContextReference,
selectionContextReferenceMarker,
} from "./context-references";
import type { DailyNoteReferenceCandidate, NoteCandidate, NoteHeadingCandidate } from "./note-context";
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
import { isSlashCommandName, SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands";
import {
partialThreadTitleQuery,
@ -51,6 +51,22 @@ export interface ComposerSuggestionOptions {
tagCandidates?: readonly string[] | (() => readonly string[]);
}
export interface NoteCandidate {
basename: string;
displayName: string;
path: string;
mtime: number;
linktext: string;
headings: NoteHeadingCandidate[];
recentIndex: number | null;
}
interface NoteHeadingCandidate {
heading: string;
linkHeading: string;
level: number;
}
interface NoteCandidateMatch {
file: NoteCandidate;
match: SearchResult;

View file

@ -8,7 +8,6 @@ import {
type SkillReference,
type VaultFileReference,
} from "../../../../domain/chat/input";
import { type MarkdownCodeRange, markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../domain/markdown/code-ranges";
import {
type ActiveNoteContextReference,
type ComposerContextReferences,
@ -44,11 +43,10 @@ interface PreparedComposerInputOptions {
referenceActiveNoteOnSend: boolean;
}
function parsedWikiLinks(text: string, codeRanges: readonly MarkdownCodeRange[]): ParsedWikiLink[] {
function parsedWikiLinks(text: string): ParsedWikiLink[] {
const links: ParsedWikiLink[] = [];
const seen = new Set<string>();
for (const match of text.matchAll(WIKILINK_PATTERN)) {
if (markdownCodeRangeContainsOffset(codeRanges, match.index)) continue;
const rawValue = match[1];
if (rawValue === undefined) continue;
const raw = rawValue.trim();
@ -69,15 +67,14 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
contextReferences: ComposerContextReferences,
options: PreparedComposerInputOptions,
): PreparedComposerInput {
const codeRanges = markdownReferenceCodeRanges(text);
const contextReplacement = textWithContextReferences(text, contextReferences, codeRanges);
const contextReplacement = textWithContextReferences(text, contextReferences);
const resolvedText = contextReplacement.text;
const fileReferences: VaultFileReference[] = [];
const wikilinkReferences: ObsidianReference[] = [];
const seenPaths = new Set<string>();
const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? [];
for (const link of parsedWikiLinks(resolvedText, codeRanges)) {
for (const link of parsedWikiLinks(resolvedText)) {
const fileReference = activeNoteFileReferenceForLink(link, activeNoteSnapshots) ?? resolveFileReference(link.target);
if (!fileReference || seenPaths.has(fileReference.path)) continue;
seenPaths.add(fileReference.path);
@ -97,7 +94,7 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
const skillByName = firstEnabledSkillByName(skills);
const resolvedSkills: SkillReference[] = [];
const seenSkillPaths = new Set<string>();
for (const reference of parsedSkillReferences(resolvedText, codeRanges)) {
for (const reference of parsedSkillReferences(resolvedText)) {
const skill = skillByName.get(reference.toLowerCase());
if (!skill || seenSkillPaths.has(skill.path)) continue;
seenSkillPaths.add(skill.path);
@ -123,24 +120,19 @@ function activeNoteFileReferenceForLink(link: ParsedWikiLink, snapshots: readonl
function textWithContextReferences(
text: string,
contextReferences: ComposerContextReferences,
codeRanges: readonly MarkdownCodeRange[],
): { text: string; selections: SelectionContextReference[] } {
return {
text,
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? [], codeRanges),
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? []),
};
}
function selectionsReferencedByText(
text: string,
snapshots: readonly SelectionContextReference[],
codeRanges: readonly MarkdownCodeRange[],
): SelectionContextReference[] {
function selectionsReferencedByText(text: string, snapshots: readonly SelectionContextReference[]): SelectionContextReference[] {
const selections: SelectionContextReference[] = [];
const seen = new Set<string>();
for (const snapshot of snapshots) {
const marker = selectionContextReferenceMarker(snapshot);
if (!includesOutsideMarkdownCode(text, marker, codeRanges) || seen.has(marker)) continue;
if (!text.includes(marker) || seen.has(marker)) continue;
seen.add(marker);
selections.push(snapshot);
}
@ -179,11 +171,13 @@ function referenceKey(marker: string, path: string): string {
function obsidianContextAdditionalContext(references: readonly ObsidianReference[]): RequestAdditionalContext[] {
if (references.length === 0) return [];
const inlineExcerpts = references.filter((reference) => reference.excerpt !== undefined).length;
return [
{
key: OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY,
kind: "untrusted",
value: obsidianContextValue(references),
...(inlineExcerpts > 0 ? { attachment: { kind: "obsidian" as const, inlineExcerpts } } : {}),
},
];
}
@ -208,12 +202,10 @@ function excerptLines(references: readonly (ObsidianReference & { excerpt: strin
});
}
function parsedSkillReferences(text: string, codeRanges: readonly MarkdownCodeRange[]): string[] {
function parsedSkillReferences(text: string): string[] {
const references: string[] = [];
const seen = new Set<string>();
for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) {
const prefix = match[1] ?? "";
if (markdownCodeRangeContainsOffset(codeRanges, match.index + prefix.length)) continue;
const name = match[2];
if (name === undefined) continue;
const key = name.toLowerCase();
@ -224,19 +216,6 @@ function parsedSkillReferences(text: string, codeRanges: readonly MarkdownCodeRa
return references;
}
function markdownReferenceCodeRanges(text: string): MarkdownCodeRange[] {
return text.includes("[[") || text.includes("$") ? markdownCodeRanges(text) : [];
}
function includesOutsideMarkdownCode(text: string, value: string, codeRanges: readonly MarkdownCodeRange[]): boolean {
let index = text.indexOf(value);
while (index >= 0) {
if (!markdownCodeRangeContainsOffset(codeRanges, index)) return true;
index = text.indexOf(value, index + value.length);
}
return false;
}
function parseWikiLink(raw: string): ParsedWikiLink | null {
const trimmed = raw.trim();
if (!trimmed) return null;

View file

@ -36,7 +36,7 @@ export interface ChatConnectionActionsHost {
addSystemMessage: (text: string) => void;
configuredCommand: () => string;
isStaleConnectionError: (error: unknown) => boolean;
isStaleRuntimeError: (error: unknown) => boolean;
isStaleResourceContextError: (error: unknown) => boolean;
notifyConnectionFailed: () => void;
}
@ -53,7 +53,6 @@ function handleChatConnectionExit(host: ChatConnectionExitHost): void {
}
export interface ChatConnectionActions {
ensureInitialized(): Promise<void>;
ensureConnected(): Promise<void>;
invalidate(): void;
handleExit(): void;
@ -64,40 +63,29 @@ export interface ChatConnectionActions {
export function createChatConnectionActions(host: ChatConnectionActionsHost): ChatConnectionActions {
let generation = 0;
let activeConnection: { generation: number; initialization: Promise<void>; hydration: Promise<void> } | null = null;
let activeConnection: { generation: number; promise: Promise<void> } | null = null;
const invalidate = (): void => {
generation += 1;
activeConnection = null;
};
const isStale = (candidateGeneration: number): boolean => candidateGeneration !== generation;
const startConnection = (): NonNullable<typeof activeConnection> => {
const connectionGeneration = generation;
const connectionIsStale = (): boolean => isStale(connectionGeneration);
const initialization = initializeConnection(host, connectionIsStale);
const hydration = initialization.then(async () => {
if (connectionIsStale() || !host.connection.isConnected()) return;
await hydrateConnectedResources(host, connectionIsStale);
});
const active = { generation: connectionGeneration, initialization, hydration };
activeConnection = active;
const clear = (): void => {
if (activeConnection === active) activeConnection = null;
};
void hydration.then(clear, clear);
return active;
};
const actions: ChatConnectionActions = {
ensureInitialized: async () => {
if (!host.canConnect()) return;
if (activeConnection) return activeConnection.initialization;
if (host.connection.isConnected()) return;
await startConnection().initialization;
},
ensureConnected: async () => {
if (!host.canConnect()) return;
if (activeConnection) return activeConnection.hydration;
if (activeConnection) return activeConnection.promise;
if (host.connection.isConnected()) return;
await startConnection().hydration;
const connectionGeneration = generation;
const promise = initializeConnection(host, () => isStale(connectionGeneration));
const active = { generation: connectionGeneration, promise };
activeConnection = active;
try {
await promise;
} finally {
if (activeConnection === active) {
activeConnection = null;
}
}
},
invalidate,
handleExit: () => {
@ -117,7 +105,7 @@ async function refreshActiveThreads(host: ChatConnectionActionsHost): Promise<vo
await host.refreshSharedThreads();
host.refreshTabHeader();
} catch (error) {
if (host.isStaleRuntimeError(error)) return;
if (host.isStaleResourceContextError(error)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
@ -155,20 +143,22 @@ async function initializeConnection(host: ChatConnectionActionsHost, isStale: ()
} catch (error) {
if (isStale()) return;
if (host.isStaleConnectionError(error)) return;
if (host.isStaleRuntimeError(error)) return;
if (host.isStaleResourceContextError(error)) return;
const message = connectionErrorMessage(error, host.configuredCommand());
host.setStatus(STATUS_CONNECTION_FAILED, { kind: "failed", message });
host.addSystemMessage(message);
host.notifyConnectionFailed();
return;
}
await hydrateConnectedResources(host, isStale);
}
async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStale: () => boolean): Promise<void> {
try {
await host.metadata.refreshAppServerMetadata();
} catch (error) {
if (isStale() || host.isStaleRuntimeError(error)) return;
if (isStale() || host.isStaleResourceContextError(error)) return;
host.addSystemMessage(`Could not refresh Codex metadata: ${errorMessage(error)}`);
}
if (isStale()) return;
@ -176,7 +166,7 @@ async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStal
try {
await host.refreshSharedThreads();
} catch (error) {
if (isStale() || host.isStaleRuntimeError(error)) return;
if (isStale() || host.isStaleResourceContextError(error)) return;
host.addSystemMessage(`Could not refresh Codex threads: ${errorMessage(error)}`);
}
if (isStale()) return;

View file

@ -19,7 +19,7 @@ export interface ServerMetadataActionsHost {
refreshAppServerMetadata: () => Promise<void>;
refreshSkills: () => Promise<void>;
refreshRateLimits: () => Promise<void>;
isStaleRuntimeError: (error: unknown) => boolean;
isStaleResourceContextError: (error: unknown) => boolean;
}
export interface ServerMetadataActions {
@ -106,7 +106,7 @@ async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promis
try {
await host.refreshAppServerMetadata();
} catch (error) {
if (host.isStaleRuntimeError(error)) return;
if (host.isStaleResourceContextError(error)) return;
throw error;
}
}
@ -115,7 +115,7 @@ async function refreshMetadataResource(host: ServerMetadataActionsHost, refresh:
try {
await refresh();
} catch (error) {
if (!host.isStaleRuntimeError(error)) throw error;
if (!host.isStaleResourceContextError(error)) throw error;
}
}

View file

@ -30,7 +30,6 @@ type ActivePanelThreadFacts =
| {
readonly phase: "active";
readonly lifetime: "persistent" | "ephemeral";
readonly canAcceptDirectInput: boolean | null;
readonly provenance: "interactive" | "subagent" | null;
};
@ -50,13 +49,6 @@ function activePanelOperationDecisionForFacts(
}
if (facts.phase === "empty") return ALLOWED;
if (operation === "submit") {
if (facts.canAcceptDirectInput !== null) return facts.canAcceptDirectInput ? ALLOWED : directInputBlocked();
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
if (facts.lifetime === "ephemeral") return sideChatDecision(operation);
return ALLOWED;
}
// Keep the stricter interpretation if a malformed or future state contains
// both mode facts. This makes mode restrictions monotonically safe.
if (facts.provenance === "subagent" && facts.lifetime === "ephemeral") {
@ -76,15 +68,10 @@ function activePanelThreadFacts(state: ChatState): ActivePanelThreadFacts {
return {
phase: "active",
lifetime: panelThread.thread.lifetime?.kind ?? "persistent",
canAcceptDirectInput: panelThread.thread.canAcceptDirectInput,
provenance: panelThread.thread.provenance?.kind ?? null,
};
}
function directInputBlocked(): ActivePanelOperationDecision {
return { kind: "blocked", message: "This thread cannot accept messages." };
}
function agentThreadBlocked(operation: ActivePanelOperation): ActivePanelOperationDecision {
switch (operation) {
case "submit":

View file

@ -123,7 +123,6 @@ export interface ChatActiveThreadState {
readonly goal: ThreadGoal | null;
readonly tokenUsage: ThreadTokenUsage | null;
readonly lifetime: ActiveThreadLifetime | null;
readonly canAcceptDirectInput: boolean | null;
readonly provenance: Thread["provenance"] | null;
}
@ -464,7 +463,6 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
goal: null,
tokenUsage: null,
lifetime: action.lifetime ?? { kind: "persistent" },
canAcceptDirectInput: action.thread.canAcceptDirectInput ?? null,
provenance: action.thread.provenance,
},
},
@ -902,7 +900,6 @@ function createActiveThreadState(id: string): ChatActiveThreadState {
goal: null,
tokenUsage: null,
lifetime: null,
canAcceptDirectInput: null,
provenance: null,
};
}

View file

@ -5,28 +5,21 @@ import {
type ThreadRenameLifecycleEvent,
type ThreadRenameLifecycleState,
threadRenameGenerationStillActive,
threadRenameSaveStillActive,
transitionThreadRenameLifecycleState,
} from "../../../../domain/threads/rename-lifecycle";
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
import type { DisclosureSetAction } from "./actions";
import { patchObject } from "./patch";
export type ChatRenameUiState = { readonly kind: "idle" } | (ThreadRenameActiveState & { readonly threadId: string });
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
export type ChatRenameSavingUiState = Extract<ChatRenameUiState, { kind: "saving" }>;
type ChatRenameUiAction = Extract<
UiAction,
{
type:
| "ui/rename-started"
| "ui/rename-draft-updated"
| "ui/rename-auto-name-context-resolved"
| "ui/rename-cancelled"
| "ui/rename-save-started"
| "ui/rename-save-failed"
| "ui/rename-save-succeeded"
| "ui/rename-generation-started"
| "ui/rename-generation-succeeded"
| "ui/rename-generation-finished"
@ -78,11 +71,7 @@ export type UiAction =
| { type: "ui/archive-confirm-set"; threadId: string | null }
| { type: "ui/rename-started"; threadId: string; draft: string }
| { type: "ui/rename-draft-updated"; threadId: string; draft: string }
| { type: "ui/rename-auto-name-context-resolved"; threadId: string; context: ThreadTitleContext | null }
| { type: "ui/rename-cancelled"; threadId: string }
| { type: "ui/rename-save-started"; threadId: string; saveToken: number }
| { type: "ui/rename-save-failed"; threadId: string; saveToken: number }
| { type: "ui/rename-save-succeeded"; threadId: string; saveToken: number }
| { type: "ui/rename-generation-started"; threadId: string; generationToken: number }
| { type: "ui/rename-generation-succeeded"; threadId: string; generationToken: number; draft: string }
| { type: "ui/rename-generation-finished"; threadId: string; generationToken: number }
@ -110,11 +99,7 @@ export function isUiAction(action: { type: string }): action is UiAction {
case "ui/archive-confirm-set":
case "ui/rename-started":
case "ui/rename-draft-updated":
case "ui/rename-auto-name-context-resolved":
case "ui/rename-cancelled":
case "ui/rename-save-started":
case "ui/rename-save-failed":
case "ui/rename-save-succeeded":
case "ui/rename-generation-started":
case "ui/rename-generation-succeeded":
case "ui/rename-generation-finished":
@ -138,11 +123,7 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState
return patchObject(state, { archiveConfirmThreadId: action.threadId });
case "ui/rename-started":
case "ui/rename-draft-updated":
case "ui/rename-auto-name-context-resolved":
case "ui/rename-cancelled":
case "ui/rename-save-started":
case "ui/rename-save-failed":
case "ui/rename-save-succeeded":
case "ui/rename-generation-started":
case "ui/rename-generation-succeeded":
case "ui/rename-generation-finished":
@ -191,10 +172,6 @@ export function renameGenerationStillActive(
return state.kind === "generating" && state.threadId === threadId && threadRenameGenerationStillActive(state, generationToken);
}
export function renameSaveStillActive(state: ChatRenameUiState, threadId: string, saveToken: number): state is ChatRenameSavingUiState {
return state.kind === "saving" && state.threadId === threadId && threadRenameSaveStillActive(state, saveToken);
}
export function clearAllRequestDisclosures(state: ChatUiState): ChatUiState {
if (state.disclosures.approvalDetails.size === 0) return state;
return patchObject(state, {
@ -276,23 +253,11 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string)
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
switch (action.type) {
case "ui/rename-started":
if (state.kind === "saving") return state;
return { kind: "editing", threadId: action.threadId, draft: action.draft, autoName: { kind: "checking" } };
return { kind: "editing", threadId: action.threadId, draft: action.draft };
case "ui/rename-draft-updated":
return transitionScopedChatRenameUiState(state, action.threadId, { type: "draft-updated", draft: action.draft });
case "ui/rename-auto-name-context-resolved":
return transitionScopedChatRenameUiState(state, action.threadId, {
type: "auto-name-context-resolved",
context: action.context,
});
case "ui/rename-cancelled":
return transitionScopedChatRenameUiState(state, action.threadId, { type: "cancelled" });
case "ui/rename-save-started":
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-started", saveToken: action.saveToken });
case "ui/rename-save-failed":
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-failed", saveToken: action.saveToken });
case "ui/rename-save-succeeded":
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-succeeded", saveToken: action.saveToken });
case "ui/rename-generation-started":
return transitionScopedChatRenameUiState(state, action.threadId, {
type: "generation-started",
@ -339,14 +304,12 @@ function chatRenameLifecycleStateWithoutThreadId(state: ChatRenameUiState): Thre
function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState, { kind: "idle" }>): ThreadRenameActiveState {
switch (state.kind) {
case "editing":
return { kind: "editing", draft: state.draft, autoName: state.autoName };
case "saving":
return { kind: "saving", draft: state.draft, autoName: state.autoName, saveToken: state.saveToken };
return { kind: "editing", draft: state.draft };
case "generating":
return {
kind: "generating",
draft: state.draft,
autoName: state.autoName,
originalDraft: state.originalDraft,
generationToken: state.generationToken,
};
}

View file

@ -3,7 +3,7 @@ import type { ThreadTitleContext } from "../../../../domain/threads/title-genera
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import { threadStreamItems } from "../state/thread-stream";
import { type ChatRenameUiState, renameGenerationStillActive, renameSaveStillActive } from "../state/ui-state";
import { type ChatRenameUiState, renameGenerationStillActive } from "../state/ui-state";
import { firstThreadTitleContextFromThreadStreamItems } from "./title-context";
interface RenameEditState {
@ -16,8 +16,7 @@ export interface ThreadRenameEditorActionsHost {
ensureConnected: () => Promise<void>;
addSystemMessage: (text: string) => void;
renameThread(threadId: string, value: string): Promise<boolean>;
resolveThreadTitleContext(threadId: string): Promise<ThreadTitleContext | null>;
generateThreadTitle(context: ThreadTitleContext, signal?: AbortSignal): Promise<string | null>;
generateThreadTitle(threadId: string): Promise<string>;
}
export interface ThreadRenameEditorActions {
@ -27,22 +26,15 @@ export interface ThreadRenameEditorActions {
start(threadId: string): void;
updateDraft(threadId: string, value: string): void;
cancel(threadId: string): void;
cancelAutoName(threadId: string): void;
save(threadId: string, value: string): Promise<void>;
autoNameDraft(threadId: string): Promise<void>;
}
export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions {
let nextRenameGenerationToken = 1;
let nextRenameSaveToken = 1;
let activeGeneration: { threadId: string; generationToken: number; controller: AbortController } | null = null;
let activeContextPreparation: { threadId: string } | null = null;
const action = {
invalidate(): void {
activeGeneration?.controller.abort();
activeGeneration = null;
activeContextPreparation = null;
dispatch(host, { type: "ui/rename-cleared" });
},
@ -60,13 +52,9 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
},
start(threadId: string): void {
const current = renameState(host);
if (current.kind === "saving") return;
const thread = host.stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
if (!thread) return;
abortActiveGeneration();
dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) });
void prepareAutoName(threadId);
},
updateDraft(threadId: string, value: string): void {
@ -74,38 +62,29 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
},
cancel(threadId: string): void {
const current = renameState(host);
if (current.kind === "saving" && current.threadId === threadId) return;
abortGeneration(threadId);
if (activeContextPreparation?.threadId === threadId) activeContextPreparation = null;
dispatch(host, { type: "ui/rename-cancelled", threadId });
},
cancelAutoName(threadId: string): void {
const current = renameState(host);
if (current.kind !== "generating" || current.threadId !== threadId) return;
abortGeneration(threadId);
finishAutoNameDraftGeneration(host, threadId, current.generationToken);
},
async save(threadId: string, value: string): Promise<void> {
const current = renameState(host);
if (current.kind !== "editing" || current.threadId !== threadId) return;
const saveToken = nextRenameSaveToken;
dispatch(host, { type: "ui/rename-save-started", threadId, saveToken });
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
nextRenameSaveToken += 1;
if (current.kind === "idle" || current.threadId !== threadId || current.kind === "generating") return;
const editingState = current;
try {
await host.ensureConnected();
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
if (renameState(host) !== editingState) return;
await host.renameThread(threadId, value);
dispatch(host, { type: "ui/rename-save-succeeded", threadId, saveToken });
const result = await host.renameThread(threadId, value);
if (!result) {
if (renameState(host) === editingState) action.cancel(threadId);
return;
}
if (renameState(host) === editingState) {
dispatch(host, { type: "ui/rename-cleared" });
}
} catch (error) {
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
if (renameState(host) !== editingState) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
dispatch(host, { type: "ui/rename-save-failed", threadId, saveToken });
}
},
@ -113,67 +92,34 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
const current = renameState(host);
if (current.kind !== "editing" || current.threadId !== threadId) return;
const editingState = current;
await host.ensureConnected();
if (renameState(host) !== editingState) return;
dispatch(host, {
type: "ui/rename-generation-started",
threadId,
generationToken: nextRenameGenerationToken,
});
const generationToken = nextRenameGenerationToken;
const generating = renameState(host);
if (!renameGenerationStillActive(generating, threadId, generationToken)) return;
if (!renameGenerationStillActive(renameState(host), threadId, generationToken)) return;
nextRenameGenerationToken += 1;
const controller = new AbortController();
activeGeneration = { threadId, generationToken, controller };
try {
const title = await host.generateThreadTitle(generating.autoName.context, controller.signal);
if (!title) throw new Error("Codex did not return a usable thread title.");
const title = await host.generateThreadTitle(threadId);
dispatch(host, { type: "ui/rename-generation-succeeded", threadId, generationToken, draft: title });
} catch (error) {
if (renameGenerationStillActive(renameState(host), threadId, generationToken)) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
} finally {
clearGeneration(generationToken);
finishAutoNameDraftGeneration(host, threadId, generationToken);
}
},
};
return action;
function abortGeneration(threadId: string): void {
if (activeGeneration?.threadId !== threadId) return;
abortActiveGeneration();
}
function abortActiveGeneration(): void {
if (!activeGeneration) return;
activeGeneration.controller.abort();
activeGeneration = null;
}
function clearGeneration(generationToken: number): void {
if (activeGeneration?.generationToken === generationToken) activeGeneration = null;
}
async function prepareAutoName(threadId: string): Promise<void> {
const preparation = { threadId };
activeContextPreparation = preparation;
let context: ThreadTitleContext | null = null;
try {
await host.ensureConnected();
if (activeContextPreparation !== preparation) return;
context = await host.resolveThreadTitleContext(threadId);
} catch {
// Auto-name availability is reflected by the disabled action.
}
if (activeContextPreparation !== preparation) return;
activeContextPreparation = null;
const current = renameState(host);
if ((current.kind !== "editing" && current.kind !== "saving") || current.threadId !== threadId) return;
dispatch(host, { type: "ui/rename-auto-name-context-resolved", threadId, context });
}
}
export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null {

View file

@ -2,8 +2,9 @@ import { inheritedForkThreadName, type Thread } from "../../../../domain/threads
import { activeThreadRuntimeState } from "../../domain/runtime/state";
import { effectCompleted, effectCompletedInCurrentContext } from "../effect-outcome";
import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy";
import { resumedThreadActionFromActiveRuntime } from "../state/actions";
import { capturePanelTargetLease, type PanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target";
import { activeThreadId, type ChatState } from "../state/root-reducer";
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream";
import { chatTurnBusy } from "../turns/turn-state";
@ -22,23 +23,12 @@ export interface ThreadManagementActionsHost {
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<void>;
openThreadInCurrentPanel: (threadId: string, onAdopted: () => void) => Promise<CurrentPanelAdoption>;
beginThreadForkPublication: (sourceThreadId: string) => ThreadForkPublication;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyActiveThreadIdentityChanged: () => void;
recordThread: (thread: Thread) => void;
threadHasPendingOrRunningPanel: (threadId: string) => boolean;
}
type CurrentPanelAdoption =
| { readonly adopted: false }
| {
readonly adopted: true;
readonly activityPublication: { publish(commit: () => void): void };
};
interface ThreadForkPublication {
record(thread: Thread): void;
finish(options?: { sourceArchived?: boolean }): void;
}
interface ThreadManagementOperations {
renameThread(threadId: string, value: string): Promise<boolean>;
archiveThread(threadId: string, options?: { saveMarkdown?: boolean }): Promise<boolean>;
@ -143,22 +133,16 @@ async function forkThreadFromTurn(
host.addSystemMessage("Could not find the selected turn to fork.");
return;
}
let publication: ThreadForkPublication | null = null;
let publicationFinished = false;
let activityPublication: { publish(commit: () => void): void } | null = null;
try {
const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads);
if (!(await host.threadTransport.ensureConnected())) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
publication = host.beginThreadForkPublication(threadId);
const effect = turnId
? await host.threadTransport.forkThread(threadId, { position: { kind: "through-turn", turnId } })
: await host.threadTransport.forkThread(threadId);
const effect = turnId ? await host.threadTransport.forkThread(threadId, turnId) : await host.threadTransport.forkThread(threadId);
if (!effectCompleted(effect)) return;
const forkedThread = effect.value;
const forkedThreadId = forkedThread.id;
publication.record(forkedThread);
host.recordThread(forkedThread);
if (!effectCompletedInCurrentContext(effect)) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
if (sourceName) {
@ -172,32 +156,17 @@ async function forkThreadFromTurn(
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
}
if (archiveSource) {
let adoption: CurrentPanelAdoption;
if (!(await archiveThreadFromPanel(host, threadId))) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
try {
adoption = await host.openThreadInCurrentPanel(forkedThreadId, () => undefined);
await host.openThreadInCurrentPanel(forkedThreadId);
} catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in the current panel: ${message}`);
return;
host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`);
}
if (!adoption.adopted) {
if (threadManagementScopeStillTargetsOriginalPanel(host, scope)) {
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in the current panel.`);
}
return;
}
activityPublication = adoption.activityPublication;
const sourceArchived = await archiveReplacedSource(host, threadId, forkedThreadId, {
failureMessage: "Forked the thread, but could not archive the previous version",
});
activityPublication.publish(() => publication?.finish({ sourceArchived }));
activityPublication = null;
publicationFinished = true;
return;
}
publication.finish();
publicationFinished = true;
try {
await host.openThreadInNewView(forkedThreadId);
} catch (error) {
@ -208,11 +177,6 @@ async function forkThreadFromTurn(
} catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
} finally {
if (!publicationFinished) {
if (activityPublication) activityPublication.publish(() => publication?.finish());
else publication?.finish();
}
}
}
@ -240,99 +204,43 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
host.addSystemMessage("No completed turn to roll back.");
return;
}
const runtime = activeThreadRuntimeState(threadManagementState(host).runtime);
const runtimeOverrides = {
...(runtime.model ? { model: runtime.model } : {}),
reasoningEffort: runtime.reasoningEffort,
...(runtime.serviceTierKnown ? { serviceTier: runtime.serviceTier } : {}),
...(runtime.approvalPolicyKnown && runtime.approvalPolicy ? { approvalPolicy: runtime.approvalPolicy } : {}),
...(runtime.approvalsReviewer ? { approvalsReviewer: runtime.approvalsReviewer } : {}),
...(runtime.permissionProfileKnown && runtime.activePermissionProfile
? { permissions: runtime.activePermissionProfile.id }
: runtime.sandboxPolicyKnown && runtime.sandboxPolicy
? { sandboxPolicy: runtime.sandboxPolicy }
: {}),
};
let publication: ThreadForkPublication | null = null;
let publicationFinished = false;
let activityPublication: { publish(commit: () => void): void } | null = null;
try {
host.setStatus(STATUS_ROLLBACK_STARTING);
if (!(await host.threadTransport.ensureConnected())) return;
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
publication = host.beginThreadForkPublication(threadId);
const effect = await host.threadTransport.forkThread(threadId, {
position: { kind: "before-turn", turnId: candidate.turnId },
deferGoalContinuation: true,
runtime: runtimeOverrides,
});
const effect = await host.threadTransport.rollbackThread(threadId);
if (!effectCompleted(effect)) return;
const forkedThread = effect.value;
if (effectCompletedInCurrentContext(effect)) publication.record(forkedThread);
else return;
if (!effectCompletedInCurrentContext(effect)) return;
const snapshot = effect.value;
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
const adoption = await host.openThreadInCurrentPanel(forkedThread.id, () => {
host.setComposerText(candidate.text);
threadManagementDispatch(
host,
resumedThreadActionFromActiveRuntime({
thread: snapshot.thread,
runtime: activeThreadRuntimeState(threadManagementState(host).runtime),
listedThreads: threadManagementState(host).threadList.listedThreads,
expectedPanelTargetRevision: scope.panelTarget.revision,
}),
);
threadManagementDispatch(host, {
type: "thread-stream/items-replaced",
items: snapshot.items,
historyCursor: null,
loadingHistory: false,
});
if (!adoption.adopted) {
if (threadManagementScopeStillTargetsPanel(host, scope)) {
host.addSystemMessage("The rolled-back version was created but could not be opened in this panel. Open it from thread history.");
host.setStatus(STATUS_ROLLBACK_FAILED);
}
return;
}
activityPublication = adoption.activityPublication;
if (activeThreadId(threadManagementState(host)) === forkedThread.id) {
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
host.setStatus(STATUS_ROLLBACK_COMPLETE);
}
const sourceArchived = await archiveReplacedSource(host, threadId, forkedThread.id, {
saveMarkdown: false,
failureMessage: "Rolled back the latest turn, but could not archive the previous version",
});
activityPublication.publish(() => publication?.finish({ sourceArchived }));
activityPublication = null;
publicationFinished = true;
host.setComposerText(candidate.text);
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
host.setStatus(STATUS_ROLLBACK_COMPLETE);
host.notifyActiveThreadIdentityChanged();
host.recordThread(snapshot.thread);
} catch (error) {
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
host.setStatus(STATUS_ROLLBACK_FAILED);
} finally {
if (!publicationFinished) {
if (activityPublication) activityPublication.publish(() => publication?.finish());
else publication?.finish();
}
}
}
async function archiveReplacedSource(
host: ThreadManagementActionsHost,
sourceThreadId: string,
replacementThreadId: string,
options: { readonly saveMarkdown?: boolean; readonly failureMessage: string },
): Promise<boolean> {
try {
const archiveOptions = options.saveMarkdown === undefined ? {} : { saveMarkdown: options.saveMarkdown };
if (await host.operations.archiveThread(sourceThreadId, archiveOptions)) return true;
reportReplacementArchiveFailure(host, replacementThreadId, options.failureMessage, "archive was not completed");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
reportReplacementArchiveFailure(host, replacementThreadId, options.failureMessage, message);
}
return false;
}
function reportReplacementArchiveFailure(
host: ThreadManagementActionsHost,
replacementThreadId: string,
failureMessage: string,
detail: string,
): void {
if (activeThreadId(threadManagementState(host)) !== replacementThreadId) return;
host.addSystemMessage(`${failureMessage}: ${detail}`);
}
function activePanelOperationBlocked(host: ThreadManagementActionsHost, threadId: string, operation: ActivePanelOperation): boolean {
const state = threadManagementState(host);
if (activeThreadId(state) !== threadId) return false;
@ -346,6 +254,10 @@ function threadManagementState(host: ThreadManagementActionsHost): ChatState {
return host.stateStore.getState();
}
function threadManagementDispatch(host: ThreadManagementActionsHost, action: ChatAction): void {
host.stateStore.dispatch(action);
}
function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, targetThreadId: string): ThreadManagementPanelScope {
return {
targetThreadId,

View file

@ -1,31 +1,15 @@
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import type { RuntimeApprovalPolicy, RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions";
import type { ApprovalsReviewer, ServiceTier } from "../../../../domain/runtime/policy";
import type { Thread } from "../../../../domain/threads/model";
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
import type { EffectOutcome } from "../effect-outcome";
type ThreadForkPosition =
| { readonly kind: "through-turn"; readonly turnId: string }
| { readonly kind: "before-turn"; readonly turnId: string };
interface ThreadForkOptions {
readonly position?: ThreadForkPosition;
readonly deferGoalContinuation?: boolean;
readonly runtime?: ThreadForkRuntimeOverrides;
}
interface ThreadForkRuntimeOverrides {
readonly model?: string;
readonly reasoningEffort?: ReasoningEffort | null;
readonly serviceTier?: ServiceTier | null;
readonly approvalPolicy?: RuntimeApprovalPolicy;
readonly approvalsReviewer?: ApprovalsReviewer;
readonly permissions?: string;
readonly sandboxPolicy?: RuntimeSandboxPolicy;
export interface ThreadRollbackSnapshot {
thread: Thread;
items: ThreadStreamItem[];
}
export interface ThreadMutationTransport {
ensureConnected(): Promise<boolean>;
compactThread(threadId: string): Promise<EffectOutcome<void>>;
forkThread(threadId: string, options?: ThreadForkOptions): Promise<EffectOutcome<Thread>>;
forkThread(threadId: string, lastTurnId?: string | null): Promise<EffectOutcome<Thread>>;
rollbackThread(threadId: string): Promise<EffectOutcome<ThreadRollbackSnapshot>>;
}

View file

@ -77,14 +77,14 @@ export async function submitComposer(host: ComposerSubmitActionsHost): Promise<v
const state = submissionStateSnapshot(chatState);
if (host.stateStore.getState().pendingSubmission) return;
const operationDecision = activePanelOperationDecision(chatState, "submit");
if (state.busy && state.activeThreadId && state.activeTurnId && (draft.length === 0 || operationDecision.kind === "blocked")) {
await interruptTurn(host, panelTarget);
return;
}
if (operationDecision.kind === "blocked") {
host.status.addSystemMessage(operationDecision.message);
return;
}
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
await interruptTurn(host, panelTarget);
return;
}
await sendMessage(host, draft, originalDraft, panelTarget);
}
@ -133,6 +133,7 @@ async function sendMessage(
text: result.sendText,
inputSnapshot,
...(result.sendInput !== undefined ? { codexInputOverride: result.sendInput } : {}),
...(result.referencedThread !== undefined ? { referencedThread: result.referencedThread } : {}),
...(result.sendInput !== undefined ? { preserveComposerContextOnFailure: true } : {}),
...(pendingWeb ? { pendingSubmissionId: pendingWeb.id, failureDraft: originalDraft } : {}),
});

View file

@ -15,6 +15,7 @@ interface LocalUserDialogueParams {
text: string;
copyText?: string;
turnId?: string;
referencedThread?: ThreadStreamDialogueItem["referencedThread"];
referencedFiles?: readonly ThreadStreamFileReference[];
contextAttachments?: ThreadStreamDialogueItem["contextAttachments"];
}
@ -63,6 +64,7 @@ function localUserDialogueItem(params: LocalUserDialogueParams): ThreadStreamDia
provenance: localUserDialogueProvenance(params.clientId ?? params.id, params.interaction),
...(params.clientId ? { clientId: params.clientId } : {}),
...(params.turnId ? { turnId: params.turnId } : {}),
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
...(referencedFiles.length > 0 ? { referencedFiles: [...referencedFiles] } : {}),
...(contextAttachments.length > 0 ? { contextAttachments: [...contextAttachments] } : {}),
};
@ -85,6 +87,7 @@ export function localUserDialogueItemFromInput(params: LocalUserDialogueFromInpu
text: userMessageDisplayText(params.text, params.codexInput),
copyText: params.text,
...(params.turnId ? { turnId: params.turnId } : {}),
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
referencedFiles: fileReferencesFromInput([...params.codexInput]),
contextAttachments: contextAttachmentsFromInput(params.codexInput),
});

View file

@ -4,6 +4,7 @@ import type { CodexInput } from "../../../../domain/chat/input";
import type { ThreadGoal } from "../../../../domain/threads/goal";
import { shortThreadId } from "../../../../domain/threads/id";
import type { Thread } from "../../../../domain/threads/model";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import { resolveThreadSearchQuery } from "../../../../domain/threads/search";
import { threadDisplayTitle } from "../../../../domain/threads/title";
import { modelOverrideMessage, permissionProfileOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
@ -78,12 +79,14 @@ export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts
export interface SlashCommandExecutionResult {
sendText?: string;
sendInput?: CodexInput;
referencedThread?: ReferencedThreadMetadata;
composerDraft?: string;
}
export interface ThreadReferenceInput {
text: string;
input: CodexInput;
referencedThread: ReferencedThreadMetadata;
}
export interface WebUrlInput {
@ -147,7 +150,7 @@ export async function executeSlashCommand(
}
const reference = await context.referThread(thread.thread, parsed.message, context.inputSnapshot);
if (!reference) return;
return { sendText: reference.text, sendInput: reference.input };
return { sendText: reference.text, sendInput: reference.input, referencedThread: reference.referencedThread };
}
case "web": {
const parsed = parseWebCommandArgs(args);

View file

@ -1,4 +1,5 @@
import { type CodexInput, codexTextInput } from "../../../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import type { ComposerInputSnapshot } from "../composer/input-snapshot";
import type { LocalIdSource } from "../local-id-source";
import { activePanelOperationDecision } from "../panel-operation-policy";
@ -58,6 +59,7 @@ export interface TurnSubmissionRequest {
text: string;
inputSnapshot?: ComposerInputSnapshot;
codexInputOverride?: CodexInput;
referencedThread?: ReferencedThreadMetadata;
preserveComposerContextOnFailure?: boolean;
pendingSubmissionId?: string;
failureDraft?: string;
@ -83,7 +85,7 @@ async function sendTurnText(
localItemIds: LocalIdSource,
request: TurnSubmissionRequest,
): Promise<boolean> {
const { text, inputSnapshot, codexInputOverride } = request;
const { text, inputSnapshot, codexInputOverride, referencedThread } = request;
let panelTarget = capturePanelTargetLease(host.stateStore.getState());
const prepared = codexInputOverride
? { text, input: codexInputOverride }
@ -113,7 +115,7 @@ async function sendTurnText(
if (pendingRequestIsCurrent(host, request)) host.addSystemMessage(plan.message);
return false;
case "steer":
return await steerCurrentTurn(host, localItemIds, plan, text, prepared, request);
return await steerCurrentTurn(host, localItemIds, plan, text, prepared, request, referencedThread);
case "start-thread-then-turn":
if (!commitPendingRequest(host, request)) return false;
{
@ -161,6 +163,7 @@ async function sendTurnText(
...(request.pendingSubmissionId ? { clientId: clientUserMessageId } : {}),
text: prepared.text,
codexInput: prepared.input,
referencedThread,
});
host.stateStore.dispatch({
type: "turn/optimistic-started",
@ -264,6 +267,7 @@ async function steerCurrentTurn(
text: string,
prepared: { text: string; input: CodexInput },
request: TurnSubmissionRequest,
referencedThread?: ReferencedThreadMetadata,
): Promise<boolean> {
if (!pendingRequestIsCurrent(host, request)) return false;
if (!commitPendingRequest(host, request)) return false;
@ -293,6 +297,7 @@ async function steerCurrentTurn(
...(request.pendingSubmissionId ? { clientId: localSteerId, interaction: "steer" as const } : {}),
text: prepared.text,
turnId: plan.turnId,
referencedThread,
codexInput: prepared.input,
});
host.stateStore.dispatch(

View file

@ -0,0 +1,4 @@
import type { ExecutionState } from "./items";
export type ThreadStreamExecutionState = Exclude<ExecutionState, null>;
export const RUNNING_EXECUTION_STATE: ThreadStreamExecutionState = "running";

View file

@ -1,4 +1,5 @@
import { RUNNING_EXECUTION_STATE, type ThreadStreamItem, type ThreadStreamItemKind } from "../items";
import { RUNNING_EXECUTION_STATE } from "../execution-state";
import type { ThreadStreamItem, ThreadStreamItemKind } from "../items";
export const STREAMED_COMMAND_RUNNING_TEXT = "Command running";
export const STREAMED_MCP_PROGRESS_LABEL = "mcp progress";

View file

@ -1,3 +1,4 @@
import type { TurnContextManifest } from "../../../../../domain/chat/context-manifest";
import type { CodexInputItem } from "../../../../../domain/chat/input";
import type { ThreadStreamContextAttachment } from "../items";
@ -5,23 +6,20 @@ export const WEB_CONTEXT_KEY = "codex_panel_web_context";
export function contextAttachmentsFromInput(input: readonly CodexInputItem[]): ThreadStreamContextAttachment[] {
return input.flatMap((item) => {
if (item.type !== "additionalContext" || item.key !== WEB_CONTEXT_KEY) return [];
if (item.type !== "additionalContext" || (item.attachment?.kind !== "web" && item.key !== WEB_CONTEXT_KEY)) return [];
const source = webContextSource(item.value);
return [{ label: "Web page", ...(source ? { detail: source } : {}) }];
});
}
export function contextAttachmentsFromHistoryContexts(
contexts: readonly { kind: "web" | "obsidian"; truncated: boolean; inlineExcerpts?: number }[],
visibleText: string,
): ThreadStreamContextAttachment[] {
export function contextAttachmentsFromManifest(manifest: TurnContextManifest | null, visibleText: string): ThreadStreamContextAttachment[] {
const attachments: ThreadStreamContextAttachment[] = [];
const web = contexts.find((context) => context.kind === "web");
const web = manifest?.contexts.find((context) => context.kind === "web");
if (web) {
const source = visibleWebSource(visibleText);
attachments.push({ label: web.truncated ? "Web page (truncated)" : "Web page", ...(source ? { detail: source } : {}) });
}
const obsidian = contexts.find((context) => context.kind === "obsidian" && context.truncated);
const obsidian = manifest?.contexts.find((context) => context.kind === "obsidian" && context.truncated);
if (obsidian) {
attachments.push({ label: obsidian.inlineExcerpts ? "Obsidian excerpt (truncated)" : "Obsidian context (truncated)" });
}

View file

@ -1,5 +1,4 @@
import { markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../../domain/markdown/code-ranges";
type TextRange = [number, number];
interface UserMessageDisplayInputItem {
type: string;
name?: string;
@ -13,7 +12,7 @@ export function userMessageDisplayText(text: string, input: readonly UserMessage
const codeRanges = markdownCodeRanges(text);
return text.replace(pattern, (match: string, prefix: string, name: string, offset: number) => {
const dollarIndex = offset + prefix.length;
return markdownCodeRangeContainsOffset(codeRanges, dollarIndex) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`;
return isIndexInRanges(dollarIndex, codeRanges) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`;
});
}
@ -37,6 +36,64 @@ function markdownCodeSpan(text: string): string {
return `${delimiter} ${text} ${delimiter}`;
}
function markdownCodeRanges(text: string): TextRange[] {
return [...markdownFenceRanges(text), ...markdownInlineCodeRanges(text)].sort((a, b) => a[0] - b[0]);
}
function markdownFenceRanges(text: string): TextRange[] {
const ranges: TextRange[] = [];
let active: { marker: string; start: number } | null = null;
let offset = 0;
for (const line of text.matchAll(/[^\n]*(?:\n|$)/g)) {
const value = line[0];
if (value.length === 0) break;
const fence = /^(?: {0,3})(`{3,}|~{3,})/.exec(value);
if (fence) {
const marker = fence[1];
if (!marker) continue;
if (!active) {
active = { marker, start: offset };
} else if (marker.startsWith(active.marker.charAt(0)) && marker.length >= active.marker.length) {
ranges.push([active.start, offset + value.length]);
active = null;
}
}
offset += value.length;
}
if (active) ranges.push([active.start, text.length]);
return ranges;
}
function markdownInlineCodeRanges(text: string): TextRange[] {
const ranges: TextRange[] = [];
const fenceRanges = markdownFenceRanges(text);
let index = 0;
while (index < text.length) {
if (isIndexInRanges(index, fenceRanges) || text[index] !== "`") {
index += 1;
continue;
}
const match = /`+/.exec(text.slice(index));
if (!match) {
index += 1;
continue;
}
const delimiter = match[0];
const end = text.indexOf(delimiter, index + delimiter.length);
if (end < 0) {
index += delimiter.length;
continue;
}
ranges.push([index, end + delimiter.length]);
index = end + delimiter.length;
}
return ranges;
}
function isIndexInRanges(index: number, ranges: readonly TextRange[]): boolean {
return ranges.some(([start, end]) => index >= start && index < end);
}
function escapeRegExp(value: string): string {
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
}

View file

@ -19,8 +19,6 @@ export type ThreadStreamItemKind =
| "reviewResult";
type ThreadStreamRole = "user" | "assistant" | "system" | "tool";
export type ExecutionState = "running" | "completed" | "failed" | null;
export type ThreadStreamExecutionState = Exclude<ExecutionState, null>;
export const RUNNING_EXECUTION_STATE: ThreadStreamExecutionState = "running";
type DialogueState = "streaming" | "completed";
interface ThreadStreamBase {

View file

@ -31,14 +31,14 @@ export function createChatComposerController(
contextReferenceProvider: new VaultComposerContextReferenceProvider(environment.obsidian.app),
attachmentHandler: createVaultComposerAttachmentHandler({
app: environment.obsidian.app,
attachmentFolder: () => environment.plugin.settings.attachmentFolder(),
attachmentFolder: () => environment.plugin.settingsRef.settings.attachmentFolder(),
}),
sourcePath: () => environment.obsidian.app.workspace.getActiveFile()?.path ?? "",
stateStore,
viewId: environment.obsidian.viewId,
referenceActiveNoteOnSend: () => environment.plugin.settings.referenceActiveNoteOnSend(),
sendShortcut: () => environment.plugin.settings.sendShortcut(),
scrollThreadFromComposerEdges: () => environment.plugin.settings.scrollThreadFromComposerEdges(),
referenceActiveNoteOnSend: () => environment.plugin.settingsRef.settings.referenceActiveNoteOnSend(),
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(),
scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges(),
canInterrupt: (model) => {
return model.turnBusy && Boolean(model.activeThreadId && model.activeTurnId);
},

View file

@ -2,8 +2,8 @@ import { Notice } from "obsidian";
import type { AppServerClient, AppServerServerRequestResponder } from "../../../../app-server/connection/client";
import { type ConnectionManager, StaleConnectionError } from "../../../../app-server/connection/connection-manager";
import { isStaleAppServerResourceContextError } from "../../../../app-server/query/resource-store";
import type { SharedServerMetadataResource } from "../../../../domain/server/metadata";
import { isStaleExecutionRuntimeError } from "../../../../shared/runtime/execution-runtime-lifetime";
import { type ChatInboundHandler, createChatInboundHandler } from "../../app-server/inbound/handler";
import { type ChatConnectionActions, createChatConnectionActions } from "../../application/connection/connection-actions";
import type { ServerDiagnosticsTransport } from "../../application/connection/metadata-transport";
@ -127,7 +127,7 @@ export function createConnectionBundle(
refreshAppServerMetadata: () => environment.plugin.appServerQueries.refreshAppServerMetadata(),
refreshSkills: () => environment.plugin.appServerQueries.refreshSkills(),
refreshRateLimits: () => environment.plugin.appServerQueries.refreshRateLimits(),
isStaleRuntimeError: isStaleExecutionRuntimeError,
isStaleResourceContextError: isStaleAppServerResourceContextError,
});
const serverDiagnostics = createServerDiagnosticsActions({
stateStore,
@ -135,7 +135,7 @@ export function createConnectionBundle(
appServerMetadataSnapshot: () => environment.plugin.appServerQueries.appServerMetadataSnapshot(),
});
const refreshSharedThreads = async (): Promise<void> => {
await environment.plugin.threadCatalog.refreshActiveThreads();
await environment.plugin.threadCatalog.refreshActive();
};
const inboundHandler = createChatInboundHandler(
stateStore,
@ -153,8 +153,8 @@ export function createConnectionBundle(
maybeNameThread: (threadId, turnId, completedTurnTranscriptSummary) => {
autoTitleCoordinator.maybeAutoTitleThread(threadId, turnId, completedTurnTranscriptSummary);
},
applyThreadOperationEvent: (event) => {
environment.plugin.threadOperationCoordinator.apply(event);
applyThreadCatalogEvent: (event) => {
environment.plugin.threadCatalog.apply(event);
},
respondToServerRequest: (requestId, result) => serverRequestResponders.respond(requestId, result),
rejectServerRequest: (requestId, code, message) => serverRequestResponders.reject(requestId, code, message),
@ -222,9 +222,9 @@ export function createConnectionBundle(
},
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
configuredCommand: () => environment.plugin.appServerContext.codexPath,
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath(),
isStaleConnectionError: (error) => error instanceof StaleConnectionError,
isStaleRuntimeError: isStaleExecutionRuntimeError,
isStaleResourceContextError: isStaleAppServerResourceContextError,
notifyConnectionFailed: () => {
new Notice("Codex app-server connection failed.");
},

View file

@ -48,8 +48,8 @@ export function createRuntimeBundle(
projection: createChatPanelRuntimeProjection({
state: () => host.stateStore.getState(),
connected: () => input.connection.isConnected(),
configuredCommand: () => host.environment.plugin.appServerContext.codexPath,
vaultPath: () => host.environment.plugin.appServerContext.vaultPath,
configuredCommand: () => host.environment.plugin.settingsRef.settings.codexPath(),
vaultPath: () => host.environment.plugin.settingsRef.vaultPath,
nowMs: () => Date.now(),
}),
};

View file

@ -68,7 +68,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
toolbarPanel: toolbarPanelActions,
rename,
navigation,
loadMoreThreads: () => environment.plugin.threadCatalog.loadMoreActiveThreads(),
loadMoreThreads: () => environment.plugin.threadCatalog.loadMoreActive(),
openSideChat: () => {
const state = stateStore.getState();
if (activePanelOperationDecision(state, "start-side-chat").kind !== "allowed") return;
@ -89,13 +89,13 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
nowMs: () => Date.now(),
},
settings: {
vaultPath: () => environment.plugin.appServerContext.vaultPath,
configuredCommand: () => environment.plugin.appServerContext.codexPath,
archiveExportEnabled: () => environment.plugin.settings.archiveExportEnabled(),
vaultPath: () => environment.plugin.settingsRef.vaultPath,
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath(),
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(),
},
};
const goalSurface: ChatPanelGoalSurface = {
sendShortcut: () => environment.plugin.settings.sendShortcut(),
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(),
actions: goals,
};
const threadStreamContext = createChatThreadStreamSurfaceContext({
@ -103,13 +103,12 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
app: environment.obsidian.app,
owner: environment.obsidian.owner,
stateStore,
vaultPath: environment.plugin.appServerContext.vaultPath,
vaultPath: environment.plugin.settingsRef.vaultPath,
loadOlderTurns: () => void history.loadOlder(),
actions: {
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
implementPlan: (itemId) => void turn.turnActions.planImplementation.implement(itemId),
openThreadInAvailableView: (threadId) => void environment.plugin.workspace.openThreadInAvailableView(threadId),
openThreadInNewView: (threadId) => void environment.plugin.workspace.openThreadInNewView(threadId),
openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state),
},

View file

@ -39,6 +39,7 @@ export type ChatPanelThreadActions = ReturnType<typeof createThreadManagementAct
export type ChatPanelThreadNavigationActions = ReturnType<typeof createThreadNavigationActions>;
export interface ChatPanelThreadLifecycle {
history: HistoryController;
restoration: RestorationController;
resume: ResumeActions;
identity: ActiveThreadIdentitySync;
@ -57,7 +58,6 @@ interface ChatPanelThreadHost {
showLatest(): void;
};
getClosing: () => boolean;
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
}
interface ChatPanelThreadFoundationInput {
@ -80,16 +80,19 @@ interface ChatPanelThreadLifecycleInput {
appServer: ChatAppServerGateway;
localItemIds: LocalIdSource;
ensureConnected: () => Promise<void>;
ensureInitialized: () => Promise<void>;
status: ChatPanelThreadStatus;
threadStart: ThreadStartActions;
foundation: ChatPanelThreadFoundation;
notifyActiveThreadIdentityChanged: () => void;
}
interface ChatPanelThreadLifecycleBundle extends ChatPanelThreadLifecycle {
interface ChatPanelThreadLifecycleBundle {
goals: ChatPanelGoalActions;
rename: ThreadRenameEditorActions;
lifecycle: ChatPanelThreadLifecycle;
identity: ActiveThreadIdentitySync;
restoration: RestorationController;
resume: ResumeActions;
}
interface ChatPanelThreadActionInput {
@ -122,13 +125,13 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan
transport: threadOperationsTransport,
nameMutations: environment.plugin.threadNameMutations,
archiveExport: {
settings: () => environment.plugin.settings.archiveExportSettings(),
enabled: () => environment.plugin.settings.archiveExportEnabled(),
vaultPath: environment.plugin.appServerContext.vaultPath,
settings: () => environment.plugin.settingsRef.settings.archiveExportSettings(),
enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(),
vaultPath: environment.plugin.settingsRef.vaultPath,
vaultConfigDir: environment.obsidian.app.vault.configDir,
},
archiveDestination: environment.obsidian.archiveDestination,
operationEvents: environment.plugin.threadOperationCoordinator,
catalog: environment.plugin.threadCatalog,
referenceThreads: () => stateStore.getState().threadList.listedThreads,
notice: (text) => {
new Notice(text);
@ -193,19 +196,9 @@ export function createThreadLifecycleBundle(
host: ChatPanelThreadHost,
input: ChatPanelThreadLifecycleInput,
): ChatPanelThreadLifecycleBundle {
const {
appServer,
localItemIds,
ensureConnected,
ensureInitialized,
status,
threadStart,
foundation,
notifyActiveThreadIdentityChanged,
} = input;
const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, notifyActiveThreadIdentityChanged } = input;
const lifecycle = createSessionThreadLifecycle(host, {
appServer,
ensureInitialized,
status,
goalSync: foundation.goalSync,
autoTitleCoordinator: foundation.autoTitleCoordinator,
@ -239,14 +232,14 @@ export function createThreadLifecycleBundle(
ensureConnected,
addSystemMessage: status.addSystemMessage,
renameThread: (threadId, value) => foundation.threadOperations.renameThread(threadId, value),
resolveThreadTitleContext: (threadId) => foundation.titleService.resolveContext(threadId),
generateThreadTitle: (context, signal) => foundation.titleService.generate(context, signal),
generateThreadTitle: (threadId) => foundation.titleService.generateTitle(threadId),
});
const { identity, restoration, resume } = lifecycle;
return {
goals,
rename,
lifecycle,
identity,
restoration,
resume,
@ -254,7 +247,7 @@ export function createThreadLifecycleBundle(
}
export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatPanelThreadActionInput): ChatPanelThreadActionBundle {
const { appServer, status, composerController, foundation, lifecycle } = input;
const { appServer, status, composerController, foundation, lifecycle, notifyActiveThreadIdentityChanged } = input;
const { environment, stateStore } = host;
const threadManagementHost: ThreadManagementActionsHost = {
stateStore,
@ -272,25 +265,13 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP
composerController.setDraft(text, { focus: true });
},
openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId),
openThreadInCurrentPanel: async (threadId, onAdopted) => {
const activityPublication = host.beginPanelActivityPublication(threadId);
const adoption = { completed: false };
try {
await lifecycle.resume.resumeThread(threadId, undefined, {
onAdopted: () => {
adoption.completed = true;
onAdopted();
},
});
if (adoption.completed) return { adopted: true, activityPublication };
activityPublication.publish(() => undefined);
return { adopted: false };
} catch (error) {
activityPublication.publish(() => undefined);
throw error;
}
openThreadInCurrentPanel: async (threadId) => {
await lifecycle.resume.resumeThread(threadId);
},
notifyActiveThreadIdentityChanged,
recordThread: (thread) => {
environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread });
},
beginThreadForkPublication: (sourceThreadId) => environment.plugin.threadOperationCoordinator.beginForkPublication(sourceThreadId),
threadHasPendingOrRunningPanel: (threadId) => environment.plugin.workspace.threadHasPendingOrRunningPanel(threadId),
};
const actions = createThreadManagementActions(threadManagementHost);
@ -322,7 +303,6 @@ function createSessionThreadLifecycle(
host: ChatPanelThreadHost,
input: {
appServer: ChatAppServerGateway;
ensureInitialized: () => Promise<void>;
status: ChatPanelThreadStatus;
goalSync: ChatPanelGoalSyncActions;
autoTitleCoordinator: AutoTitleCoordinator;
@ -331,16 +311,7 @@ function createSessionThreadLifecycle(
notifyActiveThreadIdentityChanged: () => void;
},
): ChatPanelThreadLifecycle {
const {
appServer,
ensureInitialized,
status,
goalSync,
autoTitleCoordinator,
history,
invalidateThreadWork,
notifyActiveThreadIdentityChanged,
} = input;
const { appServer, status, goalSync, autoTitleCoordinator, history, invalidateThreadWork, notifyActiveThreadIdentityChanged } = input;
const restoration = new RestorationController({
stateStore: host.stateStore,
});
@ -349,20 +320,14 @@ function createSessionThreadLifecycle(
};
const resume = createResumeActions({
stateStore: host.stateStore,
resumeTransport: {
ensureConnected: async () => {
await ensureInitialized();
return appServer.connectionAvailable();
},
resumeThread: (threadId) => appServer.threadResume.resumeThread(threadId),
},
resumeTransport: appServer.threadResume,
resumeWork: host.resumeWork,
history,
closing: host.getClosing,
resetThreadTurnPresence,
notifyActiveThreadIdentityChanged,
recordResumedThread: (thread) => {
host.environment.plugin.threadOperationCoordinator.apply({ type: "thread-upserted", thread });
host.environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread });
},
addSystemMessage: status.addSystemMessage,
syncThreadGoal: (threadId) => goalSync.syncThreadGoal(threadId),
@ -377,6 +342,7 @@ function createSessionThreadLifecycle(
});
return {
history,
restoration,
resume,
identity,

View file

@ -1,46 +1,53 @@
import type { App, Component, EventRef } from "obsidian";
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
import type { AppServerExecutionContext } from "../../../app-server/connection/execution-context";
import type { AppServerQueryContext } from "../../../app-server/query/keys";
import type { ObservedResultListener } from "../../../app-server/query/observed-result";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { ModelMetadata, ReasoningEffort } from "../../../domain/catalog/metadata";
import type { SendShortcut } from "../../../domain/input/send-shortcut";
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../../domain/server/metadata";
import type { ArchiveExportSettings } from "../../../domain/threads/archive-markdown";
import type { KeyedOperationQueue } from "../../../shared/runtime/keyed-operation-queue";
import type { ThreadCatalogPaginatedActiveReader } from "../../threads/catalog/thread-catalog";
import type { ArchiveExportDestination, ArchiveExportSettings } from "../../threads/workflows/archive-export";
import type { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../../threads/catalog/thread-catalog";
import type { ArchiveExportDestination } from "../../threads/workflows/archive-export";
import type { ThreadTitleTransport } from "../../threads/workflows/ports";
import type { ThreadOperationCoordinator } from "../../threads/workflows/thread-operation-coordinator";
import type { ThreadNameMutationCoordinator } from "../../threads/workflows/thread-name-mutation-coordinator";
import type { TurnDiffViewState } from "../../turn-diff/model";
import type { ThreadGoalOperationCoordinator } from "../application/threads/goal-actions";
export interface CodexChatHost {
readonly appServerClientAccess: AppServerClientAccess;
readonly appServerContext: Readonly<AppServerExecutionContext>;
readonly settings: ChatPanelSettingsAccess;
readonly appServerContext: Readonly<AppServerQueryContext>;
readonly settingsRef: ChatPanelSettingsRef;
readonly workspace: WorkspacePanels;
readonly appServerQueries: ChatAppServerQueries;
readonly threadCatalog: ChatThreadCatalog;
readonly threadOperationCoordinator: ThreadOperationCoordinator;
readonly threadNameMutations: KeyedOperationQueue<string>;
readonly threadNameMutations: ThreadNameMutationCoordinator;
readonly threadTitleTransport: ThreadTitleTransport;
readonly threadGoalOperations: ThreadGoalOperationCoordinator;
readonly runtimeSettingsCommitQueue: KeyedOperationQueue<string>;
}
interface ChatPanelSettingsRef {
readonly settings: ChatPanelSettingsAccess;
readonly vaultPath: string;
}
export interface ChatPanelSettingsAccess {
referenceActiveNoteOnSend(): boolean;
attachmentFolder(): string;
archiveExportEnabled(): boolean;
archiveExportSettings(): ArchiveExportSettings;
codexPath(): string;
scrollThreadFromComposerEdges(): boolean;
sendShortcut(): SendShortcut;
showToolbar(): boolean;
threadNamingEffort(): ReasoningEffort | null;
threadNamingModel(): string | null;
}
export interface WorkspacePanels {
openThreadInNewView(threadId: string): Promise<void>;
openThreadInAvailableView(threadId: string): Promise<void>;
openThreadFromPanel(threadId: string, originViewId: string, originSwitchable: boolean): Promise<void>;
focusThreadInOpenView(threadId: string): Promise<boolean>;
threadHasPendingOrRunningPanel(threadId: string): boolean;
@ -49,7 +56,7 @@ export interface WorkspacePanels {
openSideChat(sourceThreadId: string, sourceThreadTitle: string | null): Promise<void>;
}
type ChatThreadCatalog = ThreadCatalogPaginatedActiveReader;
type ChatThreadCatalog = ThreadCatalogPaginatedActiveReader & ThreadCatalogEventSink;
interface ChatAppServerQueries {
appServerMetadataSnapshot(): SharedServerMetadata | null;
@ -116,11 +123,6 @@ export interface ChatWorkspacePanelSnapshot {
threadId: string | null;
turnBusy: boolean;
pending: boolean;
publishedActivity: {
threadId: string | null;
turnBusy: boolean;
pending: boolean;
};
hasComposerDraft: boolean;
connected: boolean;
}

View file

@ -1,7 +1,7 @@
import { type App, moment, normalizePath, TFile } from "obsidian";
import { appHasDailyNotesPluginLoaded, getDailyNoteSettings, type IPeriodicNoteSettings } from "obsidian-daily-notes-interface";
import type { DailyNoteReferenceCandidate } from "../../application/composer/note-context";
import type { DailyNoteReferenceCandidate } from "../../application/composer/daily-note-references";
import { displayNameForFile, linktextForFile } from "./vault-note-links.obsidian";
const RELATIVE_DAILY_NOTES = [

View file

@ -2,7 +2,8 @@ import type { App, EventRef } from "obsidian";
import { stripHeadingForLink, TFile } from "obsidian";
import type { VaultFileReference } from "../../../../domain/chat/input";
import type { NoteCandidate, NoteCandidateProvider } from "../../application/composer/note-context";
import type { NoteCandidateProvider } from "../../application/composer/note-context";
import type { NoteCandidate } from "../../application/composer/suggestions";
import { configuredDailyNoteReferences } from "./vault-daily-note-references.obsidian";
import { displayNameForFile, linktextForFile } from "./vault-note-links.obsidian";

View file

@ -49,7 +49,7 @@ async function readUrlToInput(
return {
text,
input: codexTextInputWithAttachments(text, [
{ type: "additionalContext", key: WEB_CONTEXT_KEY, kind: "untrusted", value: context },
{ type: "additionalContext", key: WEB_CONTEXT_KEY, kind: "untrusted", value: context, attachment: { kind: "web" } },
...messageInput.input,
]),
};

View file

@ -1,6 +1,6 @@
import { codexPanelAppServerInitializeParams } from "../../../app-server/connection/client-profile";
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
import { isStaleExecutionRuntimeError } from "../../../shared/runtime/execution-runtime-lifetime";
import { isStaleAppServerResourceContextError } from "../../../app-server/query/resource-store";
import { createChatAppServerGateway, createChatCurrentAppServerGateway } from "../app-server/session-gateway";
import { createReconnectPanelAction } from "../application/connection/reconnect-actions";
import { createLocalIdSource, type LocalIdSource } from "../application/local-id-source";
@ -72,7 +72,7 @@ interface ChatPanelSessionRuntimeHost {
resumeWork: ChatResumeWorkTracker;
threadStreamScrollBinding: ChatThreadStreamScrollBinding;
getClosing: () => boolean;
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
reconnect?: () => Promise<void>;
}
export class ChatPanelSessionRuntime {
@ -178,7 +178,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
threadStartTransport: appServer.threadStart,
runtimeSnapshotForState: runtimeSnapshotForChatState,
recordStartedThread: (thread) => {
environment.plugin.threadOperationCoordinator.apply({ type: "thread-upserted", thread });
environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread });
},
syncThreadGoal: (threadId) => {
void threadFoundation.goalSync.syncThreadGoal(threadId);
@ -188,7 +188,6 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
appServer,
localItemIds,
ensureConnected,
ensureInitialized: () => connectionActions.ensureInitialized(),
status,
threadStart,
foundation: threadFoundation,
@ -246,12 +245,13 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
const reconnect = async () => {
await reconnectPanel();
};
const reconnectForUser = host.reconnect ?? reconnect;
const turn = createTurnBundle(host, {
localItemIds,
appServer,
status,
inboundHandler,
threadLifecycle,
threadLifecycle: threadLifecycle.lifecycle,
threadActions: threadActions.actions,
navigation: threadActions.navigation,
composerController,
@ -259,7 +259,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
threadStart,
goals: threadLifecycle.goals,
autoTitleCoordinator: threadFoundation.autoTitleCoordinator,
reconnect,
reconnect: reconnectForUser,
runtimeProjection: runtime.projection,
refreshDiagnostics: () => connectionActions.refreshDiagnostics(),
notifyActiveThreadIdentityChanged,
@ -272,7 +272,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
threadActions: threadActions.actions,
toolbarPanelActions: threadActions.toolbarPanelActions,
navigation: threadActions.navigation,
reconnect,
reconnect: reconnectForUser,
history: threadFoundation.history,
pendingRequests: turn.pendingRequests,
turn,
@ -282,7 +282,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
try {
await connectionBundle.refreshSharedThreads();
} catch (error) {
if (isStaleExecutionRuntimeError(error)) return;
if (isStaleAppServerResourceContextError(error)) return;
throw error;
}
};

View file

@ -21,8 +21,6 @@ export class ChatPanelSession implements ChatPanelHandle {
private opened = false;
private closing = false;
private observedPanelActivity: PanelActivity | null = null;
private readonly panelActivityPublicationSlots: PanelActivityPublicationSlot[] = [];
private activePanelActivityHold: PanelActivityHold | null = null;
private unsubscribePanelActivity: (() => void) | null = null;
private pendingRuntimeRestore: ChatPanelRuntimeSnapshot | null;
@ -87,6 +85,10 @@ export class ChatPanelSession implements ChatPanelHandle {
this.mountOrRepairShell();
}
private async reconnect(): Promise<void> {
await this.runtime.actions.reconnect();
}
runtimeSnapshot(): ChatPanelRuntimeSnapshot {
const lifetime = activeThreadState(this.state)?.lifetime;
return {
@ -102,15 +104,10 @@ export class ChatPanelSession implements ChatPanelHandle {
openPanelSnapshot(): ChatWorkspacePanelSnapshot {
const activity = panelActivity(this.state);
const publishedActivity = this.activePanelActivityHold?.activity ?? activity;
return {
viewId: this.environment.obsidian.viewId,
...activity,
threadId: this.closing ? null : activity.threadId,
publishedActivity: {
...publishedActivity,
threadId: this.closing ? null : publishedActivity.threadId,
},
hasComposerDraft: this.state.composer.draft.trim().length > 0,
connected: this.runtime.connection.manager.isConnected(),
};
@ -217,7 +214,7 @@ export class ChatPanelSession implements ChatPanelHandle {
if (!root) return;
renderChatPanelShell(root, {
stateStore: this.stateStore,
showToolbar: this.environment.plugin.settings.showToolbar(),
showToolbar: this.environment.plugin.settingsRef.settings.showToolbar(),
parts: this.runtime.shell.parts,
});
}
@ -267,9 +264,6 @@ export class ChatPanelSession implements ChatPanelHandle {
const next = panelActivity(this.state);
if (panelActivityEquals(this.observedPanelActivity, next)) return;
this.observedPanelActivity = next;
const hold = this.activePanelActivityHold;
if (hold && (next.threadId === hold.activity.threadId || hold.replacementThreadIds.has(next.threadId))) return;
if (hold) this.activePanelActivityHold = null;
this.notifyPanelActivityChanged();
});
this.notifyPanelActivityChanged();
@ -279,47 +273,6 @@ export class ChatPanelSession implements ChatPanelHandle {
this.environment.plugin.workspace.notifyPanelActivityChanged();
}
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void } {
const hold: PanelActivityHold = this.activePanelActivityHold ?? {
activity: panelActivity(this.state),
replacementThreadIds: new Set<string | null>(),
pendingPublications: 0,
};
hold.replacementThreadIds.add(replacementThreadId);
hold.pendingPublications += 1;
this.activePanelActivityHold = hold;
const slot: PanelActivityPublicationSlot = { hold, commit: null };
this.panelActivityPublicationSlots.push(slot);
return {
publish: (commit) => {
if (slot.commit) return;
slot.commit = commit;
hold.pendingPublications -= 1;
let failure: unknown = null;
let commitFailed = false;
let first = this.panelActivityPublicationSlots[0];
while (first?.commit && first.hold.pendingPublications === 0) {
this.panelActivityPublicationSlots.shift();
const slotHold = first.hold;
const publishActivity =
this.activePanelActivityHold === slotHold && !this.panelActivityPublicationSlots.some((pending) => pending.hold === slotHold);
if (publishActivity) this.activePanelActivityHold = null;
try {
first.commit();
} catch (error) {
if (!commitFailed) failure = error;
commitFailed = true;
}
if (publishActivity && !panelActivityEquals(slotHold.activity, panelActivity(this.state))) {
this.notifyPanelActivityChanged();
}
first = this.panelActivityPublicationSlots[0];
}
if (commitFailed) throw failure;
},
};
}
private activeThreadTitle(): string | null {
const activeThread = activeThreadState(this.state);
if (!activeThread) return null;
@ -357,7 +310,7 @@ export class ChatPanelSession implements ChatPanelHandle {
resumeWork: this.resumeWork,
threadStreamScrollBinding: this.threadStreamScrollBinding,
getClosing: () => this.closing,
beginPanelActivityPublication: (replacementThreadId) => this.beginPanelActivityPublication(replacementThreadId),
reconnect: () => this.reconnect(),
});
}
}
@ -368,17 +321,6 @@ interface PanelActivity {
readonly pending: boolean;
}
interface PanelActivityHold {
readonly activity: PanelActivity;
readonly replacementThreadIds: Set<string | null>;
pendingPublications: number;
}
interface PanelActivityPublicationSlot {
readonly hold: PanelActivityHold;
commit: (() => void) | null;
}
function panelActivity(state: ChatState): PanelActivity {
return {
threadId: panelThreadId(state),

View file

@ -6,9 +6,9 @@ import type { ChatStateStore } from "../../application/state/store";
type ThreadObserver = (result: ObservedPaginatedResult<readonly Thread[]>) => void;
interface SharedStateThreadCatalog {
activeThreadsSnapshot(): readonly Thread[] | null;
hasMoreActiveThreads(): boolean;
observeActiveThreadsResult(observer: ThreadObserver, options?: { emitCurrent?: boolean }): () => void;
activeSnapshot(): readonly Thread[] | null;
hasMoreActive(): boolean;
observeActive(observer: ThreadObserver, options?: { emitCurrent?: boolean }): () => void;
}
interface SharedStateAppServerQueries {
@ -56,12 +56,12 @@ export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateB
}
};
const applyCached = (): void => {
const threads = threadCatalog.activeThreadsSnapshot();
const threads = threadCatalog.activeSnapshot();
if (threads) {
stateStore.dispatch({
type: "thread-list/applied",
threads,
hasMore: threadCatalog.hasMoreActiveThreads(),
hasMore: threadCatalog.hasMoreActive(),
isFetching: false,
isFetchingNextPage: false,
error: null,
@ -75,7 +75,7 @@ export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateB
unsubscribe();
applyCached();
unsubscribers.push(
threadCatalog.observeActiveThreadsResult(receiveThreadResult, { emitCurrent: false }),
threadCatalog.observeActive(receiveThreadResult, { emitCurrent: false }),
appServerQueries.observeAppServerMetadataResources(applyAppServerMetadataResource),
);
},

View file

@ -1,7 +1,7 @@
import { Component, ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL } from "../../../constants";
import { createObsidianVaultMarkdownDestination } from "../../../shared/obsidian/vault-write-destination.obsidian";
import { createObsidianArchiveExportDestination } from "../../threads/obsidian/archive-export-destination.obsidian";
import { createLocalIdSource } from "../application/local-id-source";
import type { ChatPanelHandle, ChatPanelRuntimeSnapshot, ChatViewRuntimeOwner, CodexChatHost } from "./contracts";
import { ChatPanelSession } from "./session";
@ -46,7 +46,7 @@ export class CodexChatView extends ItemView {
registerPointerDown: (handler) => {
owner.registerDomEvent(this.containerEl.doc, "pointerdown", handler);
},
archiveDestination: () => createObsidianVaultMarkdownDestination(this.app.vault),
archiveDestination: () => createObsidianArchiveExportDestination(this.app.vault),
requestWorkspaceLayoutSave: () => {
void this.app.workspace.requestSaveLayout();
},

View file

@ -14,13 +14,14 @@ import {
selectionContextReferenceMarker,
} from "../application/composer/context-references";
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
import type { NoteCandidate, NoteCandidateProvider } from "../application/composer/note-context";
import type { NoteCandidateProvider } from "../application/composer/note-context";
import { activePanelOperationForSlashCommandSuggestion } from "../application/composer/slash-commands";
import {
activeComposerSuggestions,
applyComposerSuggestionInsertion,
type ComposerSuggestion,
composerSuggestionNavigationDirection,
type NoteCandidate,
nextComposerSuggestionIndex,
} from "../application/composer/suggestions";
import { type ThreadCommandTarget, threadCommandTargetForDraft } from "../application/composer/thread-title-argument";
@ -111,9 +112,7 @@ export class ChatComposerController {
draft: model.draft,
busy: model.turnBusy,
canInterrupt: this.options.canInterrupt(model),
submissionDisabled: model.webSubmissionPending,
directInputDisabled: model.submissionBlockedByPanelPolicy,
runtimeControlsDisabled: model.runtimeSettingsDisabled,
submissionDisabled: model.submissionBlockedByPanelPolicy || model.webSubmissionPending,
sendDisabled: model.attachmentSavePending,
webSubmissionCancellable: model.webSubmissionCancellable,
normalPlaceholder: projection.placeholder,

View file

@ -70,9 +70,7 @@ export interface ChatPanelComposerModel {
readonly activeThreadId: string | null;
readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"];
readonly activeThreadSubagent: boolean;
readonly canAcceptDirectInput: ChatActiveThreadState["canAcceptDirectInput"];
readonly submissionBlockedByPanelPolicy: boolean;
readonly runtimeSettingsDisabled: boolean;
readonly webSubmissionPending: boolean;
readonly webSubmissionCancellable: boolean;
readonly turnBusy: boolean;
@ -93,7 +91,7 @@ export function selectChatPanelToolbar(state: ChatState): ChatPanelToolbarModel
activeThreadId: activeThread?.id ?? null,
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
sideChatStartDisabled: activePanelOperationDecision(state, "start-side-chat").kind !== "allowed",
compactDisabled: !activeThread || activePanelOperationDecision(state, "compact").kind !== "allowed",
compactDisabled: activePanelOperationDecision(state, "compact").kind !== "allowed",
goalMutationDisabled: activePanelOperationDecision(state, "goal-mutation").kind === "blocked",
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
turnBusy: chatTurnBusy(state),
@ -156,9 +154,7 @@ export function selectChatPanelComposer(state: ChatState): ChatPanelComposerMode
activeThreadId,
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
canAcceptDirectInput: activeThread?.canAcceptDirectInput ?? null,
submissionBlockedByPanelPolicy: activePanelOperationDecision(state, "submit").kind === "blocked",
runtimeSettingsDisabled: activePanelOperationDecision(state, "thread-settings").kind !== "allowed",
webSubmissionPending: state.pendingSubmission !== null,
webSubmissionCancellable: state.pendingSubmission?.phase === "cancellable",
turnBusy: chatTurnBusy(state),

View file

@ -130,7 +130,10 @@ function ChatPanelToolbarRegion({
actions: ToolbarActions;
}): UiNode {
const model = useChatSelector(stateStore, selectChatPanelToolbar);
return <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />;
return useMemo(
() => <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />,
[model, stateStore, surface, actions],
);
}
function ChatPanelGoalRegion({ stateStore, surface }: { stateStore: ChatStateStore; surface: ChatPanelGoalSurface }): UiNode {

View file

@ -71,12 +71,9 @@ export function chatPanelComposerProjection(
availableModels: model.availableModels,
});
return {
placeholder:
model.canAcceptDirectInput === false
? "This thread cannot accept messages."
: model.activeThreadSubagent && model.canAcceptDirectInput === null
? "Agent thread is read-only."
: composerPlaceholder(activeComposerThreadName(model), model.sideChatActive, model.sideChatSourceTitle),
placeholder: model.activeThreadSubagent
? "Agent thread is read-only."
: composerPlaceholder(activeComposerThreadName(model), model.sideChatActive, model.sideChatSourceTitle),
meta: {
...composerMetaViewModel(model, snapshot),
...runtimeComposerChoices({

View file

@ -31,7 +31,6 @@ export interface ChatThreadStreamActions {
rollbackThread: (threadId: string) => void;
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
implementPlan: (itemId: string) => void;
openThreadInAvailableView: (threadId: string) => void;
openThreadInNewView: (threadId: string) => void;
openTurnDiff: (state: TurnDiffViewState) => void;
}

View file

@ -21,7 +21,6 @@ export function createChatThreadStreamSurfaceContext(options: ChatThreadStreamSu
app: options.app,
owner: options.owner,
vaultPath: options.vaultPath,
openThread: options.actions.openThreadInAvailableView,
});
const dispatch = (action: ChatAction): void => {
options.stateStore.dispatch(action);

View file

@ -206,18 +206,11 @@ function toolbarThreadRows(input: {
title: core.title,
threadId: core.threadId,
selected: core.selected,
renameDisabled: input.renameState.kind === "saving",
archiveDisabled: input.turnBusy,
disabled: input.turnBusy && threadId !== input.activeThreadId,
openDisabled: false,
canArchive: true,
archiveConfirm: core.archiveConfirm,
rename: core.rename.active
? {
draft: core.rename.draft,
generating: core.rename.generating,
saving: core.rename.saving,
autoNameDisabled: core.rename.autoNameDisabled,
}
: null,
rename: core.rename.active ? { draft: core.rename.draft, generating: core.rename.generating } : null,
};
});
}

View file

@ -195,9 +195,6 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb
cancel: (threadId) => {
deps.rename.cancel(threadId);
},
cancelAutoName: (threadId) => {
deps.rename.cancelAutoName(threadId);
},
autoName: (threadId) => {
void deps.rename.autoNameDraft(threadId);
},

View file

@ -88,8 +88,6 @@ export interface ComposerShellProps {
busy: boolean;
canInterrupt: boolean;
submissionDisabled: boolean;
directInputDisabled: boolean;
runtimeControlsDisabled: boolean;
sendDisabled: boolean;
webSubmissionCancellable: boolean;
normalPlaceholder: string;
@ -108,8 +106,6 @@ export function ComposerShell({
busy,
canInterrupt,
submissionDisabled,
directInputDisabled,
runtimeControlsDisabled,
sendDisabled,
webSubmissionCancellable,
normalPlaceholder,
@ -156,16 +152,8 @@ export function ComposerShell({
if (pendingSelection.value === draft) restoreComposerSelection(composerRef.current, pendingSelection);
onPendingSelectionApplied?.();
}, [draft, pendingSelection, onPendingSelectionApplied]);
const sendMode = composerSendMode(
busy,
canInterrupt,
draft,
submissionDisabled,
directInputDisabled,
sendDisabled,
webSubmissionCancellable,
);
const composerLocked = submissionDisabled || directInputDisabled;
const sendMode = composerSendMode(busy, canInterrupt, draft, submissionDisabled, sendDisabled, webSubmissionCancellable);
const composerLocked = submissionDisabled;
const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1);
const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined;
@ -175,7 +163,7 @@ export function ComposerShell({
<textarea
ref={composerRef}
className="codex-panel-ui__text-input codex-panel__composer-input"
placeholder={sendMode.canInterrupt && !composerLocked ? "Steer the current turn..." : normalPlaceholder}
placeholder={sendMode.canInterrupt ? "Steer the current turn..." : normalPlaceholder}
role="combobox"
aria-autocomplete="list"
aria-expanded={suggestions.length > 0 ? "true" : "false"}
@ -203,7 +191,7 @@ export function ComposerShell({
callbacks.onDragOver?.(event);
}}
/>
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} disabled={composerLocked || runtimeControlsDisabled} />
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} disabled={composerLocked} />
</div>
<ComposerSuggestions
containerRef={suggestionsRef}
@ -492,7 +480,6 @@ function composerSendMode(
canInterrupt: boolean,
draft: string,
submissionDisabled: boolean,
directInputDisabled: boolean,
sendDisabled: boolean,
webSubmissionCancellable: boolean,
): ComposerSendMode {
@ -506,13 +493,13 @@ function composerSendMode(
};
}
const hasDraft = Boolean(draft.trim());
const interruptMode = canInterrupt && (!hasDraft || directInputDisabled);
const canSteer = canInterrupt && hasDraft && !directInputDisabled;
const canSteer = canInterrupt && hasDraft;
const interruptMode = canInterrupt && !hasDraft;
return {
icon: interruptMode ? "square" : canSteer ? "corner-down-right" : "send",
label: interruptMode ? "Interrupt" : canSteer ? "Steer" : "Send",
className: interruptMode ? "is-interrupt" : canSteer ? "is-steer" : "",
disabled: submissionDisabled || (directInputDisabled && !interruptMode) || (sendDisabled && !interruptMode) || (busy && !canInterrupt),
disabled: submissionDisabled || (sendDisabled && !interruptMode) || (busy && !canInterrupt),
canInterrupt,
};
}

View file

@ -1,7 +1,6 @@
import { micromark } from "micromark";
import { type App, type Component, MarkdownRenderer, Notice } from "obsidian";
import { codexThreadIdFromHref } from "../../../../domain/threads/deep-link";
import { isAbsoluteFileHref, vaultRelativeFileLinkTarget } from "../../../../domain/vault/file-hrefs";
import { vaultFileLinkTarget } from "../../../../shared/obsidian/vault-file-links.obsidian";
import { notifyThreadStreamContentRendered } from "./content-rendered-event.dom";
@ -10,7 +9,6 @@ interface ThreadStreamMarkdownRendererOptions {
app: App;
owner: Component;
vaultPath: string;
openThread: (threadId: string) => void;
}
interface StreamMarkdownRenderContext {
@ -49,7 +47,6 @@ export class ThreadStreamMarkdownRenderer {
parent.replaceChildren(...Array.from(staging.childNodes));
bindRenderedWikiLinks(parent, sourcePath, this.options);
bindRenderedMarkdownFileLinks(parent, sourcePath, this.options);
bindCodexThreadLinks(parent, this.options);
bindRenderedTags(parent, this.options);
notifyThreadStreamContentRendered(parent);
})
@ -75,10 +72,6 @@ interface RenderedMarkdownLinkContext {
vaultPath: string;
}
interface CodexThreadLinkContext {
openThread: (threadId: string) => void;
}
function bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string, context: RenderedMarkdownLinkContext): void {
parent.querySelectorAll<HTMLAnchorElement>("a.internal-link").forEach((link) => {
link.addClass("codex-panel__wikilink");
@ -122,18 +115,6 @@ function bindRenderedTags(parent: HTMLElement, context: RenderedMarkdownLinkCont
});
}
function bindCodexThreadLinks(parent: HTMLElement, context: CodexThreadLinkContext): void {
parent.querySelectorAll<HTMLAnchorElement>('a[href^="codex://threads/"]').forEach((link) => {
const threadId = codexThreadIdFromHref(link.getAttribute("href") ?? "");
if (!threadId) return;
link.addClass("codex-panel__thread-link");
link.onclick = (event) => {
event.preventDefault();
context.openThread(threadId);
};
});
}
function renderedTagName(link: HTMLAnchorElement): string | null {
const text = link.textContent.trim();
const value = text || link.getAttribute("data-tag") || link.getAttribute("href");

View file

@ -12,15 +12,13 @@ export interface ToolbarThreadRow {
title: string;
threadId: string;
selected: boolean;
renameDisabled: boolean;
archiveDisabled: boolean;
disabled: boolean;
openDisabled?: boolean;
canArchive: boolean;
archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean };
rename: {
draft: string;
generating: boolean;
saving: boolean;
autoNameDisabled: boolean;
} | null;
}
@ -89,7 +87,6 @@ interface ToolbarThreadActions {
updateDraft: (threadId: string, value: string) => void;
save: (threadId: string, value: string) => void;
cancel: (threadId: string) => void;
cancelAutoName: (threadId: string) => void;
autoName: (threadId: string) => void;
};
}
@ -368,11 +365,7 @@ function ThreadList({
{threads.map((thread) => (
<ThreadListRow key={thread.threadId} thread={thread} actions={actions} />
))}
{error ? (
<div className="codex-panel__thread-list-status codex-panel__thread-list-status--error" role="status">
{error}
</div>
) : null}
{error ? <ToolbarPanelItem label={error} className="codex-panel__thread codex-panel__thread--error" interactive={false} /> : null}
{hasMore ? (
<ToolbarPanelItem
label={loadingMore ? "Loading more threads…" : "Load more threads"}
@ -411,6 +404,7 @@ function ThreadListRow({ thread, actions }: { thread: ToolbarThreadRow; actions:
label={thread.title}
selected={thread.selected}
selectionStyle="row"
disabled={thread.openDisabled ?? thread.disabled}
className="codex-panel__thread"
onClick={() => {
actions.resume(thread.threadId);
@ -421,7 +415,7 @@ function ThreadListRow({ thread, actions }: { thread: ToolbarThreadRow; actions:
icon="pencil"
label="Rename thread"
className="codex-panel__thread-action"
disabled={thread.renameDisabled}
disabled={thread.disabled}
onClick={(event) => {
event.stopPropagation();
actions.rename.start(thread.threadId);
@ -443,7 +437,7 @@ function ArchiveControls({ thread, actions }: { thread: ToolbarThreadRow; action
icon="archive"
label="Archive thread"
className="codex-panel__thread-action"
disabled={thread.archiveDisabled}
disabled={thread.disabled}
onClick={(event) => {
event.stopPropagation();
actions.archive.start(thread.threadId);
@ -477,7 +471,7 @@ function ArchiveModeButton({
icon={saveMarkdown ? "save" : "trash"}
label={label}
className={`codex-panel__thread-action ${primary ? "codex-panel__archive-default" : "codex-panel__archive-alternate"}`}
disabled={thread.archiveDisabled}
disabled={thread.disabled}
onClick={(event) => {
event.stopPropagation();
actions.archive.confirm(thread.threadId, saveMarkdown);
@ -489,12 +483,10 @@ function ArchiveModeButton({
function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; actions: ToolbarThreadActions }): UiNode {
const inputRef = useRef<HTMLInputElement | null>(null);
const generating = thread.rename?.generating ?? false;
const saving = thread.rename?.saving ?? false;
const renameBusy = generating || saving;
const draft = thread.rename?.draft ?? thread.title;
useLayoutEffect(() => {
if (!renameBusy) focusToolbarRenameInput(inputRef.current);
}, [draft, renameBusy]);
focusToolbarRenameInput(inputRef.current);
}, [draft]);
return (
<>
@ -509,41 +501,39 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action
className="codex-panel-ui__nav-inline-input codex-panel__thread-rename-input"
type="text"
value={draft}
disabled={renameBusy}
onInput={(event) => {
actions.rename.updateDraft(thread.threadId, event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (!event.isComposing && !renameBusy) actions.rename.save(thread.threadId, event.currentTarget.value);
if (!event.isComposing && !generating) actions.rename.save(thread.threadId, event.currentTarget.value);
return;
}
if (event.key === "Escape") {
event.preventDefault();
if (!renameBusy) actions.rename.cancel(thread.threadId);
actions.rename.cancel(thread.threadId);
}
}}
onBlur={(event) => {
if (!renameBusy) actions.rename.save(thread.threadId, event.currentTarget.value);
if (!generating) actions.rename.save(thread.threadId, event.currentTarget.value);
}}
/>
</div>
)}
/>
<ToolbarRowActionButton
icon={generating ? "x" : "sparkles"}
label={generating ? "Cancel auto-name" : "Auto-name thread"}
icon={generating ? "loader" : "sparkles"}
label="Auto-name thread"
className="codex-panel__thread-action"
disabled={saving || (!generating && (thread.rename?.autoNameDisabled ?? true))}
disabled={generating}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
if (generating) actions.rename.cancelAutoName(thread.threadId);
else actions.rename.autoName(thread.threadId);
actions.rename.autoName(thread.threadId);
}}
/>
</>

View file

@ -3,13 +3,9 @@ import {
runEphemeralStructuredTurnForLastAgentText,
type StructuredTurnOutputSchema,
} from "../../app-server/services/ephemeral-structured-turn";
import { SelectionRewriteOutputError, selectionRewriteOutputParseResultFromText } from "./output";
import { SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS, SELECTION_REWRITE_SERVICE_NAME } from "./prompt";
import {
type SelectionRewriteOutput,
SelectionRewriteOutputError,
type SelectionRewriteTransport,
type SelectionRewriteTransportRequest,
} from "./transport";
import type { SelectionRewriteTransport, SelectionRewriteTransportRequest } from "./transport";
const SELECTION_REWRITE_TIMEOUT_MS = 120_000;
@ -64,19 +60,7 @@ async function runAppServerSelectionRewrite(options: AppServerSelectionRewriteTr
},
options.runner,
);
const output = selectionRewriteOutputFromText(lastAgentText);
if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", lastAgentText);
const { output, rawText } = selectionRewriteOutputParseResultFromText(lastAgentText);
if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", rawText);
return output;
}
function selectionRewriteOutputFromText(text: string | null): SelectionRewriteOutput | null {
if (!text) return null;
try {
const parsed = JSON.parse(text.trim()) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const replacementText = (parsed as { replacementText?: unknown }).replacementText;
return typeof replacementText === "string" ? { replacementText } : null;
} catch {
return null;
}
}

View file

@ -27,17 +27,22 @@ export function registerSelectionRewriteCommand(
plugin.addCommand({
id: "rewrite-selection",
name: "Rewrite selection",
editorCheckCallback: (checking, editor, view) => {
if (!(view instanceof MarkdownView) || !view.file) return false;
const originalText = editor.getSelection();
if (!originalText.trim()) return false;
if (checking) return true;
editorCallback: (editor, view) => {
if (!(view instanceof MarkdownView) || !view.file) {
new Notice("Select text in an active markdown note first.");
return;
}
const originalText = editor.getSelection();
if (!originalText.trim()) {
new Notice("Select text to rewrite first.");
return;
}
const viewDocument = view.containerEl.doc;
const viewWindow = viewDocument.defaultView;
if (!viewWindow) {
new Notice("Could not open rewrite popover for this note.");
return false;
return;
}
const rewriteState: SelectionRewriteState = {
@ -70,7 +75,6 @@ export function registerSelectionRewriteCommand(
});
popover.open();
activePopovers.add(popover);
return true;
},
});

View file

@ -1,13 +1,20 @@
import { diffArrays } from "diff";
export function buildSelectionDiffLines(originalText: string, replacementText: string): string[] {
export function buildSelectionUnifiedDiff(filePath: string, originalText: string, replacementText: string): string {
const originalLines = textLines(originalText);
const replacementLines = textLines(replacementText);
const changes = lineChanges(originalLines, replacementLines);
return changes.map((change) => `${change.prefix}${change.text}`);
const oldCount = Math.max(originalLines.length, 1);
const newCount = Math.max(replacementLines.length, 1);
return [
`diff --git a/${filePath} b/${filePath}`,
`--- a/${filePath}`,
`+++ b/${filePath}`,
`@@ -1,${String(oldCount)} +1,${String(newCount)} @@`,
...changes.map((change) => `${change.prefix}${change.text}`),
].join("\n");
}
const MAX_SELECTION_REWRITE_EDIT_LENGTH = 400;
const MAX_SELECTION_REWRITE_LCS_CELLS = 40_000;
interface DiffLine {
prefix: " " | "+" | "-";
@ -22,17 +29,31 @@ function textLines(text: string): string[] {
}
function lineChanges(originalLines: string[], replacementLines: string[]): DiffLine[] {
const arrayChanges = diffArrays(originalLines, replacementLines, {
maxEditLength: MAX_SELECTION_REWRITE_EDIT_LENGTH,
});
if (!arrayChanges) return linearLineChanges(originalLines, replacementLines);
if (originalLines.length * replacementLines.length > MAX_SELECTION_REWRITE_LCS_CELLS) {
return linearLineChanges(originalLines, replacementLines);
}
const changes = arrayChanges.flatMap<DiffLine>((change) =>
change.value.map((text) => ({
prefix: change.added ? "+" : change.removed ? "-" : " ",
text,
})),
);
const lengths = lcsLengths(originalLines, replacementLines);
const changes: DiffLine[] = [];
let oldIndex = 0;
let newIndex = 0;
while (oldIndex < originalLines.length || newIndex < replacementLines.length) {
if (oldIndex < originalLines.length && newIndex < replacementLines.length && originalLines[oldIndex] === replacementLines[newIndex]) {
changes.push({ prefix: " ", text: originalLines[oldIndex] ?? "" });
oldIndex += 1;
newIndex += 1;
} else if (
newIndex < replacementLines.length &&
(oldIndex === originalLines.length || (lengths[oldIndex]?.[newIndex + 1] ?? 0) > (lengths[oldIndex + 1]?.[newIndex] ?? 0))
) {
changes.push({ prefix: "+", text: replacementLines[newIndex] ?? "" });
newIndex += 1;
} else if (oldIndex < originalLines.length) {
changes.push({ prefix: "-", text: originalLines[oldIndex] ?? "" });
oldIndex += 1;
}
}
return changes.length > 0 ? changes : [{ prefix: " ", text: "" }];
}
@ -75,3 +96,18 @@ function pushLines(changes: DiffLine[], prefix: DiffLine["prefix"], lines: strin
changes.push({ prefix, text: lines[index] ?? "" });
}
}
function lcsLengths(left: string[], right: string[]): number[][] {
const rows = Array.from({ length: left.length + 1 }, () => Array.from({ length: right.length + 1 }, () => 0));
for (let leftIndex = left.length - 1; leftIndex >= 0; leftIndex -= 1) {
for (let rightIndex = right.length - 1; rightIndex >= 0; rightIndex -= 1) {
const row = rows[leftIndex];
if (row === undefined) continue;
row[rightIndex] =
left[leftIndex] === right[rightIndex]
? (rows[leftIndex + 1]?.[rightIndex + 1] ?? 0) + 1
: Math.max(rows[leftIndex + 1]?.[rightIndex] ?? 0, rows[leftIndex]?.[rightIndex + 1] ?? 0);
}
}
return rows;
}

View file

@ -0,0 +1,35 @@
export interface SelectionRewriteOutput {
replacementText: string;
}
export interface SelectionRewriteOutputParseResult {
output: SelectionRewriteOutput | null;
rawText: string | null;
}
export class SelectionRewriteOutputError extends Error {
constructor(
message: string,
readonly rawText: string | null,
) {
super(message);
this.name = "SelectionRewriteOutputError";
}
}
function parseSelectionRewriteOutput(text: string): SelectionRewriteOutput | null {
try {
const parsed = JSON.parse(text.trim()) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const replacementText = (parsed as { replacementText?: unknown }).replacementText;
if (typeof replacementText !== "string") return null;
return { replacementText };
} catch {
return null;
}
}
export function selectionRewriteOutputParseResultFromText(text: string | null): SelectionRewriteOutputParseResult {
if (!text) return { output: null, rawText: null };
return { output: parseSelectionRewriteOutput(text), rawText: text };
}

View file

@ -6,8 +6,8 @@ import { renderUiRoot, unmountUiRoot } from "../../shared/dom/preact-root.dom";
import { syncTextareaHeight } from "../../shared/dom/textarea-autogrow.measure";
import { textareaCursorAtVisualBoundary } from "../../shared/dom/textarea-caret.measure";
import { IconButton } from "../../shared/obsidian/components.obsidian";
import { DiffLineList } from "../../shared/ui/diff-view";
import { buildSelectionDiffLines } from "./diff";
import { DiffLineList, unifiedDiffDisplayLines } from "../../shared/ui/diff-view";
import { buildSelectionUnifiedDiff } from "./diff";
import {
canApplySelectionRewrite,
type SelectionRewriteInstructionHistoryDirection,
@ -201,7 +201,7 @@ export class SelectionRewritePopover {
elements.applyButton = element;
}}
debugText={state.debugText}
diffLines={replacement === null ? null : buildSelectionDiffLines(state.originalText, replacement)}
diff={replacement === null ? null : buildSelectionUnifiedDiff(state.filePath, state.originalText, replacement)}
generating={state.status === "generating"}
hasInstruction={this.session.hasInstruction}
hasReplacement={replacement !== null}
@ -306,7 +306,7 @@ function selectionRewriteInstructionCursorOnLogicalBoundary(
interface SelectionRewritePopoverViewProps {
applyButtonRef: (element: HTMLButtonElement | null) => void;
debugText: string | null;
diffLines: readonly string[] | null;
diff: string | null;
generating: boolean;
hasInstruction: boolean;
hasReplacement: boolean;
@ -324,7 +324,7 @@ interface SelectionRewritePopoverViewProps {
function SelectionRewritePopoverView({
applyButtonRef,
debugText,
diffLines,
diff,
generating,
hasInstruction,
hasReplacement,
@ -365,7 +365,7 @@ function SelectionRewritePopoverView({
<SelectionRewriteStatus status={status} />
<pre className={`codex-panel-selection-rewrite__stream-preview${streamPreview ? "" : " is-hidden"}`}>{streamPreview}</pre>
<div className={`codex-panel-selection-rewrite__result${hasReplacement ? "" : " is-hidden"}`}>
<div className="codex-panel-selection-rewrite__diff">{diffLines ? <SelectionRewriteDiff lines={diffLines} /> : null}</div>
<div className="codex-panel-selection-rewrite__diff">{diff ? <SelectionRewriteDiff diff={diff} /> : null}</div>
<div className="codex-panel-selection-rewrite__result-actions">
<IconButton
buttonRef={applyButtonRef}
@ -407,6 +407,11 @@ function SelectionRewriteStatus({ status }: { status: SelectionRewriteSessionSta
);
}
function SelectionRewriteDiff({ lines }: { lines: readonly string[] }): UiNode {
return <DiffLineList lines={lines.map((text) => ({ text }))} className="codex-panel-selection-rewrite__diff-body" />;
function SelectionRewriteDiff({ diff }: { diff: string }): UiNode {
return (
<DiffLineList
lines={unifiedDiffDisplayLines(diff).filter((line) => line.kind !== "file" && !line.text.startsWith("@@"))}
className="codex-panel-selection-rewrite__diff-body"
/>
);
}

View file

@ -1,6 +1,7 @@
import type { SelectionRewriteInstructionHistoryDirection, SelectionRewriteRuntimeSettings, SelectionRewriteState } from "./model";
import { SelectionRewriteOutputError } from "./output";
import { buildSelectionRewritePrompt } from "./prompt";
import { type SelectionRewriteActivity, SelectionRewriteOutputError, type SelectionRewriteTransport } from "./transport";
import type { SelectionRewriteActivity, SelectionRewriteTransport } from "./transport";
const MAX_SELECTION_REWRITE_INSTRUCTION_HISTORY = 20;

View file

@ -1,21 +1,8 @@
import type { SelectionRewriteRuntimeSettings } from "./model";
import type { SelectionRewriteOutput } from "./output";
export type SelectionRewriteActivity = "reasoning" | "writing";
export interface SelectionRewriteOutput {
replacementText: string;
}
export class SelectionRewriteOutputError extends Error {
constructor(
message: string,
readonly rawText: string | null,
) {
super(message);
this.name = "SelectionRewriteOutputError";
}
}
export interface SelectionRewriteTransportRequest {
prompt: string;
runtimeSettings: SelectionRewriteRuntimeSettings;

View file

@ -34,11 +34,11 @@ export function openThreadPicker(host: ThreadPickerHost, onClosed: () => void):
};
const loadAndOpen = async (): Promise<void> => {
try {
const recentSnapshot = host.threadCatalog.recentActiveThreadsSnapshot();
const loadedThreads = recentSnapshot ?? (await host.threadCatalog.fetchActiveThreads());
const recentSnapshot = host.threadCatalog.recentActiveSnapshot();
const loadedThreads = recentSnapshot ?? (await host.threadCatalog.loadActive());
if (state.closed) return;
const recentThreads = host.threadCatalog.recentActiveThreadsSnapshot() ?? loadedThreads;
if (recentThreads.length === 0 && !host.threadCatalog.hasMoreActiveThreads()) {
const recentThreads = host.threadCatalog.recentActiveSnapshot() ?? loadedThreads;
if (recentThreads.length === 0 && !host.threadCatalog.hasMoreActive()) {
new Notice("No Codex threads found.");
finish();
return;
@ -152,7 +152,7 @@ class ThreadPickerModal extends SuggestModal<ThreadSuggestion> {
private async loadCompleteThreadList(): Promise<readonly Thread[]> {
if (this.completeThreads) return this.completeThreads;
const pending = this.completeThreadsPromise ?? this.host.threadCatalog.fetchActiveThreadSearchInventory();
const pending = this.completeThreadsPromise ?? this.host.threadCatalog.searchActive();
this.completeThreadsPromise = pending;
try {
this.completeThreads = Object.freeze([...(await pending)]);

View file

@ -2,16 +2,17 @@ import { Notice } from "obsidian";
import type { ObservedPaginatedResult } from "../../app-server/query/observed-result";
import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result";
import { isStaleAppServerResourceContextError } from "../../app-server/query/resource-store";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
import type { Thread } from "../../domain/threads/model";
import type { ThreadRenameLifecycleEvent } from "../../domain/threads/rename-lifecycle";
import { DeferredTask } from "../../shared/runtime/deferred-task";
import { isStaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
import type { KeyedOperationQueue } from "../../shared/runtime/keyed-operation-queue";
import { OwnerLifetime } from "../../shared/runtime/owner-lifetime";
import type { ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
import type { ArchiveExportDestination, ArchiveExportSettings } from "../threads/workflows/archive-export";
import type { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
import type { ArchiveExportDestination } from "../threads/workflows/archive-export";
import type { ThreadOperationsTransport, ThreadTitleTransport } from "../threads/workflows/ports";
import type { ThreadOperationEventSink } from "../threads/workflows/thread-operation-event";
import type { ThreadNameMutationCoordinator } from "../threads/workflows/thread-name-mutation-coordinator";
import { createThreadOperations, type ThreadOperations } from "../threads/workflows/thread-operations";
import { createThreadTitleService, type ThreadTitleService } from "../threads/workflows/thread-title-service";
import { isThreadsArchiveConfirmPointer, renderThreadsViewShell, unmountThreadsViewShell } from "./shell.dom";
@ -20,8 +21,7 @@ export interface ThreadsViewHost {
readonly settings: ThreadsViewSettingsAccess;
readonly vaultPath: string;
readonly threadCatalog: ThreadsViewThreadCatalog;
readonly threadEvents: ThreadOperationEventSink;
readonly threadNameMutations: KeyedOperationQueue<string>;
readonly threadNameMutations: ThreadNameMutationCoordinator;
readonly threadOperationsTransport: ThreadOperationsTransport;
readonly threadTitleTransport: ThreadTitleTransport;
openNewPanel(): Promise<unknown>;
@ -29,10 +29,13 @@ export interface ThreadsViewHost {
openPanelActivities(): readonly ThreadsViewPanelActivity[];
}
type ThreadsViewThreadCatalog = ThreadCatalogPaginatedActiveReader;
type ThreadsViewThreadCatalog = ThreadCatalogPaginatedActiveReader & ThreadCatalogEventSink;
export interface ThreadsViewSettingsAccess {
archiveExportEnabled(): boolean;
codexPath(): string;
threadNamingModel(): string | null;
threadNamingEffort(): ReasoningEffort | null;
archiveExportSettings(): ArchiveExportSettings;
}
@ -45,16 +48,17 @@ export interface ThreadsViewSessionEnvironment {
viewWindow(): Window | null;
}
type ThreadsViewStatus = { kind: "idle" } | { kind: "loading"; message: string } | { kind: "error"; message: string };
type ThreadsViewStatus =
| { kind: "idle" }
| { kind: "loading"; message: string }
| { kind: "empty"; message: string }
| { kind: "log"; message: string }
| { kind: "error"; message: string };
interface ThreadsViewOperationLease {
readonly lifetime: AbortSignal;
}
interface ThreadTitleContextPreparation {
readonly threadId: string;
}
export class ThreadsViewSession {
private readonly lifetime = new OwnerLifetime();
private readonly operations: ThreadOperations;
@ -66,10 +70,7 @@ export class ThreadsViewSession {
private threads: readonly Thread[] = [];
private threadsLoaded = false;
private readonly renameStates = new Map<string, ThreadsRenameState>();
private readonly renameContextPreparations = new Map<string, ThreadTitleContextPreparation>();
private readonly renameGenerationControllers = new Map<string, { generationToken: number; controller: AbortController }>();
private nextRenameGenerationToken = 1;
private nextRenameSaveToken = 1;
private unsubscribeThreads: (() => void) | null = null;
private archiveConfirmThreadId: string | null = null;
@ -85,7 +86,7 @@ export class ThreadsViewSession {
vaultConfigDir: this.environment.vaultConfigDir(),
},
archiveDestination: () => this.environment.archiveDestination(),
operationEvents: this.host.threadEvents,
catalog: this.host.threadCatalog,
referenceThreads: () => this.threads,
notice: (message) => {
new Notice(message);
@ -101,12 +102,12 @@ export class ThreadsViewSession {
this.environment.registerPointerDown((event) => {
this.cancelArchiveConfirmOnOutsidePointer(event);
});
const activeThreadsSnapshot = this.host.threadCatalog.activeThreadsSnapshot();
const activeThreadsSnapshot = this.host.threadCatalog.activeSnapshot();
if (activeThreadsSnapshot) {
this.threads = activeThreadsSnapshot;
this.threadsLoaded = true;
}
this.unsubscribeThreads = this.host.threadCatalog.observeActiveThreadsResult((result) => {
this.unsubscribeThreads = this.host.threadCatalog.observeActive((result) => {
this.receiveObservedThreadsResult(result);
});
this.render();
@ -115,9 +116,6 @@ export class ThreadsViewSession {
close(): void {
this.lifetime.dispose();
for (const operation of this.renameGenerationControllers.values()) operation.controller.abort();
this.renameGenerationControllers.clear();
this.renameContextPreparations.clear();
this.titleService.invalidate();
this.observedFetching = false;
this.observedFetchingNextPage = false;
@ -128,11 +126,11 @@ export class ThreadsViewSession {
}
async refresh(): Promise<void> {
await this.requestThreads(() => this.host.threadCatalog.refreshActiveThreads());
await this.requestThreads(() => this.host.threadCatalog.refreshActive());
}
private async load(): Promise<void> {
await this.requestThreads(() => this.host.threadCatalog.fetchActiveThreads());
await this.requestThreads(() => this.host.threadCatalog.loadActive());
}
private async requestThreads(request: () => Promise<readonly Thread[]>): Promise<void> {
@ -141,24 +139,23 @@ export class ThreadsViewSession {
try {
await request();
} catch (error) {
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(error)) return;
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
if (!this.currentThreadsSnapshot()) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
} else {
this.noticeError(error);
}
}
}
async loadMore(): Promise<void> {
const lifetime = this.lifetime.signal();
if (!this.lifetime.isCurrent(lifetime) || !this.host.threadCatalog.hasMoreActiveThreads() || this.observedFetching) return;
if (!this.lifetime.isCurrent(lifetime) || !this.host.threadCatalog.hasMoreActive() || this.observedFetching) return;
try {
await this.host.threadCatalog.loadMoreActiveThreads();
await this.host.threadCatalog.loadMoreActive();
} catch (error) {
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(error)) return;
this.noticeError(error);
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
}
}
@ -175,10 +172,13 @@ export class ThreadsViewSession {
this.observedFetchingNextPage = result.isFetchingNextPage;
const observedThreads = result.value;
if (observedThreads) {
const hadThreadsSnapshot = this.currentThreadsSnapshot() !== null;
this.threads = observedThreads;
this.threadsLoaded = true;
this.status = result.error && !hadThreadsSnapshot ? { kind: "error", message: result.error.message } : { kind: "idle" };
this.status = result.error
? { kind: "error", message: result.error.message }
: observedThreads.length === 0
? { kind: "empty", message: "No threads" }
: { kind: "idle" };
this.render();
return;
}
@ -208,10 +208,10 @@ export class ThreadsViewSession {
renderThreadsViewShell(
this.environment.root,
{
status: this.status.kind === "idle" ? null : this.status,
status: this.status.kind === "idle" ? null : this.status.message,
loading: this.observedFetchingNextPage,
fetching: this.observedFetching,
hasMore: this.host.threadCatalog.hasMoreActiveThreads(),
hasMore: this.host.threadCatalog.hasMoreActive(),
rows: threadRows(
this.threads,
this.host.openPanelActivities(),
@ -235,9 +235,6 @@ export class ThreadsViewSession {
cancelRename: (threadId) => {
this.cancelRename(threadId);
},
cancelAutoName: (threadId) => {
this.cancelAutoName(threadId);
},
autoNameThread: (threadId) => void this.autoNameThread(threadId),
startArchive: (threadId) => {
this.startArchive(threadId);
@ -263,12 +260,9 @@ export class ThreadsViewSession {
}
private startRename(threadId: string, value: string): void {
const current = this.renameStates.get(threadId);
if (current?.kind === "saving") return;
this.archiveConfirmThreadId = null;
this.transitionRenameState(threadId, { type: "started", draft: value });
this.render();
void this.prepareAutoName(threadId);
}
private updateRename(threadId: string, value: string): void {
@ -277,45 +271,30 @@ export class ThreadsViewSession {
}
private cancelRename(threadId: string): void {
if (this.renameStates.get(threadId)?.kind === "saving") return;
this.abortAutoName(threadId);
this.renameContextPreparations.delete(threadId);
this.transitionRenameState(threadId, { type: "cancelled" });
this.render();
}
private cancelAutoName(threadId: string): void {
const state = this.renameStates.get(threadId);
if (state?.kind !== "generating") return;
this.abortAutoName(threadId);
this.finishAutoNameThread(threadId, state.generationToken);
}
private async saveRename(threadId: string, value: string): Promise<void> {
const lease = this.captureOperationLease();
const previousState = this.renameStates.get(threadId);
if (previousState?.kind !== "editing") return;
const saveToken = this.nextRenameSaveToken;
const savingState = this.transitionRenameState(threadId, { type: "save-started", saveToken });
if (savingState === previousState || savingState?.kind !== "saving") return;
this.nextRenameSaveToken += 1;
this.render();
const editingState = this.renameStates.get(threadId);
if (!editingState || editingState.kind === "generating") return;
try {
await this.operations.renameThread(threadId, value, {
shouldStart: () => this.operationContextIsCurrent(lease) && this.renameSaveStillActive(threadId, saveToken),
shouldPublish: () => this.operationContextIsCurrent(lease) && this.renameSaveStillActive(threadId, saveToken),
if (this.renameStates.get(threadId) !== editingState) return;
const result = await this.operations.renameThread(threadId, value, {
shouldStart: () => this.operationContextIsCurrent(lease),
shouldPublish: () => this.operationContextIsCurrent(lease),
});
if (!this.operationViewIsCurrent(lease)) return;
const currentState = this.renameStates.get(threadId);
const nextState = this.transitionRenameState(threadId, { type: "save-succeeded", saveToken });
if (nextState !== currentState) {
if (!nextState) this.renameContextPreparations.delete(threadId);
this.render();
if (!this.operationViewIsCurrent(lease) || this.renameStates.get(threadId) !== editingState) return;
if (!result) {
this.cancelRename(threadId);
return;
}
this.renameStates.delete(threadId);
this.render();
} catch (error) {
if (!this.operationViewIsCurrent(lease) || !this.renameSaveStillActive(threadId, saveToken)) return;
this.noticeError(error);
this.transitionRenameState(threadId, { type: "save-failed", saveToken });
if (!this.operationViewIsCurrent(lease) || this.renameStates.get(threadId) !== editingState) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
}
}
@ -330,24 +309,19 @@ export class ThreadsViewSession {
});
if (generatingState === previousState || generatingState?.kind !== "generating") return;
this.nextRenameGenerationToken += 1;
const controller = new AbortController();
this.renameGenerationControllers.set(threadId, { generationToken, controller });
this.render();
try {
const context = generatingState.autoName.context;
const title = await this.titleService.generate(context, controller.signal);
if (!title) throw new Error("Codex did not return a usable thread title.");
if (this.renameStates.get(threadId) !== generatingState) return;
const title = await this.titleService.generateTitle(threadId);
if (!this.operationViewIsCurrent(lease)) return;
this.transitionRenameState(threadId, { type: "generation-succeeded", generationToken, draft: title });
} catch (error) {
if (!this.operationViewIsCurrent(lease)) return;
if (this.renameStates.get(threadId) === generatingState) {
this.noticeError(error);
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
}
} finally {
const operation = this.renameGenerationControllers.get(threadId);
if (operation?.generationToken === generationToken) this.renameGenerationControllers.delete(threadId);
if (this.operationViewIsCurrent(lease)) {
this.finishAutoNameThread(threadId, generationToken);
}
@ -382,7 +356,8 @@ export class ThreadsViewSession {
this.renameStates.delete(threadId);
} catch (error) {
if (!this.operationViewIsCurrent(lease)) return;
this.noticeError(error);
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
}
}
@ -398,38 +373,12 @@ export class ThreadsViewSession {
return this.lifetime.isCurrent(lease.lifetime);
}
private noticeError(error: unknown): void {
new Notice(error instanceof Error ? error.message : String(error));
}
private finishAutoNameThread(threadId: string, generationToken: number): void {
const previousState = this.renameStates.get(threadId);
const nextState = this.transitionRenameState(threadId, { type: "generation-finished", generationToken });
if (nextState !== previousState) this.render();
}
private async prepareAutoName(threadId: string): Promise<void> {
const preparation = { threadId };
this.renameContextPreparations.set(threadId, preparation);
let context = null;
try {
context = await this.titleService.resolveContext(threadId);
} catch {
// Auto-name availability is reflected by the disabled action.
}
if (!this.lifetime.isActive() || this.renameContextPreparations.get(threadId) !== preparation) return;
this.renameContextPreparations.delete(threadId);
const state = this.renameStates.get(threadId);
if (state?.kind !== "editing" && state?.kind !== "saving") return;
this.transitionRenameState(threadId, { type: "auto-name-context-resolved", context });
this.render();
}
private abortAutoName(threadId: string): void {
this.renameGenerationControllers.get(threadId)?.controller.abort();
this.renameGenerationControllers.delete(threadId);
}
private transitionRenameState(threadId: string, event: ThreadRenameLifecycleEvent): ThreadsRenameState | undefined {
const nextState = transitionThreadsRenameState(this.renameStates.get(threadId), event);
if (nextState) {
@ -440,11 +389,6 @@ export class ThreadsViewSession {
return nextState;
}
private renameSaveStillActive(threadId: string, saveToken: number): boolean {
const current = this.renameStates.get(threadId);
return current?.kind === "saving" && current.saveToken === saveToken;
}
private viewWindow(): Window {
return this.environment.viewWindow() ?? window;
}

View file

@ -9,7 +9,7 @@ type ButtonProps = ButtonHTMLAttributes & {
};
export interface ThreadsViewShellModel {
status: { kind: "loading" | "error"; message: string } | null;
status: string | null;
loading: boolean;
fetching?: boolean;
hasMore?: boolean;
@ -25,7 +25,6 @@ export interface ThreadsViewShellActions {
updateRename: (threadId: string, value: string) => void;
saveRename: (threadId: string, value: string) => void;
cancelRename: (threadId: string) => void;
cancelAutoName: (threadId: string) => void;
autoNameThread: (threadId: string) => void;
startArchive: (threadId: string) => void;
archiveThread: (threadId: string, saveMarkdown: boolean) => void;
@ -66,22 +65,10 @@ function ThreadsViewShell({ model, actions }: { model: ThreadsViewShellModel; ac
</div>
<div className="codex-panel-threads__list">
{model.rows.length === 0 ? (
model.status ? (
<div
className={
model.status.kind === "error"
? "codex-panel-threads__status codex-panel-threads__status--error"
: "codex-panel-threads__status"
}
role="status"
>
{model.status.message}
</div>
) : (
<div className="codex-panel-threads__empty">No threads</div>
)
<div className="codex-panel-threads__empty">{model.status ?? (model.loading ? "Loading threads..." : "No threads")}</div>
) : (
<>
{model.status ? <div className="codex-panel-threads__status">{model.status}</div> : null}
{model.rows.map((row) => (
<ThreadRow key={row.threadId} row={row} actions={actions} />
))}
@ -226,10 +213,9 @@ function threadArchiveDisabled(row: ThreadsRowModel): boolean {
function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions: ThreadsViewShellActions; className: string }): UiNode {
const inputRef = useRef<HTMLInputElement | null>(null);
const renameBusy = row.rename.generating || row.rename.saving;
useLayoutEffect(() => {
if (!renameBusy) focusThreadsRenameInput(inputRef.current);
}, [row.rename.draft, renameBusy]);
focusThreadsRenameInput(inputRef.current);
}, [row.rename.draft]);
return (
<>
@ -240,41 +226,39 @@ function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions:
className="codex-panel-ui__nav-inline-input codex-panel-threads__rename-input"
type="text"
value={row.rename.draft}
disabled={renameBusy}
onInput={(event) => {
actions.updateRename(row.threadId, event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (!event.isComposing && !renameBusy) actions.saveRename(row.threadId, event.currentTarget.value);
if (!event.isComposing && !row.rename.generating) actions.saveRename(row.threadId, event.currentTarget.value);
return;
}
if (event.key === "Escape") {
event.preventDefault();
if (!renameBusy) actions.cancelRename(row.threadId);
actions.cancelRename(row.threadId);
}
}}
onBlur={(event) => {
if (!renameBusy) actions.saveRename(row.threadId, event.currentTarget.value);
if (!row.rename.generating) actions.saveRename(row.threadId, event.currentTarget.value);
}}
/>
</div>
</div>
<div className="codex-panel-threads__actions codex-panel-threads__rename-actions">
<ThreadsRowButton
icon={row.rename.generating ? "x" : "sparkles"}
label={row.rename.generating ? "Cancel auto-name" : "Auto-name thread"}
icon={row.rename.generating ? "loader" : "sparkles"}
label="Auto-name thread"
className="codex-panel-threads__row-button"
disabled={row.rename.saving || (!row.rename.generating && row.rename.autoNameDisabled)}
disabled={row.rename.generating}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
if (row.rename.generating) actions.cancelAutoName(row.threadId);
else actions.autoNameThread(row.threadId);
actions.autoNameThread(row.threadId);
}}
/>
</div>

View file

@ -1,7 +1,7 @@
import { Component, ItemView, type WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_CODEX_THREADS } from "../../constants";
import { createObsidianVaultMarkdownDestination } from "../../shared/obsidian/vault-write-destination.obsidian";
import { createObsidianArchiveExportDestination } from "../threads/obsidian/archive-export-destination.obsidian";
import { type ThreadsViewHost, ThreadsViewSession } from "./session";
export interface ThreadsRuntimeView {
@ -38,7 +38,7 @@ export class CodexThreadsView extends ItemView {
registerPointerDown: (handler) => {
owner.registerDomEvent(this.containerEl.doc, "pointerdown", handler);
},
archiveDestination: () => createObsidianVaultMarkdownDestination(this.app.vault),
archiveDestination: () => createObsidianArchiveExportDestination(this.app.vault),
vaultConfigDir: () => this.app.vault.configDir,
viewWindow: () => this.containerEl.doc.defaultView,
});

View file

@ -5,32 +5,129 @@ import type { Thread } from "../../../domain/threads/model";
type ActiveThreadListObserver = ObservedPaginatedResultListener<readonly Thread[]>;
type ArchivedThreadListObserver = ObservedResultListener<readonly Thread[]>;
export interface ThreadCatalogPaginatedActiveReader {
interface ThreadCatalogStore {
activeThreadsSnapshot(): readonly Thread[] | null;
recentActiveThreadsSnapshot(): readonly Thread[] | null;
archivedThreadsSnapshot(): readonly Thread[] | null;
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]>;
fetchActiveThreads(): Promise<readonly Thread[]>;
refreshActiveThreads(): Promise<readonly Thread[]>;
observeActiveThreadsResult(observer: ActiveThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
hasMoreActiveThreads(): boolean;
loadMoreActiveThreads(): Promise<readonly Thread[]>;
}
export interface ThreadCatalogSearchReader {
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]>;
}
export interface ThreadCatalogArchivedReader {
archivedThreadsSnapshot(): readonly Thread[] | null;
refreshActiveThreads(): Promise<readonly Thread[]>;
refreshArchivedThreads(): Promise<readonly Thread[]>;
applyThreadListMutations(mutations: readonly ThreadListMutation[]): void;
observeActiveThreadsResult(observer: ActiveThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
observeArchivedThreadsResult(observer: ArchivedThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
}
interface ThreadCatalogWriter {
applyThreadListMutations(changes: readonly ThreadListMutation[]): void;
type ThreadCatalogEventObserver = (event: ThreadCatalogEvent) => void;
export interface ThreadCatalogOptions {
store: ThreadCatalogStore;
onEventApplied?: ThreadCatalogEventObserver;
}
export type ThreadCatalogEvent =
| { type: "thread-upserted"; thread: Thread }
| { type: "thread-renamed"; threadId: string; name: string | null }
| { type: "thread-archived"; threadId: string }
| { type: "thread-deleted"; threadId: string }
| { type: "thread-restored"; thread: Thread }
| { type: "thread-unarchived"; threadId: string };
export interface ThreadCatalogPaginatedActiveReader {
activeSnapshot(): readonly Thread[] | null;
recentActiveSnapshot(): readonly Thread[] | null;
loadActive(): Promise<readonly Thread[]>;
refreshActive(): Promise<readonly Thread[]>;
observeActive(observer: ActiveThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
hasMoreActive(): boolean;
loadMoreActive(): Promise<readonly Thread[]>;
}
export interface ThreadCatalogSearchReader {
searchActive(): Promise<readonly Thread[]>;
}
export interface ThreadCatalogArchivedReader {
archivedSnapshot(): readonly Thread[] | null;
refreshArchived(): Promise<readonly Thread[]>;
observeArchived(observer: ArchivedThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
}
export interface ThreadCatalogEventSink {
apply(event: ThreadCatalogEvent): void;
}
export interface ThreadCatalog
extends ThreadCatalogPaginatedActiveReader,
ThreadCatalogSearchReader,
ThreadCatalogArchivedReader,
ThreadCatalogWriter {}
ThreadCatalogEventSink {}
export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalog {
const { store } = options;
const apply = (event: ThreadCatalogEvent): void => {
store.applyThreadListMutations(threadListMutationsForEvent(store, event));
options.onEventApplied?.(event);
};
return {
apply,
activeSnapshot: () => store.activeThreadsSnapshot(),
recentActiveSnapshot: () => store.recentActiveThreadsSnapshot(),
loadActive: () => store.fetchActiveThreads(),
searchActive: () => store.fetchActiveThreadSearchInventory(),
refreshActive: () => store.refreshActiveThreads(),
hasMoreActive: () => store.hasMoreActiveThreads(),
loadMoreActive: () => store.loadMoreActiveThreads(),
observeActive: (observer, observeOptions) => store.observeActiveThreadsResult(observer, observeOptions),
archivedSnapshot: () => store.archivedThreadsSnapshot(),
refreshArchived: () => store.refreshArchivedThreads(),
observeArchived: (observer, observeOptions) => store.observeArchivedThreadsResult(observer, observeOptions),
};
}
function threadListMutationsForEvent(store: ThreadCatalogStore, event: ThreadCatalogEvent): ThreadListMutation[] {
switch (event.type) {
case "thread-upserted":
return [{ kind: "upsert", list: "active", thread: { ...event.thread, archived: false } }];
case "thread-renamed":
return [
{ kind: "update", list: "active", threadId: event.threadId, changes: { name: event.name } },
{ kind: "update", list: "archived", threadId: event.threadId, changes: { name: event.name } },
];
case "thread-archived": {
const thread = threadById(store.activeThreadsSnapshot(), event.threadId);
return [
{ kind: "remove", list: "active", threadId: event.threadId },
...(thread
? [{ kind: "upsert", list: "archived", thread: { ...thread, archived: true } } satisfies ThreadListMutation]
: [{ kind: "refresh", list: "archived" } satisfies ThreadListMutation]),
];
}
case "thread-deleted":
return [
{ kind: "remove", list: "active", threadId: event.threadId },
{ kind: "remove", list: "archived", threadId: event.threadId },
];
case "thread-restored":
return [
{ kind: "upsert", list: "active", thread: { ...event.thread, archived: false } },
{ kind: "remove", list: "archived", threadId: event.thread.id },
];
case "thread-unarchived": {
const thread = threadById(store.archivedThreadsSnapshot(), event.threadId);
return [
{ kind: "remove", list: "archived", threadId: event.threadId },
...(thread
? [{ kind: "upsert", list: "active", thread: { ...thread, archived: false } } satisfies ThreadListMutation]
: [{ kind: "refresh", list: "active" } satisfies ThreadListMutation]),
];
}
}
}
function threadById(threads: readonly Thread[] | null, threadId: string): Thread | null {
return threads?.find((thread) => thread.id === threadId) ?? null;
}

View file

@ -6,8 +6,6 @@ interface ThreadRowCoreRenameProjection {
readonly active: boolean;
readonly draft: string;
readonly generating: boolean;
readonly saving: boolean;
readonly autoNameDisabled: boolean;
}
interface ThreadRowCoreArchiveConfirmProjection {
@ -39,8 +37,6 @@ export function threadRowCoreProjection(input: {
active: rename !== undefined,
draft: rename?.draft ?? threadRenameDraftTitle(input.thread),
generating: rename?.kind === "generating",
saving: rename?.kind === "saving",
autoNameDisabled: rename?.autoName.kind !== "ready",
},
archiveConfirm: {
active: input.archiveConfirmActive ?? false,

View file

@ -0,0 +1,7 @@
import type { Vault } from "obsidian";
import { createObsidianVaultMarkdownDestination } from "../../../shared/obsidian/vault-write-destination.obsidian";
import type { ArchiveExportDestination } from "../workflows/archive-export";
export function createObsidianArchiveExportDestination(vault: Vault): ArchiveExportDestination {
return createObsidianVaultMarkdownDestination(vault);
}

View file

@ -1,4 +1,4 @@
import { type ArchiveMarkdownOptions, type ArchiveThreadInput, archivedThreadMarkdown } from "../../../domain/threads/archive-markdown";
import { type ArchiveExportSettings, type ArchiveThreadInput, archivedThreadMarkdown } from "../../../domain/threads/archive-markdown";
import { shortThreadId } from "../../../domain/threads/id";
import { threadArchiveTitle } from "../../../domain/threads/title";
import {
@ -19,11 +19,6 @@ export interface ArchiveExportResult {
path: string;
}
export interface ArchiveExportSettings extends ArchiveMarkdownOptions {
archiveExportFolderTemplate: string;
archiveExportFilenameTemplate: string;
}
export type ArchiveExportDestination = VaultMarkdownDestination;
interface TemplateContext {

View file

@ -0,0 +1,9 @@
import { createKeyedOperationQueue } from "../../../shared/runtime/keyed-operation-queue";
export interface ThreadNameMutationCoordinator {
run<T>(threadId: string, operation: () => Promise<T>): Promise<T>;
}
export function createThreadNameMutationCoordinator(): ThreadNameMutationCoordinator {
return createKeyedOperationQueue();
}

View file

@ -1,184 +0,0 @@
import type { Thread } from "../../../domain/threads/model";
import type {
ThreadLifecycleEvent,
ThreadOperationCommitter,
ThreadOperationEvent,
ThreadOperationEventSink,
} from "./thread-operation-event";
interface ThreadForkPublication {
record(thread: Thread): void;
finish(options?: { sourceArchived?: boolean }): void;
}
export interface ThreadOperationCoordinator extends ThreadOperationEventSink {
beginForkPublication(sourceThreadId: string): ThreadForkPublication;
}
type ForkedThreadState = "active" | "archived" | "deleted";
interface PendingForkPublication {
child: PendingForkChild | null;
finished: boolean;
}
interface PendingForkChild {
thread: Thread;
state: ForkedThreadState;
eventsBeforeClaim: ThreadOperationEvent[] | null;
}
interface ForkSourceGroup {
publications: Set<PendingForkPublication>;
unclaimedChildren: Map<string, PendingForkChild>;
sourceState: "active" | "archived" | null;
restoredSource: Thread | null;
}
export function createThreadOperationCoordinator(committer: ThreadOperationCommitter): ThreadOperationCoordinator {
const sourceGroups = new Map<string, ForkSourceGroup>();
const pendingChildrenByThread = new Map<string, PendingForkChild>();
const apply = (event: ThreadOperationEvent): void => {
const pendingChild = pendingChildrenByThread.get(threadIdForEvent(event));
if (pendingChild) {
pendingChild.eventsBeforeClaim?.push(event);
if (applyChildEvent(pendingChild, event)) return;
}
if (event.type === "thread-upserted" && event.forkedFromThreadId) {
const group = sourceGroups.get(event.forkedFromThreadId);
if (group) {
const child = { thread: event.thread, state: "active", eventsBeforeClaim: [] } satisfies PendingForkChild;
group.unclaimedChildren.set(event.thread.id, child);
pendingChildrenByThread.set(event.thread.id, child);
return;
}
}
const sourceGroup = sourceGroups.get(threadIdForEvent(event));
if (sourceGroup && applySourceEvent(sourceGroup, event)) return;
committer([lifecycleEvent(event)]);
};
const beginForkPublication = (sourceThreadId: string): ThreadForkPublication => {
const publication: PendingForkPublication = { child: null, finished: false };
const group = sourceGroups.get(sourceThreadId) ?? createForkSourceGroup();
group.publications.add(publication);
sourceGroups.set(sourceThreadId, group);
return {
record: (thread) => {
if (publication.finished) return;
const child = group.unclaimedChildren.get(thread.id) ?? { thread, state: "active", eventsBeforeClaim: null };
const eventsBeforeClaim = child.eventsBeforeClaim;
child.thread = thread;
child.state = "active";
child.eventsBeforeClaim = null;
for (const event of eventsBeforeClaim ?? []) applyChildEvent(child, event);
publication.child = child;
group.unclaimedChildren.delete(thread.id);
pendingChildrenByThread.set(thread.id, child);
},
finish: (options = {}) => {
if (publication.finished) return;
publication.finished = true;
if (publication.child) pendingChildrenByThread.delete(publication.child.thread.id);
group.publications.delete(publication);
if (group.sourceState === null && options.sourceArchived !== undefined) {
group.sourceState = options.sourceArchived ? "archived" : "active";
}
const events = completedForkChildEvents(publication);
if (group.publications.size === 0) events.push(...completedSourceEvents(group, sourceThreadId));
if (events.length > 0) committer(events);
if (group.publications.size > 0) return;
sourceGroups.delete(sourceThreadId);
for (const child of group.unclaimedChildren.values()) {
pendingChildrenByThread.delete(child.thread.id);
committer(completedChildEvents(child));
}
},
};
};
return { apply, beginForkPublication };
}
function createForkSourceGroup(): ForkSourceGroup {
return {
publications: new Set(),
unclaimedChildren: new Map(),
sourceState: null,
restoredSource: null,
};
}
function applyChildEvent(child: PendingForkChild, event: ThreadOperationEvent): boolean {
switch (event.type) {
case "thread-upserted":
child.thread = event.thread;
return true;
case "thread-renamed":
child.thread = { ...child.thread, name: event.name };
return true;
case "thread-archived":
child.state = "archived";
return true;
case "thread-deleted":
child.state = "deleted";
return true;
case "thread-restored":
child.thread = event.thread;
child.state = "active";
return true;
case "thread-unarchived":
child.state = "active";
return true;
}
}
function applySourceEvent(group: ForkSourceGroup, event: ThreadOperationEvent): boolean {
switch (event.type) {
case "thread-archived":
group.sourceState = "archived";
return true;
case "thread-unarchived":
if (group.sourceState !== "archived") return false;
group.sourceState = "active";
return true;
case "thread-restored":
if (group.sourceState) group.sourceState = "active";
group.restoredSource = event.thread;
return true;
default:
return false;
}
}
function completedForkChildEvents(publication: PendingForkPublication): ThreadLifecycleEvent[] {
return publication.child ? completedChildEvents(publication.child) : [];
}
function completedChildEvents(child: PendingForkChild): ThreadLifecycleEvent[] {
const events: ThreadLifecycleEvent[] = [{ type: "thread-upserted", thread: child.thread }];
if (child.state === "archived") events.push({ type: "thread-archived", threadId: child.thread.id });
if (child.state === "deleted") events.push({ type: "thread-deleted", threadId: child.thread.id });
return events;
}
function completedSourceEvents(group: ForkSourceGroup, sourceThreadId: string): ThreadLifecycleEvent[] {
if (group.sourceState === "archived") return [{ type: "thread-archived", threadId: sourceThreadId }];
if (group.restoredSource) return [{ type: "thread-restored", thread: group.restoredSource }];
return [];
}
function lifecycleEvent(event: ThreadOperationEvent): ThreadLifecycleEvent {
if (event.type === "thread-upserted") return { type: "thread-upserted", thread: event.thread };
return event;
}
function threadIdForEvent(event: ThreadOperationEvent): string {
return event.type === "thread-upserted" || event.type === "thread-restored" ? event.thread.id : event.threadId;
}

Some files were not shown because too many files have changed in this diff Show more