Compare commits

...

31 commits
5.1.1 ... main

Author SHA1 Message Date
murashit
8dc4d82c26 chore(release): 5.2.1 2026-07-22 14:42:14 +09:00
murashit
811a4b7158 fix(threads): publish fork activity with list replacement 2026-07-22 13:23:18 +09:00
murashit
05ea87643a refactor(threads): coordinate fork publication atomically 2026-07-22 12:31:16 +09:00
murashit
bb673b2a75 refactor(chat): replace rollback RPC with marker fork 2026-07-22 11:10:22 +09:00
murashit
01a20dcf70 feat(chat): honor app-server direct input capability 2026-07-22 09:47:18 +09:00
murashit
73be2beea7 chore(app-server): update bindings for Codex CLI 0.145.0 2026-07-22 09:09:31 +09:00
murashit
14adac592d chore(deps): apply npm audit dependency updates 2026-07-21 21:07:10 +09:00
murashit
f4616cf945 refactor: consolidate thin module ownership 2026-07-21 20:59:09 +09:00
murashit
67289765c6 refactor(selection-rewrite): consolidate transport output 2026-07-21 20:59:09 +09:00
murashit
ab0a9047f4 refactor(diff): replace custom LCS with jsdiff 2026-07-21 20:58:23 +09:00
murashit
488344a74d refactor(markdown): derive code ranges from mdast 2026-07-21 20:57:59 +09:00
murashit
6bdd9e5972 refactor(archive): consolidate export ownership 2026-07-21 20:57:49 +09:00
murashit
5b6fce1a4d fix(archive): normalize markdown links with mdast 2026-07-21 19:40:52 +09:00
murashit
05f9740e8e chore(release): 5.2.0 2026-07-21 17:05:36 +09:00
murashit
6b2a20cf0d perf(chat): resume before shared resource hydration 2026-07-21 16:58:21 +09:00
murashit
d0965d19b1 feat(context): replace metadata envelopes with thread links 2026-07-21 16:39:27 +09:00
murashit
381e2a5467 chore(release): 5.1.2 2026-07-21 14:40:40 +09:00
murashit
03305a47ef fix(context): hide normalized resume manifests 2026-07-21 14:35:33 +09:00
murashit
d74747fbf0 fix(threads): serialize rename saves 2026-07-21 14:33:04 +09:00
murashit
f707da0274 fix(threads): keep errors out of navigation rows 2026-07-21 14:25:44 +09:00
murashit
2fb7d29841 fix(threads): disable unavailable auto-name actions 2026-07-21 14:25:44 +09:00
murashit
8a798a5bca fix(ui): disable operations with unmet preconditions 2026-07-21 14:25:44 +09:00
murashit
689368fe8d docs(design): prefer coherent UI over explanatory copy 2026-07-21 13:19:03 +09:00
murashit
5cd106973c refactor(threads): make auto-name exclusive and cancellable 2026-07-21 10:43:01 +09:00
murashit
f3d987ee03 refactor(settings): serialize dynamic refreshes 2026-07-21 10:26:57 +09:00
murashit
bbe311c4de refactor(chat): flatten session runtime indirection 2026-07-21 10:09:47 +09:00
murashit
4f3ebcaa31 docs: remove defensive reference caveats 2026-07-21 08:29:05 +09:00
murashit
96b88c1715 test: remove obsolete specification guards 2026-07-21 07:11:27 +09:00
murashit
193b680f06 refactor(runtime): remove obsolete lifetime and query abstractions 2026-07-20 23:47:49 +09:00
murashit
2d9f98f434 refactor(query): consolidate runtime-owned resources 2026-07-20 23:35:21 +09:00
murashit
ffcc814ba5 refactor(runtime): simplify disposable runtime boundaries 2026-07-20 23:33:43 +09:00
259 changed files with 5834 additions and 2971 deletions

6
.github/release-notes/5.1.2.md vendored Normal file
View file

@ -0,0 +1,6 @@
## 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.

4
.github/release-notes/5.2.0.md vendored Normal file
View file

@ -0,0 +1,4 @@
## 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.

5
.github/release-notes/5.2.1.md vendored Normal file
View file

@ -0,0 +1,5 @@
## 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. The plugin handles the Obsidian side of the workflow, while models, credentials, sandboxing, tools, hooks, and thread state stay with Codex.
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup.
![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>`.
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.
### Bring in the right context
@ -56,9 +56,6 @@ 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.
@ -77,7 +74,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.144.5` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
| `codexAppServer.testedCliVersion` | `0.145.0` | 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,7 +14,9 @@ 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, 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-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 settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it.
@ -54,6 +56,8 @@ 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.
@ -68,7 +72,9 @@ 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.
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.
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.
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.1.1",
"version": "5.2.1",
"minAppVersion": "1.12.0",
"description": "Codex in your sidebar.",
"author": "murashit",

430
package-lock.json generated
View file

@ -1,19 +1,23 @@
{
"name": "codex-panel",
"version": "5.1.1",
"version": "5.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-panel",
"version": "5.1.1",
"version": "5.2.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"
"preact": "^10.29.7",
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@biomejs/biome": "^2.5.3",
@ -21,9 +25,10 @@
"@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": "^10.0.3",
"concurrently": "^9.2.4",
"conventional-changelog-conventionalcommits": "^10.2.1",
"conventional-commits-parser": "^7.1.0",
"esbuild": "^0.28.0",
@ -1927,9 +1932,9 @@
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3894,6 +3899,15 @@
"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",
@ -3919,6 +3933,12 @@
"@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",
@ -4661,9 +4681,9 @@
"optional": true
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4912,56 +4932,131 @@
"license": "MIT"
},
"node_modules/concurrently": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
"integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "5.6.2",
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.4",
"supports-color": "10.2.2",
"shell-quote": "1.9.0",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "18.0.0"
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/index.js",
"concurrently": "dist/bin/index.js"
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=22"
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"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==",
"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==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
"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"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
"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"
}
},
"node_modules/concurrently/node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=18"
"node": ">=10"
},
"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",
@ -5349,6 +5444,15 @@
"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",
@ -6000,9 +6104,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6066,9 +6170,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-n/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6290,9 +6394,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-obsidianmd/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -7616,6 +7720,16 @@
"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",
@ -7986,9 +8100,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
@ -8608,6 +8722,16 @@
"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",
@ -8689,6 +8813,78 @@
"@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",
@ -8971,6 +9167,28 @@
"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",
@ -9946,6 +10164,16 @@
"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",
@ -10256,9 +10484,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"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==",
"dev": true,
"license": "MIT",
"engines": {
@ -10425,6 +10653,51 @@
"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",
@ -11071,6 +11344,61 @@
"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",
@ -11681,6 +12009,16 @@
"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.1.1",
"version": "5.2.1",
"description": "Codex in your Obsidian sidebar.",
"main": "main.js",
"author": "murashit",
@ -46,9 +46,10 @@
"@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": "^10.0.3",
"concurrently": "^9.2.4",
"conventional-changelog-conventionalcommits": "^10.2.1",
"conventional-commits-parser": "^7.1.0",
"esbuild": "^0.28.0",
@ -65,8 +66,12 @@
"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"
"preact": "^10.29.7",
"unist-util-visit": "^5.1.0"
}
}

View file

@ -30,7 +30,6 @@ 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";
@ -87,7 +86,6 @@ 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.144.5",
"testedCliVersion": "0.145.0",
"typeGeneration": {
"arguments": ["app-server", "generate-ts", "--experimental"]
},

View file

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

View file

@ -1,29 +1,21 @@
import type { ReferencedThreadMetadata } from "../threads/reference";
import { utf8ByteLength } from "./context-budget";
import type { VaultFileReference } from "./input";
import { isPanelSubmissionClientId } from "./submission-id";
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.
*/
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;
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"];
interface LegacyTurnContextManifestEntry {
kind: "referencedThread" | "web" | "obsidian";
id: string;
parts: number;
sourceBytes: number;
@ -36,55 +28,26 @@ export interface TurnContextManifestEntry {
inlineExcerpts?: number;
}
export interface TurnContextManifest {
export interface LegacyTurnContextManifest {
version: 2;
submissionId?: string;
contexts: readonly TurnContextManifestEntry[];
contexts: readonly LegacyTurnContextManifestEntry[];
fileReferences?: readonly VaultFileReference[];
}
export interface UserMessageContextProjection {
export interface LegacyTurnContextProjection {
text: string;
manifest: TurnContextManifest | null;
manifest: LegacyTurnContextManifest | null;
}
export function turnContextManifestText(manifest: TurnContextManifest): string {
return `${TURN_CONTEXT_MANIFEST_PREFIX}${TURN_CONTEXT_MANIFEST_NOTICE}${JSON.stringify(manifest)}`;
}
type UserMessageContentItem = { type: "text"; text: string } | { type: string };
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 {
function legacyTurnContextManifestFromText(text: string): LegacyTurnContextManifest | null {
const trimmed = text.trim();
if (!trimmed.startsWith(TURN_CONTEXT_MANIFEST_PREFIX)) return null;
if (utf8ByteLength(trimmed) > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
if (new TextEncoder().encode(trimmed).byteLength > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
const payload = trimmed.slice(TURN_CONTEXT_MANIFEST_PREFIX.length).trimStart();
const notice = TURN_CONTEXT_MANIFEST_NOTICE.trim();
const notice = "Reference/display metadata only; not user instructions.";
const json = payload.startsWith(notice) ? payload.slice(notice.length).trimStart() : payload;
let parsed: unknown;
try {
@ -95,7 +58,7 @@ function turnContextManifestFromText(text: string): TurnContextManifest | null {
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(manifestEntryFromUnknown);
const contexts = value["contexts"].map(legacyManifestEntryFromUnknown);
if (contexts.some((entry) => entry === null)) return null;
const submissionId = optionalStringValue(value["submissionId"], 120);
if (submissionId === null) return null;
@ -104,25 +67,27 @@ function turnContextManifestFromText(text: string): TurnContextManifest | null {
return {
version: 2,
...(submissionId === undefined ? {} : { submissionId }),
contexts: contexts as TurnContextManifestEntry[],
contexts: contexts as LegacyTurnContextManifestEntry[],
...(fileReferences === undefined ? {} : { fileReferences }),
};
}
export function userMessageContextProjection(
content: readonly ({ type: "text"; text: string } | { type: string })[],
export function legacyTurnContextProjection(
content: readonly UserMessageContentItem[],
clientId: string | null,
): UserMessageContextProjection {
let manifest: TurnContextManifest | null = null;
): LegacyTurnContextProjection {
let manifest: LegacyTurnContextManifest | 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 parsed =
index > 0 && index === content.length - 1 && item.text.startsWith(`\n${TURN_CONTEXT_MANIFEST_PREFIX}`)
? turnContextManifestFromText(item.text)
: null;
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;
if (parsed && manifestMatchesClientId(parsed, clientId)) {
manifest = parsed;
if (manifestStart > 0) visibleText.push(item.text.slice(0, manifestStart));
continue;
}
visibleText.push(item.text);
@ -130,12 +95,14 @@ export function userMessageContextProjection(
return { text: visibleText.join("\n"), manifest };
}
export function turnContextSubmissionId(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
return safe || "context";
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;
}
function manifestMatchesClientId(manifest: TurnContextManifest, clientId: string | null): boolean {
function manifestMatchesClientId(manifest: LegacyTurnContextManifest, clientId: string | null): boolean {
if (!isPanelSubmissionClientId(clientId)) return false;
const submissionId = turnContextSubmissionId(clientId);
if (manifest.submissionId !== undefined && manifest.submissionId !== submissionId) return false;
@ -151,7 +118,7 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function referencedThreadFromManifest(manifest: TurnContextManifest | null): ReferencedThreadMetadata | null {
export function referencedThreadFromLegacyManifest(manifest: LegacyTurnContextManifest | 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;
@ -166,11 +133,11 @@ export function referencedThreadFromManifest(manifest: TurnContextManifest | nul
};
}
export function fileReferencesFromManifest(manifest: TurnContextManifest | null): VaultFileReference[] {
export function fileReferencesFromLegacyManifest(manifest: LegacyTurnContextManifest | null): VaultFileReference[] {
return manifest?.fileReferences ? [...manifest.fileReferences] : [];
}
function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | null {
function legacyManifestEntryFromUnknown(input: unknown): LegacyTurnContextManifestEntry | null {
if (!input || typeof input !== "object") return null;
const value = input as Record<string, unknown>;
const kind = contextKind(value["kind"]);
@ -214,7 +181,7 @@ function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | nu
};
}
function contextKind(value: unknown): TurnContextAttachment["kind"] | null {
function contextKind(value: unknown): LegacyTurnContextManifestEntry["kind"] | null {
return value === "referencedThread" || value === "web" || value === "obsidian" ? value : null;
}

View file

@ -1,12 +1,6 @@
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";
import { splitUtf8Context } from "../../domain/chat/context-budget";
import type { CodexInputItem } from "../../domain/chat/input";
import { turnContextSubmissionId } from "../../domain/chat/submission-id";
type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original";
@ -29,20 +23,12 @@ export interface AppServerTurnInput {
const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800;
const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
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 toAppServerUserInput(input: readonly CodexInputItem[]): AppServerUserInput[] {
return input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
}
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),
@ -53,7 +39,6 @@ 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) => {
@ -73,13 +58,9 @@ 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, persistedManifest),
input: toAppServerUserInput(input),
...(Object.keys(additionalContext).length > 0 ? { additionalContext } : {}),
};
}
@ -115,36 +96,6 @@ 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,6 +21,7 @@ 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,4 +1,3 @@
import { fileReferencesFromManifest, referencedThreadFromManifest, userMessageContextProjection } from "../../domain/chat/context-manifest";
import type { VaultFileReference } from "../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
import {
@ -10,6 +9,11 @@ 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;
@ -53,11 +57,17 @@ export interface TurnUserItemProjection {
text: string;
referencedThread: ReferencedThreadMetadata | null;
fileReferences: VaultFileReference[];
manifest: ReturnType<typeof userMessageContextProjection>["manifest"];
contexts: TurnUserContextMetadata[];
}
interface TurnUserContextMetadata {
kind: "web" | "obsidian";
truncated: boolean;
inlineExcerpts?: number;
}
export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMessage" }>): TurnUserItemProjection {
const projected = userMessageContextProjection(item.content, item.clientId);
const projected = legacyTurnContextProjection(item.content, item.clientId);
if (!projected.manifest) {
const legacy = legacyPanelUserMessageProjection({
content: item.content,
@ -68,16 +78,16 @@ export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMess
text: [legacy.text, supplementalText].filter(Boolean).join("\n"),
referencedThread: legacy.referencedThread,
fileReferences: legacy.fileReferences,
manifest: null,
contexts: [],
};
}
const supplementalText = nonTextUserInputText(item.content, projected.text);
const text = [projected.text, supplementalText].filter(Boolean).join("\n");
return {
text,
referencedThread: referencedThreadFromManifest(projected.manifest),
fileReferences: fileReferencesFromManifest(projected.manifest),
manifest: projected.manifest,
referencedThread: referencedThreadFromLegacyManifest(projected.manifest),
fileReferences: fileReferencesFromLegacyManifest(projected.manifest),
contexts: legacyContextMetadata(projected.manifest.contexts),
};
}
@ -96,10 +106,7 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
if (item.type === "userMessage") {
const projection = turnUserItemProjection(item);
const text = projection.text.trim();
const contexts =
projection.manifest?.contexts
.filter((context) => context.kind === "web" || context.kind === "obsidian")
.map((context) => ({ kind: context.kind as "web" | "obsidian", truncated: context.truncated })) ?? [];
const contexts = projection.contexts.map((context) => ({ kind: context.kind, truncated: context.truncated }));
return text
? [
{
@ -123,6 +130,21 @@ 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,8 +20,10 @@ 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 { AppServerClientAccessOptions } from "../connection/client-access";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../connection/client-access";
import type { AppServerExecutionContext } from "../connection/execution-context";
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
import { listModelMetadata } from "../services/catalog";
import { readEffectiveConfig } from "../services/runtime-metadata";
@ -34,27 +36,20 @@ 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;
export interface AppServerQueryClientRunner {
runWithClient<T>(operation: (client: AppServerClient) => Promise<T>, options?: AppServerClientAccessOptions): Promise<T>;
}
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;
interface AppServerQueryOptions<T> {
readonly queryKey: readonly unknown[];
@ -62,7 +57,7 @@ interface AppServerQueryOptions<T> {
readonly staleTime?: number;
}
type ActiveThreadsQueryKey = ReturnType<typeof activeThreadsQueryKey>;
type ActiveThreadsQueryKey = typeof ACTIVE_THREADS_QUERY_KEY;
interface MetadataResourceSnapshot<T> {
readonly value: T;
@ -73,33 +68,36 @@ type MetadataResourceKind = "skills" | "permissionProfiles" | "rateLimits";
type MetadataResourceValue = readonly SkillMetadata[] | readonly RuntimePermissionProfileSummary[] | RateLimitSnapshot | null;
export class AppServerQueryCache {
private readonly context: Readonly<AppServerQueryContext>;
private readonly context: Readonly<AppServerExecutionContext>;
private readonly client: QueryClient;
private readonly clientRunner: AppServerQueryClientRunner;
private readonly clientAccess: AppServerClientAccess;
private readonly observerUnsubscribes = new Set<() => void>();
private disposed = false;
constructor(context: AppServerQueryContext, options: { client?: QueryClient; clientRunner: AppServerQueryClientRunner }) {
constructor(context: AppServerExecutionContext, clientAccess: AppServerClientAccess) {
this.context = Object.freeze({ ...context });
this.client = options.client ?? createAppServerQueryClient();
this.clientRunner = options.clientRunner;
this.client = createAppServerQueryClient();
this.clientAccess = clientAccess;
}
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>(activeThreadsQueryKey(this.context));
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
const threads = activeThreadsFromData(data);
return threads ? cloneThreads(threads) : null;
}
recentActiveThreadsSnapshot(): readonly Thread[] | null {
if (this.disposed) return null;
const data = this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey(this.context));
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
const threads = recentActiveThreadsFromData(data);
return threads ? cloneThreads(threads) : null;
}
@ -119,14 +117,14 @@ export class AppServerQueryCache {
enabled: false,
});
const emit = (result: InfiniteQueryObserverResult<ActiveThreadData>): void => {
listener(this.projectObservedActiveThreadsResult(result));
if (!this.disposed) listener(this.projectObservedActiveThreadsResult(result));
};
const unsubscribe = observer.subscribe(emit);
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
return () => {
return this.trackObserver(() => {
unsubscribe();
observer.destroy();
};
});
}
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options: { emitCurrent?: boolean } = {}): () => void {
@ -134,79 +132,83 @@ export class AppServerQueryCache {
return this.observeQueryResult(this.archivedThreadsQueryOptions(), cloneThreads, listener, options);
}
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 });
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();
}
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(),
);
return this.readThroughQueryCancellation(
async () => {
const data = await this.client.fetchInfiniteQuery(this.activeThreadsQueryOptions());
return cloneThreads(activeThreadsFromData(data) ?? []);
},
() => this.activeThreadsSnapshot(),
);
});
}
async refreshActiveThreads(): Promise<readonly Thread[]> {
refreshActiveThreads(): Promise<readonly Thread[]> {
return this.fetchActiveThreads({ force: true });
}
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)));
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)));
});
}
hasMoreActiveThreads(): boolean {
if (this.disposed) return false;
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey(this.context)));
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY));
}
async loadMoreActiveThreads(): Promise<readonly Thread[]> {
this.assertUsable();
const current = this.activeThreadsSnapshot() ?? (await this.fetchActiveThreads());
const observer = new InfiniteQueryObserver(this.client, {
...this.activeThreadsQueryOptions(),
enabled: false,
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();
}
});
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 refreshArchivedThreads(): Promise<readonly Thread[]> {
refreshArchivedThreads(): Promise<readonly Thread[]> {
return this.fetchArchivedThreads({ force: true });
}
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())),
);
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())),
);
});
}
applyThreadListMutations(mutations: readonly ThreadListMutation[]): void {
@ -214,7 +216,7 @@ export class AppServerQueryCache {
if (mutations.length === 0) return;
const activeMutations = mutations.filter((mutation) => mutation.list === "active");
if (activeMutations.length > 0) {
const key = activeThreadsQueryKey(this.context);
const key = ACTIVE_THREADS_QUERY_KEY;
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 });
@ -222,7 +224,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 = activeThreadSearchInventoryQueryKey(this.context);
const searchKey = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
void this.client.cancelQueries({ queryKey: searchKey, exact: true });
void this.client.invalidateQueries({ queryKey: searchKey, refetchType: "none" });
@ -236,7 +238,7 @@ export class AppServerQueryCache {
const archivedMutations = mutations.filter((mutation) => mutation.list === "archived");
if (archivedMutations.length > 0) {
const key = archivedThreadsQueryKey(this.context);
const key = ARCHIVED_THREADS_QUERY_KEY;
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
void this.client.cancelQueries({ queryKey: key, exact: true });
const before = this.archivedThreadsSnapshot();
@ -255,13 +257,13 @@ export class AppServerQueryCache {
private threadListSnapshot(kind: ThreadListKind): readonly Thread[] | null {
if (kind === "active") return this.activeThreadsSnapshot();
const threads = this.client.getQueryData<readonly Thread[]>(archivedThreadsQueryKey(this.context));
const threads = this.client.getQueryData<readonly Thread[]>(ARCHIVED_THREADS_QUERY_KEY);
return threads ? cloneThreads(threads) : null;
}
appServerMetadataSnapshot(): SharedServerMetadata | null {
if (this.disposed) return null;
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(appServerRuntimeConfigQueryKey(this.context));
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(RUNTIME_CONFIG_QUERY_KEY);
if (!runtimeConfig) return null;
const skills = this.metadataResourceState("skills");
const permissionProfiles = this.metadataResourceState("permissionProfiles");
@ -284,9 +286,9 @@ export class AppServerQueryCache {
options: { emitCurrent?: boolean } = {},
): () => void {
this.assertUsable();
let disposed = false;
let unsubscribed = false;
const emit = (resource: SharedServerMetadataResource): void => {
if (!disposed) listener(cloneSharedServerMetadataResource(resource));
if (!this.disposed && !unsubscribed) listener(cloneSharedServerMetadataResource(resource));
};
const runtimeConfig = new QueryObserver(this.client, { ...this.runtimeConfigQueryOptions(), enabled: false });
@ -337,46 +339,54 @@ export class AppServerQueryCache {
emitPermissionProfiles(permissionProfiles.getCurrentResult(), true);
emitRateLimits(rateLimits.getCurrentResult(), true);
}
return () => {
disposed = true;
return this.trackObserver(() => {
unsubscribed = true;
for (const unsubscribe of unsubscribers) unsubscribe();
};
runtimeConfig.destroy();
models.destroy();
skills.destroy();
permissionProfiles.destroy();
rateLimits.destroy();
});
}
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;
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 refreshSkills(): Promise<void> {
this.assertUsable();
await this.refreshNotifiedMetadataResource("skills");
this.assertUsable();
refreshSkills(): Promise<void> {
return this.runWhileActive(async () => {
await this.refreshNotifiedMetadataResource("skills");
this.assertUsable();
});
}
async refreshRateLimits(): Promise<void> {
this.assertUsable();
await this.refreshNotifiedMetadataResource("rateLimits");
this.assertUsable();
refreshRateLimits(): Promise<void> {
return this.runWhileActive(async () => {
await this.refreshNotifiedMetadataResource("rateLimits");
this.assertUsable();
});
}
modelsSnapshot(): readonly ModelMetadata[] | null {
if (this.disposed) return null;
const models = this.client.getQueryData<readonly ModelMetadata[]>(appServerModelsQueryKey(this.context));
const models = this.client.getQueryData<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
return models ? cloneModelMetadata(models) : null;
}
@ -385,19 +395,20 @@ export class AppServerQueryCache {
return this.observeQueryResult(this.modelsQueryOptions(), cloneModelMetadata, listener, options);
}
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" });
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());
this.assertUsable();
}
const models = await this.client.fetchQuery(this.modelsQueryOptions());
this.assertUsable();
return cloneModelMetadata(models);
return cloneModelMetadata(models);
});
}
async refreshModels(): Promise<readonly ModelMetadata[]> {
refreshModels(): Promise<readonly ModelMetadata[]> {
return this.fetchModels({ force: true });
}
@ -409,7 +420,7 @@ export class AppServerQueryCache {
ActiveThreadCursor
> {
return {
queryKey: activeThreadsQueryKey(this.context),
queryKey: ACTIVE_THREADS_QUERY_KEY,
queryFn: async ({ pageParam, signal }) => {
signal.throwIfAborted();
const page = await this.runWithClient((client) =>
@ -426,7 +437,7 @@ export class AppServerQueryCache {
private archivedThreadsQueryOptions(): AppServerQueryOptions<readonly Thread[]> {
return {
queryKey: archivedThreadsQueryKey(this.context),
queryKey: ARCHIVED_THREADS_QUERY_KEY,
queryFn: ({ signal }: { signal: AbortSignal }) =>
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { archived: true, signal })).then(cloneThreads),
staleTime: Number.POSITIVE_INFINITY,
@ -435,7 +446,7 @@ export class AppServerQueryCache {
private runtimeConfigQueryOptions(): AppServerQueryOptions<RuntimeConfigSnapshot> {
return {
queryKey: appServerRuntimeConfigQueryKey(this.context),
queryKey: RUNTIME_CONFIG_QUERY_KEY,
queryFn: async (): Promise<RuntimeConfigSnapshot> =>
this.runWithClient(async (client) =>
runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, this.context.vaultPath)),
@ -445,7 +456,7 @@ export class AppServerQueryCache {
private skillsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly SkillMetadata[]>> {
return {
queryKey: appServerSkillsQueryKey(this.context),
queryKey: SKILLS_QUERY_KEY,
queryFn: async () =>
this.runWithClient(async (client) => successfulMetadataResource(await readSkillMetadataProbe(client, this.context.vaultPath))),
};
@ -453,7 +464,7 @@ export class AppServerQueryCache {
private permissionProfilesQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>> {
return {
queryKey: appServerPermissionProfilesQueryKey(this.context),
queryKey: PERMISSION_PROFILES_QUERY_KEY,
queryFn: async () =>
this.runWithClient(async (client) =>
successfulMetadataResource(await readPermissionProfileMetadataProbe(client, this.context.vaultPath)),
@ -463,7 +474,7 @@ export class AppServerQueryCache {
private rateLimitsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<RateLimitSnapshot | null>> {
return {
queryKey: appServerRateLimitsQueryKey(this.context),
queryKey: RATE_LIMITS_QUERY_KEY,
queryFn: async () => this.runWithClient(async (client) => successfulMetadataResource(await readRateLimitMetadataProbe(client))),
};
}
@ -495,7 +506,7 @@ export class AppServerQueryCache {
}
private async refreshNotifiedMetadataResource(resource: "skills" | "rateLimits"): Promise<void> {
const queryKey = resource === "skills" ? appServerSkillsQueryKey(this.context) : appServerRateLimitsQueryKey(this.context);
const queryKey = resource === "skills" ? SKILLS_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
await this.client.cancelQueries({ queryKey, exact: true });
this.assertUsable();
try {
@ -513,11 +524,7 @@ export class AppServerQueryCache {
private metadataResourceState(resource: "rateLimits"): { value: RateLimitSnapshot | null; probe: DiagnosticProbeResult };
private metadataResourceState(resource: MetadataResourceKind): { value: MetadataResourceValue; probe: DiagnosticProbeResult } {
const key =
resource === "skills"
? appServerSkillsQueryKey(this.context)
: resource === "permissionProfiles"
? appServerPermissionProfilesQueryKey(this.context)
: appServerRateLimitsQueryKey(this.context);
resource === "skills" ? SKILLS_QUERY_KEY : resource === "permissionProfiles" ? PERMISSION_PROFILES_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
const state = this.client.getQueryState<MetadataResourceSnapshot<MetadataResourceValue>>(key);
const failedProbe = diagnosticProbeFromError(state?.error);
return {
@ -527,7 +534,7 @@ export class AppServerQueryCache {
}
private modelsProbe(): DiagnosticProbeResult {
const state = this.client.getQueryState<readonly ModelMetadata[]>(appServerModelsQueryKey(this.context));
const state = this.client.getQueryState<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
return (
diagnosticProbeFromError(state?.error) ??
(state?.data
@ -538,7 +545,7 @@ export class AppServerQueryCache {
private modelsQueryOptions(): AppServerQueryOptions<readonly ModelMetadata[]> {
return {
queryKey: appServerModelsQueryKey(this.context),
queryKey: MODELS_QUERY_KEY,
queryFn: async (): Promise<readonly ModelMetadata[]> => {
try {
return cloneModelMetadata(
@ -565,11 +572,14 @@ export class AppServerQueryCache {
enabled: false,
});
const emit = (result: QueryObserverResult<TQuery>): void => {
listener(this.projectObservedResult(result, project));
if (!this.disposed) listener(this.projectObservedResult(result, project));
};
const unsubscribe = observer.subscribe(emit);
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
return unsubscribe;
return this.trackObserver(() => {
unsubscribe();
observer.destroy();
});
}
private projectObservedResult<TQuery, TValue>(
@ -598,7 +608,7 @@ export class AppServerQueryCache {
private runWithClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
this.assertUsable();
return this.clientRunner.runWithClient(operation, options);
return this.clientAccess.withClient(operation, options);
}
private async readThroughQueryCancellation<T>(read: () => Promise<T>, fallback?: () => T | null): Promise<T> {
@ -625,7 +635,33 @@ export class AppServerQueryCache {
}
private assertUsable(): void {
if (this.disposed) throw new Error("Codex app-server query cache was disposed.");
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;
}
}

View file

@ -1,50 +0,0 @@
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

@ -1,166 +0,0 @@
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,7 +6,6 @@ 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";
@ -187,44 +186,61 @@ export async function readReferencedThreadTurnTranscriptSummaries(
return chronologicalTurnTranscriptSummariesFromTurnRecords(response.data);
}
export interface ThreadRollbackSnapshot {
thread: Thread;
turns: readonly HistoricalTurn[];
}
type ThreadForkPosition =
| { readonly kind: "through-turn"; readonly turnId: string }
| { readonly kind: "before-turn"; readonly turnId: string };
interface ThreadRollbackResult {
readonly thread: ThreadRecord & {
readonly 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"];
};
}
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,
lastTurnId: string | null = null,
options: AppServerForkThreadOptions = {},
): 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,
...(lastTurnId ? { lastTurnId } : {}),
...(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 } } : {}),
});
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

@ -14,7 +14,6 @@ export interface RequestAdditionalContext {
key: string;
value: string;
kind: "untrusted" | "application";
attachment?: TurnContextAttachment;
}
export type CodexInputItem =
@ -28,7 +27,6 @@ export type CodexInputItem =
key: string;
value: string;
kind: RequestAdditionalContext["kind"];
attachment?: TurnContextAttachment;
};
export type CodexInput = CodexInputItem[];
@ -53,7 +51,6 @@ export function codexTextInputWithReferences(
key: context.key,
value: context.value,
kind: context.kind,
...(context.attachment ? { attachment: context.attachment } : {}),
})),
];
}
@ -61,5 +58,3 @@ 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,3 +3,8 @@ 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

@ -0,0 +1,19 @@
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

@ -1,7 +0,0 @@
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,20 +1,21 @@
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../markdown/frontmatter";
import { isExternalFileHref, parseFileHref } from "../vault/file-hrefs";
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 { isFilesystemAbsolutePath, isVaultConfigPath, normalizeFilePath, vaultRelativePath } from "../vault/paths";
import type { Thread } from "./model";
import { threadArchiveTitle } from "./title";
import type { ThreadTranscriptEntry } from "./transcript";
interface ParsedMarkdownLink {
raw: string;
label: string;
href: string;
interface MarkdownSourceReplacement {
start: number;
end: number;
value: string;
}
export interface ArchiveExportSettings {
archiveExportFolderTemplate: string;
archiveExportFilenameTemplate: string;
export interface ArchiveMarkdownOptions {
archiveExportTags?: string;
vaultPath?: string;
vaultConfigDir?: string;
@ -24,56 +25,31 @@ export interface ArchiveThreadInput extends Thread {
transcriptEntries: readonly ThreadTranscriptEntry[];
}
export function archivedThreadMarkdown(
thread: ArchiveThreadInput,
exportedAt = new Date(),
settings?: Partial<ArchiveExportSettings>,
): string {
export function archivedThreadMarkdown(thread: ArchiveThreadInput, exportedAt = new Date(), settings: ArchiveMarkdownOptions = {}): string {
const title = threadArchiveTitle(thread);
const tags = normalizedArchiveTags(settings?.archiveExportTags ?? "");
const lines = [
const tags = normalizedArchiveTags(settings.archiveExportTags ?? "");
const frontmatter = [
"---",
`title: ${yamlFrontmatterString(title)}`,
`thread_id: ${yamlFrontmatterString(thread.id)}`,
`created: ${yamlFrontmatterString(formatDate(exportedAt))}`,
...frontmatterTagsLines(tags),
"---",
"",
`# ${title}`,
"",
...transcriptMarkdownLines(thread.transcriptEntries),
];
const markdown = `${trimTrailingBlankLines(lines).join("\n")}\n`;
return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath, settings.vaultConfigDir) : markdown;
].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}`;
}
function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
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;
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)}`;
}
return output;
}
@ -163,67 +139,45 @@ function stripMatchingQuotes(value: string): string {
return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value;
}
function unwrappedMarkdownHref(value: string): string {
if (value.startsWith("<") && value.endsWith(">")) return value.slice(1, -1);
return 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 parseMarkdownLinkAt(line: string, start: number): ParsedMarkdownLink | null {
if (line[start] !== "[" || line[start - 1] === "!") return null;
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}` }),
};
}
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;
if (!isFilesystemAbsolutePath(normalizeFilePath(parsed.path))) return null;
return {
raw: line.slice(start, end),
label: line.slice(start + 1, labelEnd),
href: line.slice(hrefStart, hrefEnd + (line[hrefStart] === "<" ? 1 : 0)),
start,
end,
value: markdownNodeSource({
type: "paragraph",
children: [...link.children, { type: "text", value: " (" }, { type: "inlineCode", value: link.url }, { type: "text", value: ")" }],
}),
};
}
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 markdownNodeSource(node: Nodes): string {
return toMarkdown(node, { unsafe: [{ character: "|", inConstruct: ["label", "phrasing"] }] }).trimEnd();
}
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())}`;
}
@ -237,3 +191,11 @@ 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

@ -0,0 +1,29 @@
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,6 +6,11 @@ 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,6 +1,5 @@
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;
@ -14,27 +13,12 @@ export interface ReferencedThreadMetadata {
truncated?: boolean;
}
export interface ReferencedThreadContextBundle {
value: string;
referencedThread: ReferencedThreadMetadata;
}
const REFERENCED_THREAD_CONTEXT_MAX_BYTES = 18_000;
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 {
export function referencedThreadContext(thread: Thread, turns: readonly TurnTranscriptSummary[]): string {
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;
@ -43,7 +27,6 @@ export function referencedThreadContextBundle(thread: Thread, turns: readonly Tu
if (included.length === 0) {
const turn = turns[index];
if (turn) included.unshift(truncatedReferenceTurn(turn, index + 1, REFERENCED_THREAD_CONTEXT_MAX_BYTES));
truncatedTurn = true;
}
break;
}
@ -51,22 +34,14 @@ export function referencedThreadContextBundle(thread: Thread, turns: readonly Tu
bytes += nextBytes;
}
const omittedTurns = turns.length - included.length;
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,
};
return [
"Referenced thread context for the current user input:",
`Thread: ${thread.id}`,
`Included turns: ${String(included.length)}`,
`Omitted turns: ${String(omittedTurns)}`,
"",
...included,
].join("\n");
}
function renderedReferenceTurn(turn: TurnTranscriptSummary, index: number): string {

View file

@ -1,15 +1,25 @@
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 }
| { kind: "generating"; draft: string; originalDraft: string; generationToken: number };
| { 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 };
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 }
@ -25,25 +35,40 @@ export function transitionThreadRenameLifecycleState(
): ThreadRenameLifecycleState {
switch (event.type) {
case "started":
return { kind: "editing", draft: event.draft };
if (state.kind === "saving") return state;
return { kind: "editing", draft: event.draft, autoName: { kind: "checking" } };
case "draft-updated":
return state.kind === "idle" ? state : { ...state, draft: event.draft };
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" },
};
case "cancelled":
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
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;
case "generation-started":
if (state.kind !== "editing") return state;
if (state.kind !== "editing" || state.autoName.kind !== "ready") return state;
return {
kind: "generating",
draft: state.draft,
originalDraft: state.draft,
autoName: state.autoName,
generationToken: event.generationToken,
};
case "generation-succeeded":
if (!threadRenameGenerationStillActive(state, event.generationToken) || state.draft !== state.originalDraft) return state;
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
return { ...state, draft: event.draft };
case "generation-finished":
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
return { kind: "editing", draft: state.draft };
return { kind: "editing", draft: state.draft, autoName: state.autoName };
case "cleared":
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
default:
@ -58,6 +83,10 @@ 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,9 +6,6 @@ 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,6 +5,7 @@ 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]) {
@ -52,6 +53,10 @@ function normalizeThreadTitleText(value: string | null | undefined): string {
}
function truncateThreadDisplayTitle(title: string, maxLength: number): string {
if (title.length <= maxLength) return title;
return `${title.slice(0, maxLength - 3).trimEnd()}...`;
const graphemes = Array.from(GRAPHEME_SEGMENTER.segment(title), ({ segment }) => segment);
if (graphemes.length <= maxLength) return title;
return `${graphemes
.slice(0, maxLength - 3)
.join("")
.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 type { AppServerQueryContext } from "./app-server/query/keys";
import { AppServerResourceStore, StaleAppServerResourceContextError } from "./app-server/query/resource-store";
import { AppServerQueryCache } from "./app-server/query/cache";
import {
type EphemeralStructuredTurnClient,
type EphemeralStructuredTurnRunner,
@ -16,22 +16,28 @@ 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 { createThreadCatalog, type ThreadCatalog, type ThreadCatalogEvent } from "./features/threads/catalog/thread-catalog";
import { createThreadNameMutationCoordinator } from "./features/threads/workflows/thread-name-mutation-coordinator";
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 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: AppServerQueryContext;
context: AppServerExecutionContext;
settings: () => CodexPanelSettings;
workspace: WorkspacePanels;
onThreadCatalogEvent(event: ThreadCatalogEvent): void;
onThreadLifecycleEvents(events: readonly ThreadLifecycleEvent[]): void;
openNewPanel(): Promise<unknown>;
openThreadInCurrentView(threadId: string): Promise<void>;
openThreadInAvailableView(threadId: string): Promise<void>;
@ -44,11 +50,12 @@ export interface ExecutionRuntimeViews {
}
export class CodexExecutionRuntime implements AppServerClientAccess {
readonly context: Readonly<AppServerQueryContext>;
readonly resourceStore: AppServerResourceStore;
readonly threadCatalog: ThreadCatalog;
private readonly context: Readonly<AppServerExecutionContext>;
private readonly appServerQueries: AppServerQueryCache;
private readonly threadCatalog: ThreadCatalog;
private readonly threadOperationCoordinator: ThreadOperationCoordinator;
readonly settingsDynamicData: SettingsDynamicDataAccess;
private readonly threadNameMutations = createThreadNameMutationCoordinator();
private readonly threadNameMutations = createKeyedOperationQueue<string>();
private readonly threadGoalOperations = createThreadGoalOperationCoordinator();
private readonly runtimeSettingsCommitQueue = createKeyedOperationQueue<string>();
private readonly shortLivedClients = new Set<AppServerClient>();
@ -61,38 +68,31 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
constructor(private readonly options: CodexExecutionRuntimeOptions) {
this.context = Object.freeze({ ...options.context });
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.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.settingsDynamicData = createSettingsAppServerDynamicData({
vaultPath: this.context.vaultPath,
clientAccess: this,
appServerQueries: this.resourceStore,
appServerQueries: this.appServerQueries,
threadCatalog: this.threadCatalog,
threadEvents: this.threadOperationCoordinator,
});
}
chatHost(): CodexChatHost {
private chatHost(): CodexChatHost {
this.assertActive();
return {
appServerClientAccess: this,
appServerContext: this.context,
settingsRef: {
settings: this.chatSettings(),
vaultPath: this.context.vaultPath,
},
settings: this.chatSettings(),
workspace: this.options.workspace,
appServerQueries: this.resourceStore,
appServerQueries: this.appServerQueries,
threadCatalog: this.threadCatalog,
threadOperationCoordinator: this.threadOperationCoordinator,
threadNameMutations: this.threadNameMutations,
threadTitleTransport: this.threadTitleTransport(),
threadGoalOperations: this.threadGoalOperations,
@ -100,12 +100,13 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
};
}
threadsHost(): ThreadsViewHost {
private 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(),
@ -263,7 +264,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
});
this.shortLivedClients.clear();
this.tryCleanup(() => {
this.resourceStore.dispose();
this.appServerQueries.dispose();
});
return views;
}
@ -277,20 +278,25 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
this.assertActive();
return operation(client);
};
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;
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;
}
}
private structuredTurnRunner(): EphemeralStructuredTurnRunner {
@ -311,7 +317,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
created: (client) => {
if (this.disposed) {
client.disconnect();
throw new StaleAppServerResourceContextError();
throw new StaleExecutionRuntimeError();
}
this.structuredTurnClients.add(client);
},
@ -340,7 +346,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
}
private assertActive(): void {
if (this.disposed) throw new StaleAppServerResourceContextError();
if (this.disposed) throw new StaleExecutionRuntimeError();
}
private tryCleanup(operation: () => void): boolean {
@ -362,21 +368,15 @@ 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 { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
import type { ThreadOperationEvent } from "../../../threads/workflows/thread-operation-event";
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;
applyThreadCatalogEvent: (event: ThreadCatalogEvent) => void;
applyThreadOperationEvent: (event: ThreadOperationEvent) => 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-catalog-event":
context.actions.applyThreadCatalogEvent(effect.event);
case "apply-thread-operation-event":
context.actions.applyThreadOperationEvent(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 { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
import type { ThreadOperationEvent } from "../../../threads/workflows/thread-operation-event";
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-catalog-event"; event: ThreadCatalogEvent };
| { type: "apply-thread-operation-event"; event: ThreadOperationEvent };
type TurnRuntimeCompletedTurnTranscriptSummary = TurnRuntimeOutcome["completedTurnTranscriptSummary"];
@ -291,17 +291,23 @@ function planThreadLifecycle(
case "thread/started":
return threadStartedPlan(state, notification);
case "thread/archived":
return effectPlan({ type: "apply-thread-catalog-event", event: { type: "thread-archived", threadId: notification.params.threadId } });
return effectPlan({
type: "apply-thread-operation-event",
event: { type: "thread-archived", threadId: notification.params.threadId },
});
case "thread/deleted":
return effectPlan({ type: "apply-thread-catalog-event", event: { type: "thread-deleted", threadId: notification.params.threadId } });
return effectPlan({
type: "apply-thread-operation-event",
event: { type: "thread-deleted", threadId: notification.params.threadId },
});
case "thread/unarchived":
return effectPlan({
type: "apply-thread-catalog-event",
type: "apply-thread-operation-event",
event: { type: "thread-unarchived", threadId: notification.params.threadId },
});
case "thread/name/updated":
return effectPlan({
type: "apply-thread-catalog-event",
type: "apply-thread-operation-event",
event: {
type: "thread-renamed",
threadId: notification.params.threadId,
@ -333,8 +339,12 @@ function threadStartedPlan(
? []
: [
{
type: "apply-thread-catalog-event",
event: { type: "thread-upserted", thread },
type: "apply-thread-operation-event",
event: {
type: "thread-upserted",
thread,
forkedFromThreadId: notification.params.thread.forkedFromId,
},
},
];
return { actions: trackAction, effects };

View file

@ -105,6 +105,9 @@ 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",
@ -157,6 +160,7 @@ 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,
@ -200,6 +204,8 @@ 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,5 +1,4 @@
import { RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/execution-state";
import type { ExecutionState } from "../../../domain/thread-stream/items";
import { type ExecutionState, RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } 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 { contextAttachmentsFromManifest } from "../../../domain/thread-stream/format/context-attachments";
import { contextAttachmentsFromHistoryContexts } 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 = contextAttachmentsFromManifest(projection.manifest, text);
const contextAttachments = contextAttachmentsFromHistoryContexts(projection.contexts, text);
if (referencedThread) {
return {
...turnItemSourceFields(item, turnId),

View file

@ -1,9 +1,10 @@
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, referencedThreadContextBundle } from "../../../domain/threads/reference";
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadContext } from "../../../domain/threads/reference";
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
import type { ThreadReferenceInput } from "../application/turns/slash-command-execution";
@ -40,28 +41,20 @@ async function referencedThreadInput(
return null;
}
const messageInput = host.prepareInput(message, snapshot);
const reference = referencedThreadContextBundle(thread, turns);
const reference = referencedThreadContext(thread, turns);
const visibleText = `${threadReferenceMarkdown(thread)}\n\n${messageInput.text}`;
host.setStatus(`Referencing ${shortThreadId(thread.id)} (${String(turns.length)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`);
return {
text: messageInput.text,
input: codexTextInputWithAttachments(messageInput.text, [
text: visibleText,
input: codexTextInputWithAttachments(visibleText, [
{
type: "additionalContext",
key: "codex_panel_referenced_thread",
kind: "untrusted",
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,
},
value: reference,
},
...messageInput.input,
]),
referencedThread: reference.referencedThread,
};
} catch (error) {
if (host.currentClient() !== client) return null;

View file

@ -10,7 +10,6 @@ import {
readThreadGoal,
recordThreadGoalUserMessage,
resumeThread,
rollbackThread,
setThreadGoal,
startThread,
threadActivationSnapshotFromAppServerResponse,
@ -30,7 +29,7 @@ import type {
ThreadResumeSnapshot,
ThreadResumeTransport,
} from "../../application/threads/thread-loading-transport";
import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport";
import type { ThreadMutationTransport } 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";
@ -166,16 +165,8 @@ function createChatThreadMutationTransport(host: ChatAppServerTransportHost): Th
runCurrentChatAppServerEffect(host, async (client) => {
await compactThread(client, threadId);
}),
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),
};
}),
forkThread: (threadId, options) =>
runCurrentChatAppServerEffect(host, (client) => forkThread(client, threadId, host.vaultPath, options)),
};
}

View file

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

View file

@ -1,6 +1,28 @@
import type { VaultFileReference } from "../../../../domain/chat/input";
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
import type { NoteCandidate } from "./suggestions";
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;
}
export interface NoteCandidateProvider {
candidates(sourcePath: string): readonly NoteCandidate[];

View file

@ -19,7 +19,7 @@ import {
type SelectionContextReference,
selectionContextReferenceMarker,
} from "./context-references";
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
import type { DailyNoteReferenceCandidate, NoteCandidate, NoteHeadingCandidate } from "./note-context";
import { isSlashCommandName, SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands";
import {
partialThreadTitleQuery,
@ -51,22 +51,6 @@ 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,6 +8,7 @@ import {
type SkillReference,
type VaultFileReference,
} from "../../../../domain/chat/input";
import { type MarkdownCodeRange, markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../domain/markdown/code-ranges";
import {
type ActiveNoteContextReference,
type ComposerContextReferences,
@ -43,10 +44,11 @@ interface PreparedComposerInputOptions {
referenceActiveNoteOnSend: boolean;
}
function parsedWikiLinks(text: string): ParsedWikiLink[] {
function parsedWikiLinks(text: string, codeRanges: readonly MarkdownCodeRange[]): 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();
@ -67,14 +69,15 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
contextReferences: ComposerContextReferences,
options: PreparedComposerInputOptions,
): PreparedComposerInput {
const contextReplacement = textWithContextReferences(text, contextReferences);
const codeRanges = markdownReferenceCodeRanges(text);
const contextReplacement = textWithContextReferences(text, contextReferences, codeRanges);
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)) {
for (const link of parsedWikiLinks(resolvedText, codeRanges)) {
const fileReference = activeNoteFileReferenceForLink(link, activeNoteSnapshots) ?? resolveFileReference(link.target);
if (!fileReference || seenPaths.has(fileReference.path)) continue;
seenPaths.add(fileReference.path);
@ -94,7 +97,7 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
const skillByName = firstEnabledSkillByName(skills);
const resolvedSkills: SkillReference[] = [];
const seenSkillPaths = new Set<string>();
for (const reference of parsedSkillReferences(resolvedText)) {
for (const reference of parsedSkillReferences(resolvedText, codeRanges)) {
const skill = skillByName.get(reference.toLowerCase());
if (!skill || seenSkillPaths.has(skill.path)) continue;
seenSkillPaths.add(skill.path);
@ -120,19 +123,24 @@ 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 ?? []),
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? [], codeRanges),
};
}
function selectionsReferencedByText(text: string, snapshots: readonly SelectionContextReference[]): SelectionContextReference[] {
function selectionsReferencedByText(
text: string,
snapshots: readonly SelectionContextReference[],
codeRanges: readonly MarkdownCodeRange[],
): SelectionContextReference[] {
const selections: SelectionContextReference[] = [];
const seen = new Set<string>();
for (const snapshot of snapshots) {
const marker = selectionContextReferenceMarker(snapshot);
if (!text.includes(marker) || seen.has(marker)) continue;
if (!includesOutsideMarkdownCode(text, marker, codeRanges) || seen.has(marker)) continue;
seen.add(marker);
selections.push(snapshot);
}
@ -171,13 +179,11 @@ 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 } } : {}),
},
];
}
@ -202,10 +208,12 @@ function excerptLines(references: readonly (ObsidianReference & { excerpt: strin
});
}
function parsedSkillReferences(text: string): string[] {
function parsedSkillReferences(text: string, codeRanges: readonly MarkdownCodeRange[]): 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();
@ -216,6 +224,19 @@ function parsedSkillReferences(text: string): string[] {
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;
isStaleResourceContextError: (error: unknown) => boolean;
isStaleRuntimeError: (error: unknown) => boolean;
notifyConnectionFailed: () => void;
}
@ -53,6 +53,7 @@ function handleChatConnectionExit(host: ChatConnectionExitHost): void {
}
export interface ChatConnectionActions {
ensureInitialized(): Promise<void>;
ensureConnected(): Promise<void>;
invalidate(): void;
handleExit(): void;
@ -63,29 +64,40 @@ export interface ChatConnectionActions {
export function createChatConnectionActions(host: ChatConnectionActionsHost): ChatConnectionActions {
let generation = 0;
let activeConnection: { generation: number; promise: Promise<void> } | null = null;
let activeConnection: { generation: number; initialization: Promise<void>; hydration: 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.promise;
if (activeConnection) return activeConnection.hydration;
if (host.connection.isConnected()) return;
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;
}
}
await startConnection().hydration;
},
invalidate,
handleExit: () => {
@ -105,7 +117,7 @@ async function refreshActiveThreads(host: ChatConnectionActionsHost): Promise<vo
await host.refreshSharedThreads();
host.refreshTabHeader();
} catch (error) {
if (host.isStaleResourceContextError(error)) return;
if (host.isStaleRuntimeError(error)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
@ -143,22 +155,20 @@ async function initializeConnection(host: ChatConnectionActionsHost, isStale: ()
} catch (error) {
if (isStale()) return;
if (host.isStaleConnectionError(error)) return;
if (host.isStaleResourceContextError(error)) return;
if (host.isStaleRuntimeError(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.isStaleResourceContextError(error)) return;
if (isStale() || host.isStaleRuntimeError(error)) return;
host.addSystemMessage(`Could not refresh Codex metadata: ${errorMessage(error)}`);
}
if (isStale()) return;
@ -166,7 +176,7 @@ async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStal
try {
await host.refreshSharedThreads();
} catch (error) {
if (isStale() || host.isStaleResourceContextError(error)) return;
if (isStale() || host.isStaleRuntimeError(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>;
isStaleResourceContextError: (error: unknown) => boolean;
isStaleRuntimeError: (error: unknown) => boolean;
}
export interface ServerMetadataActions {
@ -106,7 +106,7 @@ async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promis
try {
await host.refreshAppServerMetadata();
} catch (error) {
if (host.isStaleResourceContextError(error)) return;
if (host.isStaleRuntimeError(error)) return;
throw error;
}
}
@ -115,7 +115,7 @@ async function refreshMetadataResource(host: ServerMetadataActionsHost, refresh:
try {
await refresh();
} catch (error) {
if (!host.isStaleResourceContextError(error)) throw error;
if (!host.isStaleRuntimeError(error)) throw error;
}
}

View file

@ -30,6 +30,7 @@ type ActivePanelThreadFacts =
| {
readonly phase: "active";
readonly lifetime: "persistent" | "ephemeral";
readonly canAcceptDirectInput: boolean | null;
readonly provenance: "interactive" | "subagent" | null;
};
@ -49,6 +50,13 @@ 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") {
@ -68,10 +76,15 @@ 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,6 +123,7 @@ export interface ChatActiveThreadState {
readonly goal: ThreadGoal | null;
readonly tokenUsage: ThreadTokenUsage | null;
readonly lifetime: ActiveThreadLifetime | null;
readonly canAcceptDirectInput: boolean | null;
readonly provenance: Thread["provenance"] | null;
}
@ -463,6 +464,7 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
goal: null,
tokenUsage: null,
lifetime: action.lifetime ?? { kind: "persistent" },
canAcceptDirectInput: action.thread.canAcceptDirectInput ?? null,
provenance: action.thread.provenance,
},
},
@ -900,6 +902,7 @@ function createActiveThreadState(id: string): ChatActiveThreadState {
goal: null,
tokenUsage: null,
lifetime: null,
canAcceptDirectInput: null,
provenance: null,
};
}

View file

@ -5,21 +5,28 @@ 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"
@ -71,7 +78,11 @@ 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 }
@ -99,7 +110,11 @@ 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":
@ -123,7 +138,11 @@ 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":
@ -172,6 +191,10 @@ 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, {
@ -253,11 +276,23 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string)
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
switch (action.type) {
case "ui/rename-started":
return { kind: "editing", threadId: action.threadId, draft: action.draft };
if (state.kind === "saving") return state;
return { kind: "editing", threadId: action.threadId, draft: action.draft, autoName: { kind: "checking" } };
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",
@ -304,12 +339,14 @@ function chatRenameLifecycleStateWithoutThreadId(state: ChatRenameUiState): Thre
function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState, { kind: "idle" }>): ThreadRenameActiveState {
switch (state.kind) {
case "editing":
return { kind: "editing", draft: state.draft };
return { kind: "editing", draft: state.draft, autoName: state.autoName };
case "saving":
return { kind: "saving", draft: state.draft, autoName: state.autoName, saveToken: state.saveToken };
case "generating":
return {
kind: "generating",
draft: state.draft,
originalDraft: state.originalDraft,
autoName: state.autoName,
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 } from "../state/ui-state";
import { type ChatRenameUiState, renameGenerationStillActive, renameSaveStillActive } from "../state/ui-state";
import { firstThreadTitleContextFromThreadStreamItems } from "./title-context";
interface RenameEditState {
@ -16,7 +16,8 @@ export interface ThreadRenameEditorActionsHost {
ensureConnected: () => Promise<void>;
addSystemMessage: (text: string) => void;
renameThread(threadId: string, value: string): Promise<boolean>;
generateThreadTitle(threadId: string): Promise<string>;
resolveThreadTitleContext(threadId: string): Promise<ThreadTitleContext | null>;
generateThreadTitle(context: ThreadTitleContext, signal?: AbortSignal): Promise<string | null>;
}
export interface ThreadRenameEditorActions {
@ -26,15 +27,22 @@ 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" });
},
@ -52,9 +60,13 @@ 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 {
@ -62,29 +74,38 @@ 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 === "idle" || current.threadId !== threadId || current.kind === "generating") return;
const editingState = current;
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;
try {
await host.ensureConnected();
if (renameState(host) !== editingState) return;
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
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" });
}
await host.renameThread(threadId, value);
dispatch(host, { type: "ui/rename-save-succeeded", threadId, saveToken });
} catch (error) {
if (renameState(host) !== editingState) return;
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
dispatch(host, { type: "ui/rename-save-failed", threadId, saveToken });
}
},
@ -92,34 +113,67 @@ 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;
if (!renameGenerationStillActive(renameState(host), threadId, generationToken)) return;
const generating = renameState(host);
if (!renameGenerationStillActive(generating, threadId, generationToken)) return;
nextRenameGenerationToken += 1;
const controller = new AbortController();
activeGeneration = { threadId, generationToken, controller };
try {
const title = await host.generateThreadTitle(threadId);
const title = await host.generateThreadTitle(generating.autoName.context, controller.signal);
if (!title) throw new Error("Codex did not return a usable thread title.");
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,9 +2,8 @@ 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 ChatAction, type ChatState } from "../state/root-reducer";
import { activeThreadId, 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";
@ -23,12 +22,23 @@ export interface ThreadManagementActionsHost {
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<void>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyActiveThreadIdentityChanged: () => void;
recordThread: (thread: Thread) => void;
openThreadInCurrentPanel: (threadId: string, onAdopted: () => void) => Promise<CurrentPanelAdoption>;
beginThreadForkPublication: (sourceThreadId: string) => ThreadForkPublication;
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>;
@ -133,16 +143,22 @@ 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;
const effect = turnId ? await host.threadTransport.forkThread(threadId, turnId) : await host.threadTransport.forkThread(threadId);
publication = host.beginThreadForkPublication(threadId);
const effect = turnId
? await host.threadTransport.forkThread(threadId, { position: { kind: "through-turn", turnId } })
: await host.threadTransport.forkThread(threadId);
if (!effectCompleted(effect)) return;
const forkedThread = effect.value;
const forkedThreadId = forkedThread.id;
host.recordThread(forkedThread);
publication.record(forkedThread);
if (!effectCompletedInCurrentContext(effect)) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
if (sourceName) {
@ -156,17 +172,32 @@ async function forkThreadFromTurn(
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
}
if (archiveSource) {
if (!(await archiveThreadFromPanel(host, threadId))) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
let adoption: CurrentPanelAdoption;
try {
await host.openThreadInCurrentPanel(forkedThreadId);
adoption = await host.openThreadInCurrentPanel(forkedThreadId, () => undefined);
} catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`);
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in the current panel: ${message}`);
return;
}
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) {
@ -177,6 +208,11 @@ 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();
}
}
}
@ -204,43 +240,99 @@ 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;
const effect = await host.threadTransport.rollbackThread(threadId);
if (!effectCompleted(effect)) return;
if (!effectCompletedInCurrentContext(effect)) return;
const snapshot = effect.value;
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
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,
publication = host.beginThreadForkPublication(threadId);
const effect = await host.threadTransport.forkThread(threadId, {
position: { kind: "before-turn", turnId: candidate.turnId },
deferGoalContinuation: true,
runtime: runtimeOverrides,
});
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);
if (!effectCompleted(effect)) return;
const forkedThread = effect.value;
if (effectCompletedInCurrentContext(effect)) publication.record(forkedThread);
else return;
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
const adoption = await host.openThreadInCurrentPanel(forkedThread.id, () => {
host.setComposerText(candidate.text);
});
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;
} 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;
@ -254,10 +346,6 @@ 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,15 +1,31 @@
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";
export interface ThreadRollbackSnapshot {
thread: Thread;
items: ThreadStreamItem[];
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 ThreadMutationTransport {
ensureConnected(): Promise<boolean>;
compactThread(threadId: string): Promise<EffectOutcome<void>>;
forkThread(threadId: string, lastTurnId?: string | null): Promise<EffectOutcome<Thread>>;
rollbackThread(threadId: string): Promise<EffectOutcome<ThreadRollbackSnapshot>>;
forkThread(threadId: string, options?: ThreadForkOptions): Promise<EffectOutcome<Thread>>;
}

View file

@ -77,12 +77,12 @@ export async function submitComposer(host: ComposerSubmitActionsHost): Promise<v
const state = submissionStateSnapshot(chatState);
if (host.stateStore.getState().pendingSubmission) return;
const operationDecision = activePanelOperationDecision(chatState, "submit");
if (operationDecision.kind === "blocked") {
host.status.addSystemMessage(operationDecision.message);
if (state.busy && state.activeThreadId && state.activeTurnId && (draft.length === 0 || operationDecision.kind === "blocked")) {
await interruptTurn(host, panelTarget);
return;
}
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
await interruptTurn(host, panelTarget);
if (operationDecision.kind === "blocked") {
host.status.addSystemMessage(operationDecision.message);
return;
}
await sendMessage(host, draft, originalDraft, panelTarget);
@ -133,7 +133,6 @@ 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,7 +15,6 @@ interface LocalUserDialogueParams {
text: string;
copyText?: string;
turnId?: string;
referencedThread?: ThreadStreamDialogueItem["referencedThread"];
referencedFiles?: readonly ThreadStreamFileReference[];
contextAttachments?: ThreadStreamDialogueItem["contextAttachments"];
}
@ -64,7 +63,6 @@ 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] } : {}),
};
@ -87,7 +85,6 @@ 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,7 +4,6 @@ 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";
@ -79,14 +78,12 @@ 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 {
@ -150,7 +147,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, referencedThread: reference.referencedThread };
return { sendText: reference.text, sendInput: reference.input };
}
case "web": {
const parsed = parseWebCommandArgs(args);

View file

@ -1,5 +1,4 @@
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";
@ -59,7 +58,6 @@ export interface TurnSubmissionRequest {
text: string;
inputSnapshot?: ComposerInputSnapshot;
codexInputOverride?: CodexInput;
referencedThread?: ReferencedThreadMetadata;
preserveComposerContextOnFailure?: boolean;
pendingSubmissionId?: string;
failureDraft?: string;
@ -85,7 +83,7 @@ async function sendTurnText(
localItemIds: LocalIdSource,
request: TurnSubmissionRequest,
): Promise<boolean> {
const { text, inputSnapshot, codexInputOverride, referencedThread } = request;
const { text, inputSnapshot, codexInputOverride } = request;
let panelTarget = capturePanelTargetLease(host.stateStore.getState());
const prepared = codexInputOverride
? { text, input: codexInputOverride }
@ -115,7 +113,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, referencedThread);
return await steerCurrentTurn(host, localItemIds, plan, text, prepared, request);
case "start-thread-then-turn":
if (!commitPendingRequest(host, request)) return false;
{
@ -163,7 +161,6 @@ async function sendTurnText(
...(request.pendingSubmissionId ? { clientId: clientUserMessageId } : {}),
text: prepared.text,
codexInput: prepared.input,
referencedThread,
});
host.stateStore.dispatch({
type: "turn/optimistic-started",
@ -267,7 +264,6 @@ 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;
@ -297,7 +293,6 @@ 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

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
type TextRange = [number, number];
import { markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../../domain/markdown/code-ranges";
interface UserMessageDisplayInputItem {
type: string;
name?: string;
@ -12,7 +13,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 isIndexInRanges(dollarIndex, codeRanges) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`;
return markdownCodeRangeContainsOffset(codeRanges, dollarIndex) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`;
});
}
@ -36,64 +37,6 @@ 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,6 +19,8 @@ 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.settingsRef.settings.attachmentFolder(),
attachmentFolder: () => environment.plugin.settings.attachmentFolder(),
}),
sourcePath: () => environment.obsidian.app.workspace.getActiveFile()?.path ?? "",
stateStore,
viewId: environment.obsidian.viewId,
referenceActiveNoteOnSend: () => environment.plugin.settingsRef.settings.referenceActiveNoteOnSend(),
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(),
scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges(),
referenceActiveNoteOnSend: () => environment.plugin.settings.referenceActiveNoteOnSend(),
sendShortcut: () => environment.plugin.settings.sendShortcut(),
scrollThreadFromComposerEdges: () => environment.plugin.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(),
isStaleResourceContextError: isStaleAppServerResourceContextError,
isStaleRuntimeError: isStaleExecutionRuntimeError,
});
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.refreshActive();
await environment.plugin.threadCatalog.refreshActiveThreads();
};
const inboundHandler = createChatInboundHandler(
stateStore,
@ -153,8 +153,8 @@ export function createConnectionBundle(
maybeNameThread: (threadId, turnId, completedTurnTranscriptSummary) => {
autoTitleCoordinator.maybeAutoTitleThread(threadId, turnId, completedTurnTranscriptSummary);
},
applyThreadCatalogEvent: (event) => {
environment.plugin.threadCatalog.apply(event);
applyThreadOperationEvent: (event) => {
environment.plugin.threadOperationCoordinator.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.settingsRef.settings.codexPath(),
configuredCommand: () => environment.plugin.appServerContext.codexPath,
isStaleConnectionError: (error) => error instanceof StaleConnectionError,
isStaleResourceContextError: isStaleAppServerResourceContextError,
isStaleRuntimeError: isStaleExecutionRuntimeError,
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.settingsRef.settings.codexPath(),
vaultPath: () => host.environment.plugin.settingsRef.vaultPath,
configuredCommand: () => host.environment.plugin.appServerContext.codexPath,
vaultPath: () => host.environment.plugin.appServerContext.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.loadMoreActive(),
loadMoreThreads: () => environment.plugin.threadCatalog.loadMoreActiveThreads(),
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.settingsRef.vaultPath,
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath(),
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(),
vaultPath: () => environment.plugin.appServerContext.vaultPath,
configuredCommand: () => environment.plugin.appServerContext.codexPath,
archiveExportEnabled: () => environment.plugin.settings.archiveExportEnabled(),
},
};
const goalSurface: ChatPanelGoalSurface = {
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(),
sendShortcut: () => environment.plugin.settings.sendShortcut(),
actions: goals,
};
const threadStreamContext = createChatThreadStreamSurfaceContext({
@ -103,12 +103,13 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
app: environment.obsidian.app,
owner: environment.obsidian.owner,
stateStore,
vaultPath: environment.plugin.settingsRef.vaultPath,
vaultPath: environment.plugin.appServerContext.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,7 +39,6 @@ export type ChatPanelThreadActions = ReturnType<typeof createThreadManagementAct
export type ChatPanelThreadNavigationActions = ReturnType<typeof createThreadNavigationActions>;
export interface ChatPanelThreadLifecycle {
history: HistoryController;
restoration: RestorationController;
resume: ResumeActions;
identity: ActiveThreadIdentitySync;
@ -58,6 +57,7 @@ interface ChatPanelThreadHost {
showLatest(): void;
};
getClosing: () => boolean;
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
}
interface ChatPanelThreadFoundationInput {
@ -80,19 +80,16 @@ interface ChatPanelThreadLifecycleInput {
appServer: ChatAppServerGateway;
localItemIds: LocalIdSource;
ensureConnected: () => Promise<void>;
ensureInitialized: () => Promise<void>;
status: ChatPanelThreadStatus;
threadStart: ThreadStartActions;
foundation: ChatPanelThreadFoundation;
notifyActiveThreadIdentityChanged: () => void;
}
interface ChatPanelThreadLifecycleBundle {
interface ChatPanelThreadLifecycleBundle extends ChatPanelThreadLifecycle {
goals: ChatPanelGoalActions;
rename: ThreadRenameEditorActions;
lifecycle: ChatPanelThreadLifecycle;
identity: ActiveThreadIdentitySync;
restoration: RestorationController;
resume: ResumeActions;
}
interface ChatPanelThreadActionInput {
@ -125,13 +122,13 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan
transport: threadOperationsTransport,
nameMutations: environment.plugin.threadNameMutations,
archiveExport: {
settings: () => environment.plugin.settingsRef.settings.archiveExportSettings(),
enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(),
vaultPath: environment.plugin.settingsRef.vaultPath,
settings: () => environment.plugin.settings.archiveExportSettings(),
enabled: () => environment.plugin.settings.archiveExportEnabled(),
vaultPath: environment.plugin.appServerContext.vaultPath,
vaultConfigDir: environment.obsidian.app.vault.configDir,
},
archiveDestination: environment.obsidian.archiveDestination,
catalog: environment.plugin.threadCatalog,
operationEvents: environment.plugin.threadOperationCoordinator,
referenceThreads: () => stateStore.getState().threadList.listedThreads,
notice: (text) => {
new Notice(text);
@ -196,9 +193,19 @@ export function createThreadLifecycleBundle(
host: ChatPanelThreadHost,
input: ChatPanelThreadLifecycleInput,
): ChatPanelThreadLifecycleBundle {
const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, notifyActiveThreadIdentityChanged } = input;
const {
appServer,
localItemIds,
ensureConnected,
ensureInitialized,
status,
threadStart,
foundation,
notifyActiveThreadIdentityChanged,
} = input;
const lifecycle = createSessionThreadLifecycle(host, {
appServer,
ensureInitialized,
status,
goalSync: foundation.goalSync,
autoTitleCoordinator: foundation.autoTitleCoordinator,
@ -232,14 +239,14 @@ export function createThreadLifecycleBundle(
ensureConnected,
addSystemMessage: status.addSystemMessage,
renameThread: (threadId, value) => foundation.threadOperations.renameThread(threadId, value),
generateThreadTitle: (threadId) => foundation.titleService.generateTitle(threadId),
resolveThreadTitleContext: (threadId) => foundation.titleService.resolveContext(threadId),
generateThreadTitle: (context, signal) => foundation.titleService.generate(context, signal),
});
const { identity, restoration, resume } = lifecycle;
return {
goals,
rename,
lifecycle,
identity,
restoration,
resume,
@ -247,7 +254,7 @@ export function createThreadLifecycleBundle(
}
export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatPanelThreadActionInput): ChatPanelThreadActionBundle {
const { appServer, status, composerController, foundation, lifecycle, notifyActiveThreadIdentityChanged } = input;
const { appServer, status, composerController, foundation, lifecycle } = input;
const { environment, stateStore } = host;
const threadManagementHost: ThreadManagementActionsHost = {
stateStore,
@ -265,13 +272,25 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP
composerController.setDraft(text, { focus: true });
},
openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId),
openThreadInCurrentPanel: async (threadId) => {
await lifecycle.resume.resumeThread(threadId);
},
notifyActiveThreadIdentityChanged,
recordThread: (thread) => {
environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread });
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;
}
},
beginThreadForkPublication: (sourceThreadId) => environment.plugin.threadOperationCoordinator.beginForkPublication(sourceThreadId),
threadHasPendingOrRunningPanel: (threadId) => environment.plugin.workspace.threadHasPendingOrRunningPanel(threadId),
};
const actions = createThreadManagementActions(threadManagementHost);
@ -303,6 +322,7 @@ function createSessionThreadLifecycle(
host: ChatPanelThreadHost,
input: {
appServer: ChatAppServerGateway;
ensureInitialized: () => Promise<void>;
status: ChatPanelThreadStatus;
goalSync: ChatPanelGoalSyncActions;
autoTitleCoordinator: AutoTitleCoordinator;
@ -311,7 +331,16 @@ function createSessionThreadLifecycle(
notifyActiveThreadIdentityChanged: () => void;
},
): ChatPanelThreadLifecycle {
const { appServer, status, goalSync, autoTitleCoordinator, history, invalidateThreadWork, notifyActiveThreadIdentityChanged } = input;
const {
appServer,
ensureInitialized,
status,
goalSync,
autoTitleCoordinator,
history,
invalidateThreadWork,
notifyActiveThreadIdentityChanged,
} = input;
const restoration = new RestorationController({
stateStore: host.stateStore,
});
@ -320,14 +349,20 @@ function createSessionThreadLifecycle(
};
const resume = createResumeActions({
stateStore: host.stateStore,
resumeTransport: appServer.threadResume,
resumeTransport: {
ensureConnected: async () => {
await ensureInitialized();
return appServer.connectionAvailable();
},
resumeThread: (threadId) => appServer.threadResume.resumeThread(threadId),
},
resumeWork: host.resumeWork,
history,
closing: host.getClosing,
resetThreadTurnPresence,
notifyActiveThreadIdentityChanged,
recordResumedThread: (thread) => {
host.environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread });
host.environment.plugin.threadOperationCoordinator.apply({ type: "thread-upserted", thread });
},
addSystemMessage: status.addSystemMessage,
syncThreadGoal: (threadId) => goalSync.syncThreadGoal(threadId),
@ -342,7 +377,6 @@ function createSessionThreadLifecycle(
});
return {
history,
restoration,
resume,
identity,

View file

@ -1,53 +1,46 @@
import type { App, Component, EventRef } from "obsidian";
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
import type { AppServerQueryContext } from "../../../app-server/query/keys";
import type { AppServerExecutionContext } from "../../../app-server/connection/execution-context";
import type { ObservedResultListener } from "../../../app-server/query/observed-result";
import type { ModelMetadata, ReasoningEffort } from "../../../domain/catalog/metadata";
import type { ModelMetadata } 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 { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../../threads/catalog/thread-catalog";
import type { ArchiveExportDestination } from "../../threads/workflows/archive-export";
import type { ThreadCatalogPaginatedActiveReader } from "../../threads/catalog/thread-catalog";
import type { ArchiveExportDestination, ArchiveExportSettings } from "../../threads/workflows/archive-export";
import type { ThreadTitleTransport } from "../../threads/workflows/ports";
import type { ThreadNameMutationCoordinator } from "../../threads/workflows/thread-name-mutation-coordinator";
import type { ThreadOperationCoordinator } from "../../threads/workflows/thread-operation-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<AppServerQueryContext>;
readonly settingsRef: ChatPanelSettingsRef;
readonly appServerContext: Readonly<AppServerExecutionContext>;
readonly settings: ChatPanelSettingsAccess;
readonly workspace: WorkspacePanels;
readonly appServerQueries: ChatAppServerQueries;
readonly threadCatalog: ChatThreadCatalog;
readonly threadNameMutations: ThreadNameMutationCoordinator;
readonly threadOperationCoordinator: ThreadOperationCoordinator;
readonly threadNameMutations: KeyedOperationQueue<string>;
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;
@ -56,7 +49,7 @@ export interface WorkspacePanels {
openSideChat(sourceThreadId: string, sourceThreadTitle: string | null): Promise<void>;
}
type ChatThreadCatalog = ThreadCatalogPaginatedActiveReader & ThreadCatalogEventSink;
type ChatThreadCatalog = ThreadCatalogPaginatedActiveReader;
interface ChatAppServerQueries {
appServerMetadataSnapshot(): SharedServerMetadata | null;
@ -123,6 +116,11 @@ 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/daily-note-references";
import type { DailyNoteReferenceCandidate } from "../../application/composer/note-context";
import { displayNameForFile, linktextForFile } from "./vault-note-links.obsidian";
const RELATIVE_DAILY_NOTES = [

View file

@ -2,8 +2,7 @@ import type { App, EventRef } from "obsidian";
import { stripHeadingForLink, TFile } from "obsidian";
import type { VaultFileReference } from "../../../../domain/chat/input";
import type { NoteCandidateProvider } from "../../application/composer/note-context";
import type { NoteCandidate } from "../../application/composer/suggestions";
import type { NoteCandidate, NoteCandidateProvider } from "../../application/composer/note-context";
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, attachment: { kind: "web" } },
{ type: "additionalContext", key: WEB_CONTEXT_KEY, kind: "untrusted", value: context },
...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 { isStaleAppServerResourceContextError } from "../../../app-server/query/resource-store";
import { isStaleExecutionRuntimeError } from "../../../shared/runtime/execution-runtime-lifetime";
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;
reconnect?: () => Promise<void>;
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
}
export class ChatPanelSessionRuntime {
@ -178,7 +178,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
threadStartTransport: appServer.threadStart,
runtimeSnapshotForState: runtimeSnapshotForChatState,
recordStartedThread: (thread) => {
environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread });
environment.plugin.threadOperationCoordinator.apply({ type: "thread-upserted", thread });
},
syncThreadGoal: (threadId) => {
void threadFoundation.goalSync.syncThreadGoal(threadId);
@ -188,6 +188,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
appServer,
localItemIds,
ensureConnected,
ensureInitialized: () => connectionActions.ensureInitialized(),
status,
threadStart,
foundation: threadFoundation,
@ -245,13 +246,12 @@ 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.lifecycle,
threadLifecycle,
threadActions: threadActions.actions,
navigation: threadActions.navigation,
composerController,
@ -259,7 +259,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
threadStart,
goals: threadLifecycle.goals,
autoTitleCoordinator: threadFoundation.autoTitleCoordinator,
reconnect: reconnectForUser,
reconnect,
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: reconnectForUser,
reconnect,
history: threadFoundation.history,
pendingRequests: turn.pendingRequests,
turn,
@ -282,7 +282,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
try {
await connectionBundle.refreshSharedThreads();
} catch (error) {
if (isStaleAppServerResourceContextError(error)) return;
if (isStaleExecutionRuntimeError(error)) return;
throw error;
}
};

View file

@ -21,6 +21,8 @@ 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;
@ -85,10 +87,6 @@ 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 {
@ -104,10 +102,15 @@ 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(),
};
@ -214,7 +217,7 @@ export class ChatPanelSession implements ChatPanelHandle {
if (!root) return;
renderChatPanelShell(root, {
stateStore: this.stateStore,
showToolbar: this.environment.plugin.settingsRef.settings.showToolbar(),
showToolbar: this.environment.plugin.settings.showToolbar(),
parts: this.runtime.shell.parts,
});
}
@ -264,6 +267,9 @@ 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();
@ -273,6 +279,47 @@ 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;
@ -310,7 +357,7 @@ export class ChatPanelSession implements ChatPanelHandle {
resumeWork: this.resumeWork,
threadStreamScrollBinding: this.threadStreamScrollBinding,
getClosing: () => this.closing,
reconnect: () => this.reconnect(),
beginPanelActivityPublication: (replacementThreadId) => this.beginPanelActivityPublication(replacementThreadId),
});
}
}
@ -321,6 +368,17 @@ 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 {
activeSnapshot(): readonly Thread[] | null;
hasMoreActive(): boolean;
observeActive(observer: ThreadObserver, options?: { emitCurrent?: boolean }): () => void;
activeThreadsSnapshot(): readonly Thread[] | null;
hasMoreActiveThreads(): boolean;
observeActiveThreadsResult(observer: ThreadObserver, options?: { emitCurrent?: boolean }): () => void;
}
interface SharedStateAppServerQueries {
@ -56,12 +56,12 @@ export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateB
}
};
const applyCached = (): void => {
const threads = threadCatalog.activeSnapshot();
const threads = threadCatalog.activeThreadsSnapshot();
if (threads) {
stateStore.dispatch({
type: "thread-list/applied",
threads,
hasMore: threadCatalog.hasMoreActive(),
hasMore: threadCatalog.hasMoreActiveThreads(),
isFetching: false,
isFetchingNextPage: false,
error: null,
@ -75,7 +75,7 @@ export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateB
unsubscribe();
applyCached();
unsubscribers.push(
threadCatalog.observeActive(receiveThreadResult, { emitCurrent: false }),
threadCatalog.observeActiveThreadsResult(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 { createObsidianArchiveExportDestination } from "../../threads/obsidian/archive-export-destination.obsidian";
import { createObsidianVaultMarkdownDestination } from "../../../shared/obsidian/vault-write-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: () => createObsidianArchiveExportDestination(this.app.vault),
archiveDestination: () => createObsidianVaultMarkdownDestination(this.app.vault),
requestWorkspaceLayoutSave: () => {
void this.app.workspace.requestSaveLayout();
},

View file

@ -14,14 +14,13 @@ import {
selectionContextReferenceMarker,
} from "../application/composer/context-references";
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
import type { NoteCandidateProvider } from "../application/composer/note-context";
import type { NoteCandidate, 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";
@ -112,7 +111,9 @@ export class ChatComposerController {
draft: model.draft,
busy: model.turnBusy,
canInterrupt: this.options.canInterrupt(model),
submissionDisabled: model.submissionBlockedByPanelPolicy || model.webSubmissionPending,
submissionDisabled: model.webSubmissionPending,
directInputDisabled: model.submissionBlockedByPanelPolicy,
runtimeControlsDisabled: model.runtimeSettingsDisabled,
sendDisabled: model.attachmentSavePending,
webSubmissionCancellable: model.webSubmissionCancellable,
normalPlaceholder: projection.placeholder,

View file

@ -70,7 +70,9 @@ 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;
@ -91,7 +93,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: activePanelOperationDecision(state, "compact").kind !== "allowed",
compactDisabled: !activeThread || activePanelOperationDecision(state, "compact").kind !== "allowed",
goalMutationDisabled: activePanelOperationDecision(state, "goal-mutation").kind === "blocked",
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
turnBusy: chatTurnBusy(state),
@ -154,7 +156,9 @@ 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,10 +130,7 @@ function ChatPanelToolbarRegion({
actions: ToolbarActions;
}): UiNode {
const model = useChatSelector(stateStore, selectChatPanelToolbar);
return useMemo(
() => <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />,
[model, stateStore, surface, actions],
);
return <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />;
}
function ChatPanelGoalRegion({ stateStore, surface }: { stateStore: ChatStateStore; surface: ChatPanelGoalSurface }): UiNode {

View file

@ -71,9 +71,12 @@ export function chatPanelComposerProjection(
availableModels: model.availableModels,
});
return {
placeholder: model.activeThreadSubagent
? "Agent thread is read-only."
: composerPlaceholder(activeComposerThreadName(model), model.sideChatActive, model.sideChatSourceTitle),
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),
meta: {
...composerMetaViewModel(model, snapshot),
...runtimeComposerChoices({

View file

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

View file

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

View file

@ -1,6 +1,7 @@
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";
@ -9,6 +10,7 @@ interface ThreadStreamMarkdownRendererOptions {
app: App;
owner: Component;
vaultPath: string;
openThread: (threadId: string) => void;
}
interface StreamMarkdownRenderContext {
@ -47,6 +49,7 @@ 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);
})
@ -72,6 +75,10 @@ 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");
@ -115,6 +122,18 @@ 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,13 +12,15 @@ export interface ToolbarThreadRow {
title: string;
threadId: string;
selected: boolean;
disabled: boolean;
openDisabled?: boolean;
renameDisabled: boolean;
archiveDisabled: boolean;
canArchive: boolean;
archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean };
rename: {
draft: string;
generating: boolean;
saving: boolean;
autoNameDisabled: boolean;
} | null;
}
@ -87,6 +89,7 @@ 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;
};
}
@ -365,7 +368,11 @@ function ThreadList({
{threads.map((thread) => (
<ThreadListRow key={thread.threadId} thread={thread} actions={actions} />
))}
{error ? <ToolbarPanelItem label={error} className="codex-panel__thread codex-panel__thread--error" interactive={false} /> : null}
{error ? (
<div className="codex-panel__thread-list-status codex-panel__thread-list-status--error" role="status">
{error}
</div>
) : null}
{hasMore ? (
<ToolbarPanelItem
label={loadingMore ? "Loading more threads…" : "Load more threads"}
@ -404,7 +411,6 @@ 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);
@ -415,7 +421,7 @@ function ThreadListRow({ thread, actions }: { thread: ToolbarThreadRow; actions:
icon="pencil"
label="Rename thread"
className="codex-panel__thread-action"
disabled={thread.disabled}
disabled={thread.renameDisabled}
onClick={(event) => {
event.stopPropagation();
actions.rename.start(thread.threadId);
@ -437,7 +443,7 @@ function ArchiveControls({ thread, actions }: { thread: ToolbarThreadRow; action
icon="archive"
label="Archive thread"
className="codex-panel__thread-action"
disabled={thread.disabled}
disabled={thread.archiveDisabled}
onClick={(event) => {
event.stopPropagation();
actions.archive.start(thread.threadId);
@ -471,7 +477,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.disabled}
disabled={thread.archiveDisabled}
onClick={(event) => {
event.stopPropagation();
actions.archive.confirm(thread.threadId, saveMarkdown);
@ -483,10 +489,12 @@ 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(() => {
focusToolbarRenameInput(inputRef.current);
}, [draft]);
if (!renameBusy) focusToolbarRenameInput(inputRef.current);
}, [draft, renameBusy]);
return (
<>
@ -501,39 +509,41 @@ 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 && !generating) actions.rename.save(thread.threadId, event.currentTarget.value);
if (!event.isComposing && !renameBusy) actions.rename.save(thread.threadId, event.currentTarget.value);
return;
}
if (event.key === "Escape") {
event.preventDefault();
actions.rename.cancel(thread.threadId);
if (!renameBusy) actions.rename.cancel(thread.threadId);
}
}}
onBlur={(event) => {
if (!generating) actions.rename.save(thread.threadId, event.currentTarget.value);
if (!renameBusy) actions.rename.save(thread.threadId, event.currentTarget.value);
}}
/>
</div>
)}
/>
<ToolbarRowActionButton
icon={generating ? "loader" : "sparkles"}
label="Auto-name thread"
icon={generating ? "x" : "sparkles"}
label={generating ? "Cancel auto-name" : "Auto-name thread"}
className="codex-panel__thread-action"
disabled={generating}
disabled={saving || (!generating && (thread.rename?.autoNameDisabled ?? true))}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
actions.rename.autoName(thread.threadId);
if (generating) actions.rename.cancelAutoName(thread.threadId);
else actions.rename.autoName(thread.threadId);
}}
/>
</>

View file

@ -3,9 +3,13 @@ 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 { SelectionRewriteTransport, SelectionRewriteTransportRequest } from "./transport";
import {
type SelectionRewriteOutput,
SelectionRewriteOutputError,
type SelectionRewriteTransport,
type SelectionRewriteTransportRequest,
} from "./transport";
const SELECTION_REWRITE_TIMEOUT_MS = 120_000;
@ -60,7 +64,19 @@ async function runAppServerSelectionRewrite(options: AppServerSelectionRewriteTr
},
options.runner,
);
const { output, rawText } = selectionRewriteOutputParseResultFromText(lastAgentText);
if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", rawText);
const output = selectionRewriteOutputFromText(lastAgentText);
if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", lastAgentText);
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,22 +27,17 @@ export function registerSelectionRewriteCommand(
plugin.addCommand({
id: "rewrite-selection",
name: "Rewrite selection",
editorCallback: (editor, view) => {
if (!(view instanceof MarkdownView) || !view.file) {
new Notice("Select text in an active markdown note first.");
return;
}
editorCheckCallback: (checking, editor, view) => {
if (!(view instanceof MarkdownView) || !view.file) return false;
const originalText = editor.getSelection();
if (!originalText.trim()) {
new Notice("Select text to rewrite first.");
return;
}
if (!originalText.trim()) return false;
if (checking) return true;
const viewDocument = view.containerEl.doc;
const viewWindow = viewDocument.defaultView;
if (!viewWindow) {
new Notice("Could not open rewrite popover for this note.");
return;
return false;
}
const rewriteState: SelectionRewriteState = {
@ -75,6 +70,7 @@ export function registerSelectionRewriteCommand(
});
popover.open();
activePopovers.add(popover);
return true;
},
});

View file

@ -1,20 +1,13 @@
export function buildSelectionUnifiedDiff(filePath: string, originalText: string, replacementText: string): string {
import { diffArrays } from "diff";
export function buildSelectionDiffLines(originalText: string, replacementText: string): string[] {
const originalLines = textLines(originalText);
const replacementLines = textLines(replacementText);
const changes = lineChanges(originalLines, replacementLines);
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");
return changes.map((change) => `${change.prefix}${change.text}`);
}
const MAX_SELECTION_REWRITE_LCS_CELLS = 40_000;
const MAX_SELECTION_REWRITE_EDIT_LENGTH = 400;
interface DiffLine {
prefix: " " | "+" | "-";
@ -29,31 +22,17 @@ function textLines(text: string): string[] {
}
function lineChanges(originalLines: string[], replacementLines: string[]): DiffLine[] {
if (originalLines.length * replacementLines.length > MAX_SELECTION_REWRITE_LCS_CELLS) {
return linearLineChanges(originalLines, replacementLines);
}
const arrayChanges = diffArrays(originalLines, replacementLines, {
maxEditLength: MAX_SELECTION_REWRITE_EDIT_LENGTH,
});
if (!arrayChanges) return linearLineChanges(originalLines, replacementLines);
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;
}
}
const changes = arrayChanges.flatMap<DiffLine>((change) =>
change.value.map((text) => ({
prefix: change.added ? "+" : change.removed ? "-" : " ",
text,
})),
);
return changes.length > 0 ? changes : [{ prefix: " ", text: "" }];
}
@ -96,18 +75,3 @@ 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

@ -1,35 +0,0 @@
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, unifiedDiffDisplayLines } from "../../shared/ui/diff-view";
import { buildSelectionUnifiedDiff } from "./diff";
import { DiffLineList } from "../../shared/ui/diff-view";
import { buildSelectionDiffLines } from "./diff";
import {
canApplySelectionRewrite,
type SelectionRewriteInstructionHistoryDirection,
@ -201,7 +201,7 @@ export class SelectionRewritePopover {
elements.applyButton = element;
}}
debugText={state.debugText}
diff={replacement === null ? null : buildSelectionUnifiedDiff(state.filePath, state.originalText, replacement)}
diffLines={replacement === null ? null : buildSelectionDiffLines(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;
diff: string | null;
diffLines: readonly string[] | null;
generating: boolean;
hasInstruction: boolean;
hasReplacement: boolean;
@ -324,7 +324,7 @@ interface SelectionRewritePopoverViewProps {
function SelectionRewritePopoverView({
applyButtonRef,
debugText,
diff,
diffLines,
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">{diff ? <SelectionRewriteDiff diff={diff} /> : null}</div>
<div className="codex-panel-selection-rewrite__diff">{diffLines ? <SelectionRewriteDiff lines={diffLines} /> : null}</div>
<div className="codex-panel-selection-rewrite__result-actions">
<IconButton
buttonRef={applyButtonRef}
@ -407,11 +407,6 @@ function SelectionRewriteStatus({ status }: { status: SelectionRewriteSessionSta
);
}
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"
/>
);
function SelectionRewriteDiff({ lines }: { lines: readonly string[] }): UiNode {
return <DiffLineList lines={lines.map((text) => ({ text }))} className="codex-panel-selection-rewrite__diff-body" />;
}

View file

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

View file

@ -1,8 +1,21 @@
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.recentActiveSnapshot();
const loadedThreads = recentSnapshot ?? (await host.threadCatalog.loadActive());
const recentSnapshot = host.threadCatalog.recentActiveThreadsSnapshot();
const loadedThreads = recentSnapshot ?? (await host.threadCatalog.fetchActiveThreads());
if (state.closed) return;
const recentThreads = host.threadCatalog.recentActiveSnapshot() ?? loadedThreads;
if (recentThreads.length === 0 && !host.threadCatalog.hasMoreActive()) {
const recentThreads = host.threadCatalog.recentActiveThreadsSnapshot() ?? loadedThreads;
if (recentThreads.length === 0 && !host.threadCatalog.hasMoreActiveThreads()) {
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.searchActive();
const pending = this.completeThreadsPromise ?? this.host.threadCatalog.fetchActiveThreadSearchInventory();
this.completeThreadsPromise = pending;
try {
this.completeThreads = Object.freeze([...(await pending)]);

View file

@ -2,17 +2,16 @@ 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 { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
import type { ArchiveExportDestination } from "../threads/workflows/archive-export";
import type { ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
import type { ArchiveExportDestination, ArchiveExportSettings } from "../threads/workflows/archive-export";
import type { ThreadOperationsTransport, ThreadTitleTransport } from "../threads/workflows/ports";
import type { ThreadNameMutationCoordinator } from "../threads/workflows/thread-name-mutation-coordinator";
import type { ThreadOperationEventSink } from "../threads/workflows/thread-operation-event";
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";
@ -21,7 +20,8 @@ export interface ThreadsViewHost {
readonly settings: ThreadsViewSettingsAccess;
readonly vaultPath: string;
readonly threadCatalog: ThreadsViewThreadCatalog;
readonly threadNameMutations: ThreadNameMutationCoordinator;
readonly threadEvents: ThreadOperationEventSink;
readonly threadNameMutations: KeyedOperationQueue<string>;
readonly threadOperationsTransport: ThreadOperationsTransport;
readonly threadTitleTransport: ThreadTitleTransport;
openNewPanel(): Promise<unknown>;
@ -29,13 +29,10 @@ export interface ThreadsViewHost {
openPanelActivities(): readonly ThreadsViewPanelActivity[];
}
type ThreadsViewThreadCatalog = ThreadCatalogPaginatedActiveReader & ThreadCatalogEventSink;
type ThreadsViewThreadCatalog = ThreadCatalogPaginatedActiveReader;
export interface ThreadsViewSettingsAccess {
archiveExportEnabled(): boolean;
codexPath(): string;
threadNamingModel(): string | null;
threadNamingEffort(): ReasoningEffort | null;
archiveExportSettings(): ArchiveExportSettings;
}
@ -48,17 +45,16 @@ export interface ThreadsViewSessionEnvironment {
viewWindow(): Window | null;
}
type ThreadsViewStatus =
| { kind: "idle" }
| { kind: "loading"; message: string }
| { kind: "empty"; message: string }
| { kind: "log"; message: string }
| { kind: "error"; message: string };
type ThreadsViewStatus = { kind: "idle" } | { kind: "loading"; 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;
@ -70,7 +66,10 @@ 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;
@ -86,7 +85,7 @@ export class ThreadsViewSession {
vaultConfigDir: this.environment.vaultConfigDir(),
},
archiveDestination: () => this.environment.archiveDestination(),
catalog: this.host.threadCatalog,
operationEvents: this.host.threadEvents,
referenceThreads: () => this.threads,
notice: (message) => {
new Notice(message);
@ -102,12 +101,12 @@ export class ThreadsViewSession {
this.environment.registerPointerDown((event) => {
this.cancelArchiveConfirmOnOutsidePointer(event);
});
const activeThreadsSnapshot = this.host.threadCatalog.activeSnapshot();
const activeThreadsSnapshot = this.host.threadCatalog.activeThreadsSnapshot();
if (activeThreadsSnapshot) {
this.threads = activeThreadsSnapshot;
this.threadsLoaded = true;
}
this.unsubscribeThreads = this.host.threadCatalog.observeActive((result) => {
this.unsubscribeThreads = this.host.threadCatalog.observeActiveThreadsResult((result) => {
this.receiveObservedThreadsResult(result);
});
this.render();
@ -116,6 +115,9 @@ 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;
@ -126,11 +128,11 @@ export class ThreadsViewSession {
}
async refresh(): Promise<void> {
await this.requestThreads(() => this.host.threadCatalog.refreshActive());
await this.requestThreads(() => this.host.threadCatalog.refreshActiveThreads());
}
private async load(): Promise<void> {
await this.requestThreads(() => this.host.threadCatalog.loadActive());
await this.requestThreads(() => this.host.threadCatalog.fetchActiveThreads());
}
private async requestThreads(request: () => Promise<readonly Thread[]>): Promise<void> {
@ -139,23 +141,24 @@ export class ThreadsViewSession {
try {
await request();
} catch (error) {
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(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.hasMoreActive() || this.observedFetching) return;
if (!this.lifetime.isCurrent(lifetime) || !this.host.threadCatalog.hasMoreActiveThreads() || this.observedFetching) return;
try {
await this.host.threadCatalog.loadMoreActive();
await this.host.threadCatalog.loadMoreActiveThreads();
} catch (error) {
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(error)) return;
this.noticeError(error);
}
}
@ -172,13 +175,10 @@ 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
? { kind: "error", message: result.error.message }
: observedThreads.length === 0
? { kind: "empty", message: "No threads" }
: { kind: "idle" };
this.status = result.error && !hadThreadsSnapshot ? { kind: "error", message: result.error.message } : { kind: "idle" };
this.render();
return;
}
@ -208,10 +208,10 @@ export class ThreadsViewSession {
renderThreadsViewShell(
this.environment.root,
{
status: this.status.kind === "idle" ? null : this.status.message,
status: this.status.kind === "idle" ? null : this.status,
loading: this.observedFetchingNextPage,
fetching: this.observedFetching,
hasMore: this.host.threadCatalog.hasMoreActive(),
hasMore: this.host.threadCatalog.hasMoreActiveThreads(),
rows: threadRows(
this.threads,
this.host.openPanelActivities(),
@ -235,6 +235,9 @@ export class ThreadsViewSession {
cancelRename: (threadId) => {
this.cancelRename(threadId);
},
cancelAutoName: (threadId) => {
this.cancelAutoName(threadId);
},
autoNameThread: (threadId) => void this.autoNameThread(threadId),
startArchive: (threadId) => {
this.startArchive(threadId);
@ -260,9 +263,12 @@ 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 {
@ -271,30 +277,45 @@ 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 editingState = this.renameStates.get(threadId);
if (!editingState || editingState.kind === "generating") return;
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();
try {
if (this.renameStates.get(threadId) !== editingState) return;
const result = await this.operations.renameThread(threadId, value, {
shouldStart: () => this.operationContextIsCurrent(lease),
shouldPublish: () => this.operationContextIsCurrent(lease),
await this.operations.renameThread(threadId, value, {
shouldStart: () => this.operationContextIsCurrent(lease) && this.renameSaveStillActive(threadId, saveToken),
shouldPublish: () => this.operationContextIsCurrent(lease) && this.renameSaveStillActive(threadId, saveToken),
});
if (!this.operationViewIsCurrent(lease) || this.renameStates.get(threadId) !== editingState) return;
if (!result) {
this.cancelRename(threadId);
return;
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();
}
this.renameStates.delete(threadId);
this.render();
} catch (error) {
if (!this.operationViewIsCurrent(lease) || this.renameStates.get(threadId) !== editingState) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
if (!this.operationViewIsCurrent(lease) || !this.renameSaveStillActive(threadId, saveToken)) return;
this.noticeError(error);
this.transitionRenameState(threadId, { type: "save-failed", saveToken });
this.render();
}
}
@ -309,19 +330,24 @@ 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 {
if (this.renameStates.get(threadId) !== generatingState) return;
const title = await this.titleService.generateTitle(threadId);
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.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.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.noticeError(error);
}
} finally {
const operation = this.renameGenerationControllers.get(threadId);
if (operation?.generationToken === generationToken) this.renameGenerationControllers.delete(threadId);
if (this.operationViewIsCurrent(lease)) {
this.finishAutoNameThread(threadId, generationToken);
}
@ -356,8 +382,7 @@ export class ThreadsViewSession {
this.renameStates.delete(threadId);
} catch (error) {
if (!this.operationViewIsCurrent(lease)) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
this.noticeError(error);
}
}
@ -373,12 +398,38 @@ 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) {
@ -389,6 +440,11 @@ 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: string | null;
status: { kind: "loading" | "error"; message: string } | null;
loading: boolean;
fetching?: boolean;
hasMore?: boolean;
@ -25,6 +25,7 @@ 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;
@ -65,10 +66,22 @@ function ThreadsViewShell({ model, actions }: { model: ThreadsViewShellModel; ac
</div>
<div className="codex-panel-threads__list">
{model.rows.length === 0 ? (
<div className="codex-panel-threads__empty">{model.status ?? (model.loading ? "Loading threads..." : "No threads")}</div>
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>
)
) : (
<>
{model.status ? <div className="codex-panel-threads__status">{model.status}</div> : null}
{model.rows.map((row) => (
<ThreadRow key={row.threadId} row={row} actions={actions} />
))}
@ -213,9 +226,10 @@ 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(() => {
focusThreadsRenameInput(inputRef.current);
}, [row.rename.draft]);
if (!renameBusy) focusThreadsRenameInput(inputRef.current);
}, [row.rename.draft, renameBusy]);
return (
<>
@ -226,39 +240,41 @@ 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 && !row.rename.generating) actions.saveRename(row.threadId, event.currentTarget.value);
if (!event.isComposing && !renameBusy) actions.saveRename(row.threadId, event.currentTarget.value);
return;
}
if (event.key === "Escape") {
event.preventDefault();
actions.cancelRename(row.threadId);
if (!renameBusy) actions.cancelRename(row.threadId);
}
}}
onBlur={(event) => {
if (!row.rename.generating) actions.saveRename(row.threadId, event.currentTarget.value);
if (!renameBusy) 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 ? "loader" : "sparkles"}
label="Auto-name thread"
icon={row.rename.generating ? "x" : "sparkles"}
label={row.rename.generating ? "Cancel auto-name" : "Auto-name thread"}
className="codex-panel-threads__row-button"
disabled={row.rename.generating}
disabled={row.rename.saving || (!row.rename.generating && row.rename.autoNameDisabled)}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
actions.autoNameThread(row.threadId);
if (row.rename.generating) actions.cancelAutoName(row.threadId);
else 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 { createObsidianArchiveExportDestination } from "../threads/obsidian/archive-export-destination.obsidian";
import { createObsidianVaultMarkdownDestination } from "../../shared/obsidian/vault-write-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: () => createObsidianArchiveExportDestination(this.app.vault),
archiveDestination: () => createObsidianVaultMarkdownDestination(this.app.vault),
vaultConfigDir: () => this.app.vault.configDir,
viewWindow: () => this.containerEl.doc.defaultView,
});

View file

@ -5,129 +5,32 @@ import type { Thread } from "../../../domain/threads/model";
type ActiveThreadListObserver = ObservedPaginatedResultListener<readonly Thread[]>;
type ArchivedThreadListObserver = ObservedResultListener<readonly Thread[]>;
interface ThreadCatalogStore {
export interface ThreadCatalogPaginatedActiveReader {
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[]>;
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;
}
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[]>;
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]>;
}
export interface ThreadCatalogArchivedReader {
archivedSnapshot(): readonly Thread[] | null;
refreshArchived(): Promise<readonly Thread[]>;
observeArchived(observer: ArchivedThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
archivedThreadsSnapshot(): readonly Thread[] | null;
refreshArchivedThreads(): Promise<readonly Thread[]>;
observeArchivedThreadsResult(observer: ArchivedThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
}
export interface ThreadCatalogEventSink {
apply(event: ThreadCatalogEvent): void;
interface ThreadCatalogWriter {
applyThreadListMutations(changes: readonly ThreadListMutation[]): void;
}
export interface ThreadCatalog
extends ThreadCatalogPaginatedActiveReader,
ThreadCatalogSearchReader,
ThreadCatalogArchivedReader,
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;
}
ThreadCatalogWriter {}

View file

@ -6,6 +6,8 @@ interface ThreadRowCoreRenameProjection {
readonly active: boolean;
readonly draft: string;
readonly generating: boolean;
readonly saving: boolean;
readonly autoNameDisabled: boolean;
}
interface ThreadRowCoreArchiveConfirmProjection {
@ -37,6 +39,8 @@ 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

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

View file

@ -1,9 +0,0 @@
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

@ -0,0 +1,184 @@
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