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. 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) ![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. 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 ### 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. 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 ### 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. 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. | | `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. | | `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. 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. 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. 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. 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. 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. 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. 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. 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", "id": "codex-panel",
"name": "Codex Panel", "name": "Codex Panel",
"version": "5.1.1", "version": "5.2.1",
"minAppVersion": "1.12.0", "minAppVersion": "1.12.0",
"description": "Codex in your sidebar.", "description": "Codex in your sidebar.",
"author": "murashit", "author": "murashit",

430
package-lock.json generated
View file

@ -1,19 +1,23 @@
{ {
"name": "codex-panel", "name": "codex-panel",
"version": "5.1.1", "version": "5.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codex-panel", "name": "codex-panel",
"version": "5.1.1", "version": "5.2.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@tanstack/query-core": "^5.101.2", "@tanstack/query-core": "^5.101.2",
"defuddle": "^0.19.1", "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", "micromark": "^4.0.2",
"obsidian-daily-notes-interface": "^0.9.5", "obsidian-daily-notes-interface": "^0.9.5",
"preact": "^10.29.7" "preact": "^10.29.7",
"unist-util-visit": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.3", "@biomejs/biome": "^2.5.3",
@ -21,9 +25,10 @@
"@commitlint/config-conventional": "^21.2.0", "@commitlint/config-conventional": "^21.2.0",
"@stryker-mutator/core": "^9.6.1", "@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.6.1",
"@types/mdast": "^4.0.4",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"concurrently": "^10.0.3", "concurrently": "^9.2.4",
"conventional-changelog-conventionalcommits": "^10.2.1", "conventional-changelog-conventionalcommits": "^10.2.1",
"conventional-commits-parser": "^7.1.0", "conventional-commits-parser": "^7.1.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
@ -1927,9 +1932,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.14", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3894,6 +3899,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@ -3919,6 +3933,12 @@
"@types/estree": "*" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.64.0", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
@ -4661,9 +4681,9 @@
"optional": true "optional": true
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -4912,56 +4932,131 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/concurrently": { "node_modules/concurrently": {
"version": "10.0.3", "version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "5.6.2", "chalk": "4.1.2",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"shell-quote": "1.8.4", "shell-quote": "1.9.0",
"supports-color": "10.2.2", "supports-color": "8.1.1",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"yargs": "18.0.0" "yargs": "17.7.2"
}, },
"bin": { "bin": {
"conc": "dist/bin/index.js", "conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/index.js" "concurrently": "dist/bin/concurrently.js"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=18"
}, },
"funding": { "funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1" "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
} }
}, },
"node_modules/concurrently/node_modules/chalk": { "node_modules/concurrently/node_modules/ansi-regex": {
"version": "5.6.2", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "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": { "engines": {
"url": "https://github.com/chalk/chalk?sponsor=1" "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": { "node_modules/concurrently/node_modules/supports-color": {
"version": "10.2.2", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": { "engines": {
"node": ">=18" "node": ">=10"
}, },
"funding": { "funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1" "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": { "node_modules/conventional-changelog-angular": {
"version": "9.2.1", "version": "9.2.1",
"resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz", "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" "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": { "node_modules/diff-match-patch": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
@ -6000,9 +6104,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": { "node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": {
"version": "2.1.0", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -6066,9 +6170,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint-plugin-n/node_modules/brace-expansion": { "node_modules/eslint-plugin-n/node_modules/brace-expansion": {
"version": "2.1.0", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -6290,9 +6394,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint-plugin-obsidianmd/node_modules/brace-expansion": { "node_modules/eslint-plugin-obsidianmd/node_modules/brace-expansion": {
"version": "1.1.14", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -7616,6 +7720,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/is-generator-function": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@ -7986,9 +8100,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.2.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -8608,6 +8722,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -8689,6 +8813,78 @@
"@xmldom/xmldom": "^0.9.10" "@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": { "node_modules/mdn-data": {
"version": "2.27.1", "version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
@ -8971,6 +9167,28 @@
"micromark-util-symbol": "^2.0.0" "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": { "node_modules/micromark-util-encode": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", "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" "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": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -10256,9 +10484,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.4", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -10425,6 +10653,51 @@
"node": ">= 0.4" "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": { "node_modules/string.prototype.matchall": {
"version": "4.0.12", "version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", "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" "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": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@ -11681,6 +12009,16 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "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", "name": "codex-panel",
"version": "5.1.1", "version": "5.2.1",
"description": "Codex in your Obsidian sidebar.", "description": "Codex in your Obsidian sidebar.",
"main": "main.js", "main": "main.js",
"author": "murashit", "author": "murashit",
@ -46,9 +46,10 @@
"@commitlint/config-conventional": "^21.2.0", "@commitlint/config-conventional": "^21.2.0",
"@stryker-mutator/core": "^9.6.1", "@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.6.1",
"@types/mdast": "^4.0.4",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"concurrently": "^10.0.3", "concurrently": "^9.2.4",
"conventional-changelog-conventionalcommits": "^10.2.1", "conventional-changelog-conventionalcommits": "^10.2.1",
"conventional-commits-parser": "^7.1.0", "conventional-commits-parser": "^7.1.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
@ -65,8 +66,12 @@
"dependencies": { "dependencies": {
"@tanstack/query-core": "^5.101.2", "@tanstack/query-core": "^5.101.2",
"defuddle": "^0.19.1", "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", "micromark": "^4.0.2",
"obsidian-daily-notes-interface": "^0.9.5", "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 { ThreadListResponse } from "../../generated/app-server/v2/ThreadListResponse";
import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse"; import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse";
import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse"; 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 { ThreadSetNameResponse } from "../../generated/app-server/v2/ThreadSetNameResponse";
import type { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse"; import type { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse";
import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse"; import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse";
@ -87,7 +86,6 @@ export interface ClientResponseByMethod {
"thread/delete": ThreadDeleteResponse; "thread/delete": ThreadDeleteResponse;
"thread/unsubscribe": ThreadUnsubscribeResponse; "thread/unsubscribe": ThreadUnsubscribeResponse;
"thread/unarchive": ThreadUnarchiveResponse; "thread/unarchive": ThreadUnarchiveResponse;
"thread/rollback": ThreadRollbackResponse;
"thread/name/set": ThreadSetNameResponse; "thread/name/set": ThreadSetNameResponse;
"thread/settings/update": ThreadSettingsUpdateResponse; "thread/settings/update": ThreadSettingsUpdateResponse;
"thread/turns/list": ThreadTurnsListResponse; "thread/turns/list": ThreadTurnsListResponse;

View file

@ -1,6 +1,6 @@
{ {
"codexAppServer": { "codexAppServer": {
"testedCliVersion": "0.144.5", "testedCliVersion": "0.145.0",
"typeGeneration": { "typeGeneration": {
"arguments": ["app-server", "generate-ts", "--experimental"] "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 type { VaultFileReference } from "../../domain/chat/input";
import { utf8ByteLength } from "./context-budget"; import { isPanelSubmissionClientId, turnContextSubmissionId } from "../../domain/chat/submission-id";
import type { VaultFileReference } from "./input"; import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
import { isPanelSubmissionClientId } from "./submission-id";
/*
* 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_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_MANIFEST_MAX_BYTES = 2_800;
const TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT = 64; const TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT = 64;
const TURN_CONTEXT_FILE_REFERENCE_NAME_MAX_LENGTH = 255; const TURN_CONTEXT_FILE_REFERENCE_NAME_MAX_LENGTH = 255;
const TURN_CONTEXT_FILE_REFERENCE_PATH_MAX_LENGTH = 2_048; const TURN_CONTEXT_FILE_REFERENCE_PATH_MAX_LENGTH = 2_048;
export type TurnContextAttachment = interface LegacyTurnContextManifestEntry {
| { kind: "referencedThread" | "web" | "obsidian";
kind: "referencedThread";
threadId: string;
includedTurns: number;
turnLimit: number;
omittedTurns: number;
truncated: boolean;
}
| { kind: "web" }
| { kind: "obsidian"; inlineExcerpts: number };
export interface TurnContextManifestEntry {
kind: TurnContextAttachment["kind"];
id: string; id: string;
parts: number; parts: number;
sourceBytes: number; sourceBytes: number;
@ -36,55 +28,26 @@ export interface TurnContextManifestEntry {
inlineExcerpts?: number; inlineExcerpts?: number;
} }
export interface TurnContextManifest { export interface LegacyTurnContextManifest {
version: 2; version: 2;
submissionId?: string; submissionId?: string;
contexts: readonly TurnContextManifestEntry[]; contexts: readonly LegacyTurnContextManifestEntry[];
fileReferences?: readonly VaultFileReference[]; fileReferences?: readonly VaultFileReference[];
} }
export interface UserMessageContextProjection { export interface LegacyTurnContextProjection {
text: string; text: string;
manifest: TurnContextManifest | null; manifest: LegacyTurnContextManifest | null;
} }
export function turnContextManifestText(manifest: TurnContextManifest): string { type UserMessageContentItem = { type: "text"; text: string } | { type: string };
return `${TURN_CONTEXT_MANIFEST_PREFIX}${TURN_CONTEXT_MANIFEST_NOTICE}${JSON.stringify(manifest)}`;
}
export function boundedTurnContextManifest( function legacyTurnContextManifestFromText(text: string): LegacyTurnContextManifest | null {
submissionId: string,
contexts: readonly TurnContextManifestEntry[],
fileReferences: readonly VaultFileReference[],
): TurnContextManifest | null {
const base = {
version: 2,
submissionId: turnContextSubmissionId(submissionId),
contexts,
} satisfies TurnContextManifest;
if (utf8ByteLength(turnContextManifestText(base)) > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
const included: VaultFileReference[] = [];
const seen = new Set<string>();
for (const reference of fileReferences) {
const normalized = manifestFileReference(reference);
if (!normalized || included.length >= TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT) continue;
const identity = `${normalized.name}\u0000${normalized.path}`;
if (seen.has(identity)) continue;
const candidate = { ...base, fileReferences: [...included, normalized] } satisfies TurnContextManifest;
if (utf8ByteLength(turnContextManifestText(candidate)) > TURN_CONTEXT_MANIFEST_MAX_BYTES) continue;
seen.add(identity);
included.push(normalized);
}
if (contexts.length === 0 && included.length === 0) return null;
return { ...base, ...(included.length > 0 ? { fileReferences: included } : {}) };
}
function turnContextManifestFromText(text: string): TurnContextManifest | null {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed.startsWith(TURN_CONTEXT_MANIFEST_PREFIX)) return null; 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 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; const json = payload.startsWith(notice) ? payload.slice(notice.length).trimStart() : payload;
let parsed: unknown; let parsed: unknown;
try { try {
@ -95,7 +58,7 @@ function turnContextManifestFromText(text: string): TurnContextManifest | null {
if (!parsed || typeof parsed !== "object") return null; if (!parsed || typeof parsed !== "object") return null;
const value = parsed as Record<string, unknown>; const value = parsed as Record<string, unknown>;
if (value["version"] !== 2 || !Array.isArray(value["contexts"])) return null; 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; if (contexts.some((entry) => entry === null)) return null;
const submissionId = optionalStringValue(value["submissionId"], 120); const submissionId = optionalStringValue(value["submissionId"], 120);
if (submissionId === null) return null; if (submissionId === null) return null;
@ -104,25 +67,27 @@ function turnContextManifestFromText(text: string): TurnContextManifest | null {
return { return {
version: 2, version: 2,
...(submissionId === undefined ? {} : { submissionId }), ...(submissionId === undefined ? {} : { submissionId }),
contexts: contexts as TurnContextManifestEntry[], contexts: contexts as LegacyTurnContextManifestEntry[],
...(fileReferences === undefined ? {} : { fileReferences }), ...(fileReferences === undefined ? {} : { fileReferences }),
}; };
} }
export function userMessageContextProjection( export function legacyTurnContextProjection(
content: readonly ({ type: "text"; text: string } | { type: string })[], content: readonly UserMessageContentItem[],
clientId: string | null, clientId: string | null,
): UserMessageContextProjection { ): LegacyTurnContextProjection {
let manifest: TurnContextManifest | null = null; let manifest: LegacyTurnContextManifest | null = null;
const visibleText: string[] = []; const visibleText: string[] = [];
const lastTextIndex = lastTextItemIndex(content);
for (const [index, item] of content.entries()) { for (const [index, item] of content.entries()) {
if (item.type !== "text" || !("text" in item) || typeof item.text !== "string") continue; if (item.type !== "text" || !("text" in item) || typeof item.text !== "string") continue;
const parsed = const manifestPrefix = `\n${TURN_CONTEXT_MANIFEST_PREFIX}`;
index > 0 && index === content.length - 1 && item.text.startsWith(`\n${TURN_CONTEXT_MANIFEST_PREFIX}`) const manifestStart = index === lastTextIndex ? item.text.lastIndexOf(manifestPrefix) : -1;
? turnContextManifestFromText(item.text) const isStandaloneManifest = manifestStart === 0 && index > 0 && index === content.length - 1;
: null; const parsed = manifestStart > 0 || isStandaloneManifest ? legacyTurnContextManifestFromText(item.text.slice(manifestStart)) : null;
if (parsed && manifestMatchesClientId(parsed, clientId)) { if (parsed && manifestMatchesClientId(parsed, clientId)) {
manifest = parsed; manifest = parsed;
if (manifestStart > 0) visibleText.push(item.text.slice(0, manifestStart));
continue; continue;
} }
visibleText.push(item.text); visibleText.push(item.text);
@ -130,12 +95,14 @@ export function userMessageContextProjection(
return { text: visibleText.join("\n"), manifest }; return { text: visibleText.join("\n"), manifest };
} }
export function turnContextSubmissionId(value: string): string { function lastTextItemIndex(content: readonly UserMessageContentItem[]): number {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120); for (let index = content.length - 1; index >= 0; index -= 1) {
return safe || "context"; 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; if (!isPanelSubmissionClientId(clientId)) return false;
const submissionId = turnContextSubmissionId(clientId); const submissionId = turnContextSubmissionId(clientId);
if (manifest.submissionId !== undefined && manifest.submissionId !== submissionId) return false; if (manifest.submissionId !== undefined && manifest.submissionId !== submissionId) return false;
@ -151,7 +118,7 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 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"); const context = manifest?.contexts.find((entry) => entry.kind === "referencedThread");
if (!context?.threadId || context.includedTurns === undefined || context.turnLimit === undefined || context.omittedTurns === undefined) { if (!context?.threadId || context.includedTurns === undefined || context.turnLimit === undefined || context.omittedTurns === undefined) {
return null; 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] : []; return manifest?.fileReferences ? [...manifest.fileReferences] : [];
} }
function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | null { function legacyManifestEntryFromUnknown(input: unknown): LegacyTurnContextManifestEntry | null {
if (!input || typeof input !== "object") return null; if (!input || typeof input !== "object") return null;
const value = input as Record<string, unknown>; const value = input as Record<string, unknown>;
const kind = contextKind(value["kind"]); 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; return value === "referencedThread" || value === "web" || value === "obsidian" ? value : null;
} }

View file

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

View file

@ -21,6 +21,7 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived
archived: options.archived ?? false, archived: options.archived ?? false,
createdAt: finiteTimestamp(thread.createdAt), createdAt: finiteTimestamp(thread.createdAt),
updatedAt: finiteTimestamp(thread.updatedAt), updatedAt: finiteTimestamp(thread.updatedAt),
canAcceptDirectInput: typeof thread.canAcceptDirectInput === "boolean" ? thread.canAcceptDirectInput : null,
provenance: threadProvenance(thread), provenance: threadProvenance(thread),
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}), ...(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 { VaultFileReference } from "../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../domain/threads/reference"; import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
import { import {
@ -10,6 +9,11 @@ import {
import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem"; import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem";
import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn"; import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn";
import { legacyPanelUserMessageProjection } from "./legacy-panel-user-message"; import { legacyPanelUserMessageProjection } from "./legacy-panel-user-message";
import {
fileReferencesFromLegacyManifest,
legacyTurnContextProjection,
referencedThreadFromLegacyManifest,
} from "./legacy-turn-context-manifest";
export type TurnItem = GeneratedThreadItem; export type TurnItem = GeneratedThreadItem;
export type TurnRecord = GeneratedTurn; export type TurnRecord = GeneratedTurn;
@ -53,11 +57,17 @@ export interface TurnUserItemProjection {
text: string; text: string;
referencedThread: ReferencedThreadMetadata | null; referencedThread: ReferencedThreadMetadata | null;
fileReferences: VaultFileReference[]; 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 { 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) { if (!projected.manifest) {
const legacy = legacyPanelUserMessageProjection({ const legacy = legacyPanelUserMessageProjection({
content: item.content, content: item.content,
@ -68,16 +78,16 @@ export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMess
text: [legacy.text, supplementalText].filter(Boolean).join("\n"), text: [legacy.text, supplementalText].filter(Boolean).join("\n"),
referencedThread: legacy.referencedThread, referencedThread: legacy.referencedThread,
fileReferences: legacy.fileReferences, fileReferences: legacy.fileReferences,
manifest: null, contexts: [],
}; };
} }
const supplementalText = nonTextUserInputText(item.content, projected.text); const supplementalText = nonTextUserInputText(item.content, projected.text);
const text = [projected.text, supplementalText].filter(Boolean).join("\n"); const text = [projected.text, supplementalText].filter(Boolean).join("\n");
return { return {
text, text,
referencedThread: referencedThreadFromManifest(projected.manifest), referencedThread: referencedThreadFromLegacyManifest(projected.manifest),
fileReferences: fileReferencesFromManifest(projected.manifest), fileReferences: fileReferencesFromLegacyManifest(projected.manifest),
manifest: projected.manifest, contexts: legacyContextMetadata(projected.manifest.contexts),
}; };
} }
@ -96,10 +106,7 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
if (item.type === "userMessage") { if (item.type === "userMessage") {
const projection = turnUserItemProjection(item); const projection = turnUserItemProjection(item);
const text = projection.text.trim(); const text = projection.text.trim();
const contexts = const contexts = projection.contexts.map((context) => ({ kind: context.kind, truncated: context.truncated }));
projection.manifest?.contexts
.filter((context) => context.kind === "web" || context.kind === "obsidian")
.map((context) => ({ kind: context.kind as "web" | "obsidian", truncated: context.truncated })) ?? [];
return text return text
? [ ? [
{ {
@ -123,6 +130,21 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
return []; 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( function nonTextUserInputText(
content: Extract<TurnItem, { type: "userMessage" }>["content"], content: Extract<TurnItem, { type: "userMessage" }>["content"],
visibleText: string, visibleText: string,

View file

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

View file

@ -14,7 +14,6 @@ export interface RequestAdditionalContext {
key: string; key: string;
value: string; value: string;
kind: "untrusted" | "application"; kind: "untrusted" | "application";
attachment?: TurnContextAttachment;
} }
export type CodexInputItem = export type CodexInputItem =
@ -28,7 +27,6 @@ export type CodexInputItem =
key: string; key: string;
value: string; value: string;
kind: RequestAdditionalContext["kind"]; kind: RequestAdditionalContext["kind"];
attachment?: TurnContextAttachment;
}; };
export type CodexInput = CodexInputItem[]; export type CodexInput = CodexInputItem[];
@ -53,7 +51,6 @@ export function codexTextInputWithReferences(
key: context.key, key: context.key,
value: context.value, value: context.value,
kind: context.kind, kind: context.kind,
...(context.attachment ? { attachment: context.attachment } : {}),
})), })),
]; ];
} }
@ -61,5 +58,3 @@ export function codexTextInputWithReferences(
export function codexTextInputWithAttachments(text: string, input: readonly CodexInputItem[]): CodexInput { export function codexTextInputWithAttachments(text: string, input: readonly CodexInputItem[]): CodexInput {
return [...codexTextInput(text), ...input.filter((item) => item.type !== "text")]; 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 { export function isPanelSubmissionClientId(value: string | null | undefined): value is string {
return typeof value === "string" && PANEL_SUBMISSION_CLIENT_ID_PATTERN.test(value); 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 type { Link, Nodes } from "mdast";
import { isExternalFileHref, parseFileHref } from "../vault/file-hrefs"; 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 { isFilesystemAbsolutePath, isVaultConfigPath, normalizeFilePath, vaultRelativePath } from "../vault/paths";
import type { Thread } from "./model"; import type { Thread } from "./model";
import { threadArchiveTitle } from "./title"; import { threadArchiveTitle } from "./title";
import type { ThreadTranscriptEntry } from "./transcript"; import type { ThreadTranscriptEntry } from "./transcript";
interface ParsedMarkdownLink { interface MarkdownSourceReplacement {
raw: string; start: number;
label: string;
href: string;
end: number; end: number;
value: string;
} }
export interface ArchiveExportSettings { export interface ArchiveMarkdownOptions {
archiveExportFolderTemplate: string;
archiveExportFilenameTemplate: string;
archiveExportTags?: string; archiveExportTags?: string;
vaultPath?: string; vaultPath?: string;
vaultConfigDir?: string; vaultConfigDir?: string;
@ -24,56 +25,31 @@ export interface ArchiveThreadInput extends Thread {
transcriptEntries: readonly ThreadTranscriptEntry[]; transcriptEntries: readonly ThreadTranscriptEntry[];
} }
export function archivedThreadMarkdown( export function archivedThreadMarkdown(thread: ArchiveThreadInput, exportedAt = new Date(), settings: ArchiveMarkdownOptions = {}): string {
thread: ArchiveThreadInput,
exportedAt = new Date(),
settings?: Partial<ArchiveExportSettings>,
): string {
const title = threadArchiveTitle(thread); const title = threadArchiveTitle(thread);
const tags = normalizedArchiveTags(settings?.archiveExportTags ?? ""); const tags = normalizedArchiveTags(settings.archiveExportTags ?? "");
const lines = [ const frontmatter = [
"---", "---",
`title: ${yamlFrontmatterString(title)}`, `title: ${yamlFrontmatterString(title)}`,
`thread_id: ${yamlFrontmatterString(thread.id)}`, `thread_id: ${yamlFrontmatterString(thread.id)}`,
`created: ${yamlFrontmatterString(formatDate(exportedAt))}`, `created: ${yamlFrontmatterString(formatDate(exportedAt))}`,
...frontmatterTagsLines(tags), ...frontmatterTagsLines(tags),
"---", "---",
"", ].join("\n");
`# ${title}`, const body = `${trimTrailingBlankLines([`# ${title}`, "", ...transcriptMarkdownLines(thread.transcriptEntries)]).join("\n")}\n`;
"", const normalizedBody = settings.vaultPath ? normalizeExportedMarkdownLinks(body, settings.vaultPath, settings.vaultConfigDir) : body;
...transcriptMarkdownLines(thread.transcriptEntries), return `${frontmatter}\n\n${normalizedBody}`;
];
const markdown = `${trimTrailingBlankLines(lines).join("\n")}\n`;
return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath, settings.vaultConfigDir) : markdown;
} }
function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string, vaultConfigDir: string | null | undefined): string { function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
const lines = markdown.split("\n"); const replacements: MarkdownSourceReplacement[] = [];
let inFence = false; visit(fromMarkdown(markdown), "link", (link) => {
return lines const replacement = normalizedExportedMarkdownLink(link, vaultPath, vaultConfigDir);
.map((line) => { if (replacement) replacements.push(replacement);
if (line.trimStart().startsWith("```")) { });
inFence = !inFence; let output = markdown;
return line; 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 inFence ? line : normalizeExportedMarkdownLinksInLine(line, vaultPath, vaultConfigDir);
})
.join("\n");
}
function normalizeExportedMarkdownLinksInLine(line: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
let output = "";
let index = 0;
while (index < line.length) {
const parsed = parseMarkdownLinkAt(line, index);
if (!parsed || isInsideInlineCode(line, index)) {
output += line[index] ?? "";
index += 1;
continue;
}
output += normalizedExportedMarkdownLink(parsed, vaultPath, vaultConfigDir);
index = parsed.end;
} }
return output; return output;
} }
@ -163,67 +139,45 @@ function stripMatchingQuotes(value: string): string {
return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value; return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value;
} }
function unwrappedMarkdownHref(value: string): string { function normalizedExportedMarkdownLink(
if (value.startsWith("<") && value.endsWith(">")) return value.slice(1, -1); link: Link,
return value; 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 { const parsed = parseFileHref(link.url);
if (line[start] !== "[" || line[start - 1] === "!") return null; 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 (!isFilesystemAbsolutePath(normalizeFilePath(parsed.path))) return null;
if (labelEnd === -1 || line[labelEnd + 1] !== "(") return null;
const hrefStart = labelEnd + 2;
const hrefEnd = line[hrefStart] === "<" ? line.indexOf(">)", hrefStart + 1) : line.indexOf(")", hrefStart);
if (hrefEnd === -1) return null;
const end = line[hrefStart] === "<" ? hrefEnd + 2 : hrefEnd + 1;
return { return {
raw: line.slice(start, end), start,
label: line.slice(start + 1, labelEnd),
href: line.slice(hrefStart, hrefEnd + (line[hrefStart] === "<" ? 1 : 0)),
end, 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 { function markdownNodeSource(node: Nodes): string {
const href = unwrappedMarkdownHref(link.href.trim()); return toMarkdown(node, { unsafe: [{ character: "|", inConstruct: ["label", "phrasing"] }] }).trimEnd();
if (!href || isExternalFileHref(href)) return link.raw;
const parsed = parseFileHref(href);
if (!parsed) return link.raw;
const vaultRelative = vaultRelativePath(vaultPath, parsed.path);
if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) {
return `[${link.label}](${markdownHref(`${vaultRelative}${parsed.subpath}`)})`;
}
return isFilesystemAbsolutePath(normalizeFilePath(parsed.path)) ? `${link.label} (${markdownCodeSpan(href)})` : link.raw;
} }
function archiveExportShouldKeepAbsolute(vaultRelativePath: string, vaultConfigDir: string | null | undefined): boolean { function archiveExportShouldKeepAbsolute(vaultRelativePath: string, vaultConfigDir: string | null | undefined): boolean {
return typeof vaultConfigDir === "string" && vaultConfigDir.length > 0 && isVaultConfigPath(vaultRelativePath, vaultConfigDir); 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 { function formatDate(date: Date): string {
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; 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(); while (result[result.length - 1] === "") result.pop();
return result; 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 createdAt: number;
readonly updatedAt: number; readonly updatedAt: number;
readonly recencyAt?: number | null; 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; readonly provenance: ThreadProvenance;
} }

View file

@ -1,6 +1,5 @@
import { truncateUtf8, utf8ByteLength } from "../chat/context-budget"; import { truncateUtf8, utf8ByteLength } from "../chat/context-budget";
import type { Thread } from "./model"; import type { Thread } from "./model";
import { threadDisplayTitle } from "./title";
import type { TurnTranscriptSummary } from "./transcript"; import type { TurnTranscriptSummary } from "./transcript";
export const REFERENCED_THREAD_TURN_LIMIT = 20; export const REFERENCED_THREAD_TURN_LIMIT = 20;
@ -14,27 +13,12 @@ export interface ReferencedThreadMetadata {
truncated?: boolean; truncated?: boolean;
} }
export interface ReferencedThreadContextBundle {
value: string;
referencedThread: ReferencedThreadMetadata;
}
const REFERENCED_THREAD_CONTEXT_MAX_BYTES = 18_000; const REFERENCED_THREAD_CONTEXT_MAX_BYTES = 18_000;
function referencedThreadMetadata(thread: Thread, count: number): ReferencedThreadMetadata { export function referencedThreadContext(thread: Thread, turns: readonly TurnTranscriptSummary[]): string {
return {
threadId: thread.id,
title: threadDisplayTitle(thread),
includedTurns: count,
turnLimit: REFERENCED_THREAD_TURN_LIMIT,
};
}
export function referencedThreadContextBundle(thread: Thread, turns: readonly TurnTranscriptSummary[]): ReferencedThreadContextBundle {
const rendered = turns.map((turn, index) => renderedReferenceTurn(turn, index + 1)); const rendered = turns.map((turn, index) => renderedReferenceTurn(turn, index + 1));
const included: string[] = []; const included: string[] = [];
let bytes = 0; let bytes = 0;
let truncatedTurn = false;
for (let index = rendered.length - 1; index >= 0; index -= 1) { for (let index = rendered.length - 1; index >= 0; index -= 1) {
const value = rendered[index]; const value = rendered[index];
if (value === undefined) continue; if (value === undefined) continue;
@ -43,7 +27,6 @@ export function referencedThreadContextBundle(thread: Thread, turns: readonly Tu
if (included.length === 0) { if (included.length === 0) {
const turn = turns[index]; const turn = turns[index];
if (turn) included.unshift(truncatedReferenceTurn(turn, index + 1, REFERENCED_THREAD_CONTEXT_MAX_BYTES)); if (turn) included.unshift(truncatedReferenceTurn(turn, index + 1, REFERENCED_THREAD_CONTEXT_MAX_BYTES));
truncatedTurn = true;
} }
break; break;
} }
@ -51,22 +34,14 @@ export function referencedThreadContextBundle(thread: Thread, turns: readonly Tu
bytes += nextBytes; bytes += nextBytes;
} }
const omittedTurns = turns.length - included.length; const omittedTurns = turns.length - included.length;
const reference = { return [
...referencedThreadMetadata(thread, included.length), "Referenced thread context for the current user input:",
omittedTurns, `Thread: ${thread.id}`,
truncated: omittedTurns > 0 || truncatedTurn, `Included turns: ${String(included.length)}`,
}; `Omitted turns: ${String(omittedTurns)}`,
return { "",
value: [ ...included,
"Referenced thread context for the current user input:", ].join("\n");
`Thread: ${thread.id}`,
`Included turns: ${String(reference.includedTurns)}`,
`Omitted turns: ${String(omittedTurns)}`,
"",
...included,
].join("\n"),
referencedThread: reference,
};
} }
function renderedReferenceTurn(turn: TurnTranscriptSummary, index: number): string { 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 = export type ThreadRenameLifecycleState =
| { kind: "idle" } | { kind: "idle" }
| { kind: "editing"; draft: string } | { kind: "editing"; draft: string; autoName: ThreadRenameAutoNameState }
| { kind: "generating"; draft: string; originalDraft: string; generationToken: number }; | { 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" }>; export type ThreadRenameActiveState = Exclude<ThreadRenameLifecycleState, { kind: "idle" }>;
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>; type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
type ThreadRenameSavingState = Extract<ThreadRenameLifecycleState, { kind: "saving" }>;
export type ThreadRenameLifecycleEvent = export type ThreadRenameLifecycleEvent =
| { type: "started"; draft: string } | { type: "started"; draft: string }
| { type: "draft-updated"; draft: string } | { type: "draft-updated"; draft: string }
| { type: "auto-name-context-resolved"; context: ThreadTitleContext | null }
| { type: "cancelled" } | { type: "cancelled" }
| { type: "save-started"; saveToken: number }
| { type: "save-failed"; saveToken: number }
| { type: "save-succeeded"; saveToken: number }
| { type: "generation-started"; generationToken: number } | { type: "generation-started"; generationToken: number }
| { type: "generation-succeeded"; generationToken: number; draft: string } | { type: "generation-succeeded"; generationToken: number; draft: string }
| { type: "generation-finished"; generationToken: number } | { type: "generation-finished"; generationToken: number }
@ -25,25 +35,40 @@ export function transitionThreadRenameLifecycleState(
): ThreadRenameLifecycleState { ): ThreadRenameLifecycleState {
switch (event.type) { switch (event.type) {
case "started": 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": 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": 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": case "generation-started":
if (state.kind !== "editing") return state; if (state.kind !== "editing" || state.autoName.kind !== "ready") return state;
return { return {
kind: "generating", kind: "generating",
draft: state.draft, draft: state.draft,
originalDraft: state.draft, autoName: state.autoName,
generationToken: event.generationToken, generationToken: event.generationToken,
}; };
case "generation-succeeded": 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 }; return { ...state, draft: event.draft };
case "generation-finished": case "generation-finished":
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state; if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
return { kind: "editing", draft: state.draft }; return { kind: "editing", draft: state.draft, autoName: state.autoName };
case "cleared": case "cleared":
return state.kind === "idle" ? state : initialThreadRenameLifecycleState(); return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
default: default:
@ -58,6 +83,10 @@ export function threadRenameGenerationStillActive(
return state.kind === "generating" && state.generationToken === generationToken; 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 { function unhandledThreadRenameLifecycleEvent(event: never): never {
throw new Error(`Unhandled thread rename lifecycle event: ${String(event)}`); 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; const DEFAULT_CONTEXT_MAX_PAGES = 5;
export const THREAD_TITLE_MAX_CHARS = 40; 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 { export interface ThreadTitleContext {
userRequest: string; userRequest: string;
assistantResponse: 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 MAX_THREAD_COMMAND_DISPLAY_TITLE_LENGTH = 96;
const UNTITLED_THREAD_TITLE = "Untitled thread"; 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 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 { export function threadMeaningfulTitle(thread: Thread): string | null {
for (const value of [thread.name, thread.preview]) { 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 { function truncateThreadDisplayTitle(title: string, maxLength: number): string {
if (title.length <= maxLength) return title; const graphemes = Array.from(GRAPHEME_SEGMENTER.segment(title), ({ segment }) => segment);
return `${title.slice(0, maxLength - 3).trimEnd()}...`; 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 { AppServerClient } from "./app-server/connection/client";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access"; 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 { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
import type { AppServerQueryContext } from "./app-server/query/keys"; import { AppServerQueryCache } from "./app-server/query/cache";
import { AppServerResourceStore, StaleAppServerResourceContextError } from "./app-server/query/resource-store";
import { import {
type EphemeralStructuredTurnClient, type EphemeralStructuredTurnClient,
type EphemeralStructuredTurnRunner, type EphemeralStructuredTurnRunner,
@ -16,22 +16,28 @@ import { createAppServerSelectionRewriteTransport } from "./features/selection-r
import type { SelectionRewriteTransport } from "./features/selection-rewrite/transport"; import type { SelectionRewriteTransport } from "./features/selection-rewrite/transport";
import { openThreadPicker, type ThreadPickerController } from "./features/thread-picker/modal.obsidian"; import { openThreadPicker, type ThreadPickerController } from "./features/thread-picker/modal.obsidian";
import { createThreadOperationsTransport, createThreadTitleTransport } from "./features/threads/app-server/workflow-transports"; import { createThreadOperationsTransport, createThreadTitleTransport } from "./features/threads/app-server/workflow-transports";
import { createThreadCatalog, type ThreadCatalog, type ThreadCatalogEvent } from "./features/threads/catalog/thread-catalog"; import type { ThreadCatalog } from "./features/threads/catalog/thread-catalog";
import { createThreadNameMutationCoordinator } from "./features/threads/workflows/thread-name-mutation-coordinator"; 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 { ThreadsViewHost, ThreadsViewSettingsAccess } from "./features/threads-view/session";
import type { ThreadsViewPanelActivity } from "./features/threads-view/state"; import type { ThreadsViewPanelActivity } from "./features/threads-view/state";
import type { ThreadsRuntimeView } from "./features/threads-view/view.obsidian"; import type { ThreadsRuntimeView } from "./features/threads-view/view.obsidian";
import { createSettingsAppServerDynamicData } from "./settings/app-server-dynamic-data"; import { createSettingsAppServerDynamicData } from "./settings/app-server-dynamic-data";
import type { SettingsDynamicDataAccess } from "./settings/dynamic-data"; import type { SettingsDynamicDataAccess } from "./settings/dynamic-data";
import type { CodexPanelSettings } from "./settings/model"; import type { CodexPanelSettings } from "./settings/model";
import { StaleExecutionRuntimeError } from "./shared/runtime/execution-runtime-lifetime";
import { createKeyedOperationQueue } from "./shared/runtime/keyed-operation-queue"; import { createKeyedOperationQueue } from "./shared/runtime/keyed-operation-queue";
export interface CodexExecutionRuntimeOptions { export interface CodexExecutionRuntimeOptions {
app: App; app: App;
context: AppServerQueryContext; context: AppServerExecutionContext;
settings: () => CodexPanelSettings; settings: () => CodexPanelSettings;
workspace: WorkspacePanels; workspace: WorkspacePanels;
onThreadCatalogEvent(event: ThreadCatalogEvent): void; onThreadLifecycleEvents(events: readonly ThreadLifecycleEvent[]): void;
openNewPanel(): Promise<unknown>; openNewPanel(): Promise<unknown>;
openThreadInCurrentView(threadId: string): Promise<void>; openThreadInCurrentView(threadId: string): Promise<void>;
openThreadInAvailableView(threadId: string): Promise<void>; openThreadInAvailableView(threadId: string): Promise<void>;
@ -44,11 +50,12 @@ export interface ExecutionRuntimeViews {
} }
export class CodexExecutionRuntime implements AppServerClientAccess { export class CodexExecutionRuntime implements AppServerClientAccess {
readonly context: Readonly<AppServerQueryContext>; private readonly context: Readonly<AppServerExecutionContext>;
readonly resourceStore: AppServerResourceStore; private readonly appServerQueries: AppServerQueryCache;
readonly threadCatalog: ThreadCatalog; private readonly threadCatalog: ThreadCatalog;
private readonly threadOperationCoordinator: ThreadOperationCoordinator;
readonly settingsDynamicData: SettingsDynamicDataAccess; readonly settingsDynamicData: SettingsDynamicDataAccess;
private readonly threadNameMutations = createThreadNameMutationCoordinator(); private readonly threadNameMutations = createKeyedOperationQueue<string>();
private readonly threadGoalOperations = createThreadGoalOperationCoordinator(); private readonly threadGoalOperations = createThreadGoalOperationCoordinator();
private readonly runtimeSettingsCommitQueue = createKeyedOperationQueue<string>(); private readonly runtimeSettingsCommitQueue = createKeyedOperationQueue<string>();
private readonly shortLivedClients = new Set<AppServerClient>(); private readonly shortLivedClients = new Set<AppServerClient>();
@ -61,38 +68,31 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
constructor(private readonly options: CodexExecutionRuntimeOptions) { constructor(private readonly options: CodexExecutionRuntimeOptions) {
this.context = Object.freeze({ ...options.context }); this.context = Object.freeze({ ...options.context });
this.resourceStore = new AppServerResourceStore({ this.appServerQueries = new AppServerQueryCache(this.context, this);
context: this.context, this.threadCatalog = this.appServerQueries;
clientRunner: { this.threadOperationCoordinator = createThreadOperationCoordinator((events) => {
runWithClient: (operation, clientOptions) => this.runWithAppServerClient(operation, clientOptions), this.threadCatalog.applyThreadListMutations(projectThreadListChanges(this.threadCatalog, events));
}, options.onThreadLifecycleEvents(events);
});
this.threadCatalog = createThreadCatalog({
store: this.resourceStore,
onEventApplied: (event) => {
options.onThreadCatalogEvent(event);
},
}); });
this.settingsDynamicData = createSettingsAppServerDynamicData({ this.settingsDynamicData = createSettingsAppServerDynamicData({
vaultPath: this.context.vaultPath, vaultPath: this.context.vaultPath,
clientAccess: this, clientAccess: this,
appServerQueries: this.resourceStore, appServerQueries: this.appServerQueries,
threadCatalog: this.threadCatalog, threadCatalog: this.threadCatalog,
threadEvents: this.threadOperationCoordinator,
}); });
} }
chatHost(): CodexChatHost { private chatHost(): CodexChatHost {
this.assertActive(); this.assertActive();
return { return {
appServerClientAccess: this, appServerClientAccess: this,
appServerContext: this.context, appServerContext: this.context,
settingsRef: { settings: this.chatSettings(),
settings: this.chatSettings(),
vaultPath: this.context.vaultPath,
},
workspace: this.options.workspace, workspace: this.options.workspace,
appServerQueries: this.resourceStore, appServerQueries: this.appServerQueries,
threadCatalog: this.threadCatalog, threadCatalog: this.threadCatalog,
threadOperationCoordinator: this.threadOperationCoordinator,
threadNameMutations: this.threadNameMutations, threadNameMutations: this.threadNameMutations,
threadTitleTransport: this.threadTitleTransport(), threadTitleTransport: this.threadTitleTransport(),
threadGoalOperations: this.threadGoalOperations, threadGoalOperations: this.threadGoalOperations,
@ -100,12 +100,13 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
}; };
} }
threadsHost(): ThreadsViewHost { private threadsHost(): ThreadsViewHost {
this.assertActive(); this.assertActive();
return { return {
settings: this.threadsSettings(), settings: this.threadsSettings(),
vaultPath: this.context.vaultPath, vaultPath: this.context.vaultPath,
threadCatalog: this.threadCatalog, threadCatalog: this.threadCatalog,
threadEvents: this.threadOperationCoordinator,
threadNameMutations: this.threadNameMutations, threadNameMutations: this.threadNameMutations,
threadOperationsTransport: createThreadOperationsTransport(this), threadOperationsTransport: createThreadOperationsTransport(this),
threadTitleTransport: this.threadTitleTransport(), threadTitleTransport: this.threadTitleTransport(),
@ -263,7 +264,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
}); });
this.shortLivedClients.clear(); this.shortLivedClients.clear();
this.tryCleanup(() => { this.tryCleanup(() => {
this.resourceStore.dispose(); this.appServerQueries.dispose();
}); });
return views; return views;
} }
@ -277,20 +278,25 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
this.assertActive(); this.assertActive();
return operation(client); return operation(client);
}; };
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, { try {
created: (client) => { const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
if (this.disposed) { created: (client) => {
client.disconnect(); if (this.disposed) {
throw new StaleAppServerResourceContextError(); client.disconnect();
} throw new StaleExecutionRuntimeError();
this.shortLivedClients.add(client); }
}, this.shortLivedClients.add(client);
disposed: (client) => { },
this.shortLivedClients.delete(client); disposed: (client) => {
}, this.shortLivedClients.delete(client);
}); },
this.assertActive(); });
return result; this.assertActive();
return result;
} catch (error) {
if (this.disposed) throw new StaleExecutionRuntimeError();
throw error;
}
} }
private structuredTurnRunner(): EphemeralStructuredTurnRunner { private structuredTurnRunner(): EphemeralStructuredTurnRunner {
@ -311,7 +317,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
created: (client) => { created: (client) => {
if (this.disposed) { if (this.disposed) {
client.disconnect(); client.disconnect();
throw new StaleAppServerResourceContextError(); throw new StaleExecutionRuntimeError();
} }
this.structuredTurnClients.add(client); this.structuredTurnClients.add(client);
}, },
@ -340,7 +346,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
} }
private assertActive(): void { private assertActive(): void {
if (this.disposed) throw new StaleAppServerResourceContextError(); if (this.disposed) throw new StaleExecutionRuntimeError();
} }
private tryCleanup(operation: () => void): boolean { private tryCleanup(operation: () => void): boolean {
@ -362,21 +368,15 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate, archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,
archiveExportTags: this.options.settings().archiveExportTags, archiveExportTags: this.options.settings().archiveExportTags,
}), }),
codexPath: () => this.context.codexPath,
scrollThreadFromComposerEdges: () => this.options.settings().scrollThreadFromComposerEdges, scrollThreadFromComposerEdges: () => this.options.settings().scrollThreadFromComposerEdges,
sendShortcut: () => this.options.settings().sendShortcut, sendShortcut: () => this.options.settings().sendShortcut,
showToolbar: () => this.options.settings().showToolbar, showToolbar: () => this.options.settings().showToolbar,
threadNamingEffort: () => this.options.settings().threadNamingEffort,
threadNamingModel: () => this.options.settings().threadNamingModel,
}; };
} }
private threadsSettings(): ThreadsViewSettingsAccess { private threadsSettings(): ThreadsViewSettingsAccess {
return { return {
archiveExportEnabled: () => this.options.settings().archiveExportEnabled, archiveExportEnabled: () => this.options.settings().archiveExportEnabled,
codexPath: () => this.context.codexPath,
threadNamingModel: () => this.options.settings().threadNamingModel,
threadNamingEffort: () => this.options.settings().threadNamingEffort,
archiveExportSettings: () => ({ archiveExportSettings: () => ({
archiveExportFolderTemplate: this.options.settings().archiveExportFolderTemplate, archiveExportFolderTemplate: this.options.settings().archiveExportFolderTemplate,
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate, archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import {
import { jsonPreview } from "../../../../../domain/display/json-preview"; import { jsonPreview } from "../../../../../domain/display/json-preview";
import type { HistoricalTurn } from "../../../../../domain/threads/history"; import type { HistoricalTurn } from "../../../../../domain/threads/history";
import type { TurnTranscriptSummary } from "../../../../../domain/threads/transcript"; 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 { threadStreamFileReferences } from "../../../domain/thread-stream/format/file-references";
import { normalizeProposedPlanMarkdown } from "../../../domain/thread-stream/format/proposed-plan"; import { normalizeProposedPlanMarkdown } from "../../../domain/thread-stream/format/proposed-plan";
import { userMessageDisplayText } from "../../../domain/thread-stream/format/user-message-text"; 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 text = projection.text;
const referencedThread = projection.referencedThread; const referencedThread = projection.referencedThread;
const referencedFiles = threadStreamFileReferences(projection.fileReferences); const referencedFiles = threadStreamFileReferences(projection.fileReferences);
const contextAttachments = contextAttachmentsFromManifest(projection.manifest, text); const contextAttachments = contextAttachmentsFromHistoryContexts(projection.contexts, text);
if (referencedThread) { if (referencedThread) {
return { return {
...turnItemSourceFields(item, turnId), ...turnItemSourceFields(item, turnId),

View file

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

View file

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

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 { 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 { export interface NoteCandidateProvider {
candidates(sourcePath: string): readonly NoteCandidate[]; candidates(sourcePath: string): readonly NoteCandidate[];

View file

@ -19,7 +19,7 @@ import {
type SelectionContextReference, type SelectionContextReference,
selectionContextReferenceMarker, selectionContextReferenceMarker,
} from "./context-references"; } 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 { isSlashCommandName, SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands";
import { import {
partialThreadTitleQuery, partialThreadTitleQuery,
@ -51,22 +51,6 @@ export interface ComposerSuggestionOptions {
tagCandidates?: readonly string[] | (() => readonly string[]); 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 { interface NoteCandidateMatch {
file: NoteCandidate; file: NoteCandidate;
match: SearchResult; match: SearchResult;

View file

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

View file

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

View file

@ -19,7 +19,7 @@ export interface ServerMetadataActionsHost {
refreshAppServerMetadata: () => Promise<void>; refreshAppServerMetadata: () => Promise<void>;
refreshSkills: () => Promise<void>; refreshSkills: () => Promise<void>;
refreshRateLimits: () => Promise<void>; refreshRateLimits: () => Promise<void>;
isStaleResourceContextError: (error: unknown) => boolean; isStaleRuntimeError: (error: unknown) => boolean;
} }
export interface ServerMetadataActions { export interface ServerMetadataActions {
@ -106,7 +106,7 @@ async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promis
try { try {
await host.refreshAppServerMetadata(); await host.refreshAppServerMetadata();
} catch (error) { } catch (error) {
if (host.isStaleResourceContextError(error)) return; if (host.isStaleRuntimeError(error)) return;
throw error; throw error;
} }
} }
@ -115,7 +115,7 @@ async function refreshMetadataResource(host: ServerMetadataActionsHost, refresh:
try { try {
await refresh(); await refresh();
} catch (error) { } 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 phase: "active";
readonly lifetime: "persistent" | "ephemeral"; readonly lifetime: "persistent" | "ephemeral";
readonly canAcceptDirectInput: boolean | null;
readonly provenance: "interactive" | "subagent" | null; readonly provenance: "interactive" | "subagent" | null;
}; };
@ -49,6 +50,13 @@ function activePanelOperationDecisionForFacts(
} }
if (facts.phase === "empty") return ALLOWED; 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 // Keep the stricter interpretation if a malformed or future state contains
// both mode facts. This makes mode restrictions monotonically safe. // both mode facts. This makes mode restrictions monotonically safe.
if (facts.provenance === "subagent" && facts.lifetime === "ephemeral") { if (facts.provenance === "subagent" && facts.lifetime === "ephemeral") {
@ -68,10 +76,15 @@ function activePanelThreadFacts(state: ChatState): ActivePanelThreadFacts {
return { return {
phase: "active", phase: "active",
lifetime: panelThread.thread.lifetime?.kind ?? "persistent", lifetime: panelThread.thread.lifetime?.kind ?? "persistent",
canAcceptDirectInput: panelThread.thread.canAcceptDirectInput,
provenance: panelThread.thread.provenance?.kind ?? null, provenance: panelThread.thread.provenance?.kind ?? null,
}; };
} }
function directInputBlocked(): ActivePanelOperationDecision {
return { kind: "blocked", message: "This thread cannot accept messages." };
}
function agentThreadBlocked(operation: ActivePanelOperation): ActivePanelOperationDecision { function agentThreadBlocked(operation: ActivePanelOperation): ActivePanelOperationDecision {
switch (operation) { switch (operation) {
case "submit": case "submit":

View file

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

View file

@ -5,21 +5,28 @@ import {
type ThreadRenameLifecycleEvent, type ThreadRenameLifecycleEvent,
type ThreadRenameLifecycleState, type ThreadRenameLifecycleState,
threadRenameGenerationStillActive, threadRenameGenerationStillActive,
threadRenameSaveStillActive,
transitionThreadRenameLifecycleState, transitionThreadRenameLifecycleState,
} from "../../../../domain/threads/rename-lifecycle"; } from "../../../../domain/threads/rename-lifecycle";
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
import type { DisclosureSetAction } from "./actions"; import type { DisclosureSetAction } from "./actions";
import { patchObject } from "./patch"; import { patchObject } from "./patch";
export type ChatRenameUiState = { readonly kind: "idle" } | (ThreadRenameActiveState & { readonly threadId: string }); export type ChatRenameUiState = { readonly kind: "idle" } | (ThreadRenameActiveState & { readonly threadId: string });
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>; export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
export type ChatRenameSavingUiState = Extract<ChatRenameUiState, { kind: "saving" }>;
type ChatRenameUiAction = Extract< type ChatRenameUiAction = Extract<
UiAction, UiAction,
{ {
type: type:
| "ui/rename-started" | "ui/rename-started"
| "ui/rename-draft-updated" | "ui/rename-draft-updated"
| "ui/rename-auto-name-context-resolved"
| "ui/rename-cancelled" | "ui/rename-cancelled"
| "ui/rename-save-started"
| "ui/rename-save-failed"
| "ui/rename-save-succeeded"
| "ui/rename-generation-started" | "ui/rename-generation-started"
| "ui/rename-generation-succeeded" | "ui/rename-generation-succeeded"
| "ui/rename-generation-finished" | "ui/rename-generation-finished"
@ -71,7 +78,11 @@ export type UiAction =
| { type: "ui/archive-confirm-set"; threadId: string | null } | { type: "ui/archive-confirm-set"; threadId: string | null }
| { type: "ui/rename-started"; threadId: string; draft: string } | { type: "ui/rename-started"; threadId: string; draft: string }
| { type: "ui/rename-draft-updated"; 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-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-started"; threadId: string; generationToken: number }
| { type: "ui/rename-generation-succeeded"; threadId: string; generationToken: number; draft: string } | { type: "ui/rename-generation-succeeded"; threadId: string; generationToken: number; draft: string }
| { type: "ui/rename-generation-finished"; threadId: string; generationToken: number } | { 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/archive-confirm-set":
case "ui/rename-started": case "ui/rename-started":
case "ui/rename-draft-updated": case "ui/rename-draft-updated":
case "ui/rename-auto-name-context-resolved":
case "ui/rename-cancelled": 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-started":
case "ui/rename-generation-succeeded": case "ui/rename-generation-succeeded":
case "ui/rename-generation-finished": case "ui/rename-generation-finished":
@ -123,7 +138,11 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState
return patchObject(state, { archiveConfirmThreadId: action.threadId }); return patchObject(state, { archiveConfirmThreadId: action.threadId });
case "ui/rename-started": case "ui/rename-started":
case "ui/rename-draft-updated": case "ui/rename-draft-updated":
case "ui/rename-auto-name-context-resolved":
case "ui/rename-cancelled": 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-started":
case "ui/rename-generation-succeeded": case "ui/rename-generation-succeeded":
case "ui/rename-generation-finished": case "ui/rename-generation-finished":
@ -172,6 +191,10 @@ export function renameGenerationStillActive(
return state.kind === "generating" && state.threadId === threadId && threadRenameGenerationStillActive(state, generationToken); 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 { export function clearAllRequestDisclosures(state: ChatUiState): ChatUiState {
if (state.disclosures.approvalDetails.size === 0) return state; if (state.disclosures.approvalDetails.size === 0) return state;
return patchObject(state, { return patchObject(state, {
@ -253,11 +276,23 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string)
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState { function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
switch (action.type) { switch (action.type) {
case "ui/rename-started": 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": case "ui/rename-draft-updated":
return transitionScopedChatRenameUiState(state, action.threadId, { type: "draft-updated", draft: action.draft }); 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": case "ui/rename-cancelled":
return transitionScopedChatRenameUiState(state, action.threadId, { type: "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": case "ui/rename-generation-started":
return transitionScopedChatRenameUiState(state, action.threadId, { return transitionScopedChatRenameUiState(state, action.threadId, {
type: "generation-started", type: "generation-started",
@ -304,12 +339,14 @@ function chatRenameLifecycleStateWithoutThreadId(state: ChatRenameUiState): Thre
function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState, { kind: "idle" }>): ThreadRenameActiveState { function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState, { kind: "idle" }>): ThreadRenameActiveState {
switch (state.kind) { switch (state.kind) {
case "editing": 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": case "generating":
return { return {
kind: "generating", kind: "generating",
draft: state.draft, draft: state.draft,
originalDraft: state.originalDraft, autoName: state.autoName,
generationToken: state.generationToken, 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 { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store"; import type { ChatStateStore } from "../state/store";
import { threadStreamItems } from "../state/thread-stream"; 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"; import { firstThreadTitleContextFromThreadStreamItems } from "./title-context";
interface RenameEditState { interface RenameEditState {
@ -16,7 +16,8 @@ export interface ThreadRenameEditorActionsHost {
ensureConnected: () => Promise<void>; ensureConnected: () => Promise<void>;
addSystemMessage: (text: string) => void; addSystemMessage: (text: string) => void;
renameThread(threadId: string, value: string): Promise<boolean>; 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 { export interface ThreadRenameEditorActions {
@ -26,15 +27,22 @@ export interface ThreadRenameEditorActions {
start(threadId: string): void; start(threadId: string): void;
updateDraft(threadId: string, value: string): void; updateDraft(threadId: string, value: string): void;
cancel(threadId: string): void; cancel(threadId: string): void;
cancelAutoName(threadId: string): void;
save(threadId: string, value: string): Promise<void>; save(threadId: string, value: string): Promise<void>;
autoNameDraft(threadId: string): Promise<void>; autoNameDraft(threadId: string): Promise<void>;
} }
export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions { export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions {
let nextRenameGenerationToken = 1; let nextRenameGenerationToken = 1;
let nextRenameSaveToken = 1;
let activeGeneration: { threadId: string; generationToken: number; controller: AbortController } | null = null;
let activeContextPreparation: { threadId: string } | null = null;
const action = { const action = {
invalidate(): void { invalidate(): void {
activeGeneration?.controller.abort();
activeGeneration = null;
activeContextPreparation = null;
dispatch(host, { type: "ui/rename-cleared" }); dispatch(host, { type: "ui/rename-cleared" });
}, },
@ -52,9 +60,13 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
}, },
start(threadId: string): void { 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); const thread = host.stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
if (!thread) return; if (!thread) return;
abortActiveGeneration();
dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) }); dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) });
void prepareAutoName(threadId);
}, },
updateDraft(threadId: string, value: string): void { updateDraft(threadId: string, value: string): void {
@ -62,29 +74,38 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
}, },
cancel(threadId: string): void { 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 }); 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> { async save(threadId: string, value: string): Promise<void> {
const current = renameState(host); const current = renameState(host);
if (current.kind === "idle" || current.threadId !== threadId || current.kind === "generating") return; if (current.kind !== "editing" || current.threadId !== threadId) return;
const editingState = current; const saveToken = nextRenameSaveToken;
dispatch(host, { type: "ui/rename-save-started", threadId, saveToken });
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
nextRenameSaveToken += 1;
try { try {
await host.ensureConnected(); await host.ensureConnected();
if (renameState(host) !== editingState) return; if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
const result = await host.renameThread(threadId, value); await host.renameThread(threadId, value);
if (!result) { dispatch(host, { type: "ui/rename-save-succeeded", threadId, saveToken });
if (renameState(host) === editingState) action.cancel(threadId);
return;
}
if (renameState(host) === editingState) {
dispatch(host, { type: "ui/rename-cleared" });
}
} catch (error) { } catch (error) {
if (renameState(host) !== editingState) return; if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error)); 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); const current = renameState(host);
if (current.kind !== "editing" || current.threadId !== threadId) return; if (current.kind !== "editing" || current.threadId !== threadId) return;
const editingState = current;
await host.ensureConnected();
if (renameState(host) !== editingState) return;
dispatch(host, { dispatch(host, {
type: "ui/rename-generation-started", type: "ui/rename-generation-started",
threadId, threadId,
generationToken: nextRenameGenerationToken, generationToken: nextRenameGenerationToken,
}); });
const generationToken = nextRenameGenerationToken; const generationToken = nextRenameGenerationToken;
if (!renameGenerationStillActive(renameState(host), threadId, generationToken)) return; const generating = renameState(host);
if (!renameGenerationStillActive(generating, threadId, generationToken)) return;
nextRenameGenerationToken += 1; nextRenameGenerationToken += 1;
const controller = new AbortController();
activeGeneration = { threadId, generationToken, controller };
try { 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 }); dispatch(host, { type: "ui/rename-generation-succeeded", threadId, generationToken, draft: title });
} catch (error) { } catch (error) {
if (renameGenerationStillActive(renameState(host), threadId, generationToken)) { if (renameGenerationStillActive(renameState(host), threadId, generationToken)) {
host.addSystemMessage(error instanceof Error ? error.message : String(error)); host.addSystemMessage(error instanceof Error ? error.message : String(error));
} }
} finally { } finally {
clearGeneration(generationToken);
finishAutoNameDraftGeneration(host, threadId, generationToken); finishAutoNameDraftGeneration(host, threadId, generationToken);
} }
}, },
}; };
return action; 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 { 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 { activeThreadRuntimeState } from "../../domain/runtime/state";
import { effectCompleted, effectCompletedInCurrentContext } from "../effect-outcome"; import { effectCompleted, effectCompletedInCurrentContext } from "../effect-outcome";
import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy"; import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy";
import { resumedThreadActionFromActiveRuntime } from "../state/actions";
import { capturePanelTargetLease, type PanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target"; 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 type { ChatStateStore } from "../state/store";
import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream"; import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream";
import { chatTurnBusy } from "../turns/turn-state"; import { chatTurnBusy } from "../turns/turn-state";
@ -23,12 +22,23 @@ export interface ThreadManagementActionsHost {
setStatus: (status: string) => void; setStatus: (status: string) => void;
setComposerText: (text: string) => void; setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<void>; openThreadInNewView: (threadId: string) => Promise<void>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>; openThreadInCurrentPanel: (threadId: string, onAdopted: () => void) => Promise<CurrentPanelAdoption>;
notifyActiveThreadIdentityChanged: () => void; beginThreadForkPublication: (sourceThreadId: string) => ThreadForkPublication;
recordThread: (thread: Thread) => void;
threadHasPendingOrRunningPanel: (threadId: string) => boolean; 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 { interface ThreadManagementOperations {
renameThread(threadId: string, value: string): Promise<boolean>; renameThread(threadId: string, value: string): Promise<boolean>;
archiveThread(threadId: string, options?: { saveMarkdown?: boolean }): 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."); host.addSystemMessage("Could not find the selected turn to fork.");
return; return;
} }
let publication: ThreadForkPublication | null = null;
let publicationFinished = false;
let activityPublication: { publish(commit: () => void): void } | null = null;
try { try {
const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads); const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads);
if (!(await host.threadTransport.ensureConnected())) return; if (!(await host.threadTransport.ensureConnected())) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) 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; if (!effectCompleted(effect)) return;
const forkedThread = effect.value; const forkedThread = effect.value;
const forkedThreadId = forkedThread.id; const forkedThreadId = forkedThread.id;
host.recordThread(forkedThread); publication.record(forkedThread);
if (!effectCompletedInCurrentContext(effect)) return; if (!effectCompletedInCurrentContext(effect)) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
if (sourceName) { if (sourceName) {
@ -156,17 +172,32 @@ async function forkThreadFromTurn(
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
} }
if (archiveSource) { if (archiveSource) {
if (!(await archiveThreadFromPanel(host, threadId))) return; let adoption: CurrentPanelAdoption;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
try { try {
await host.openThreadInCurrentPanel(forkedThreadId); adoption = await host.openThreadInCurrentPanel(forkedThreadId, () => undefined);
} catch (error) { } catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
const message = error instanceof Error ? error.message : String(error); 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; return;
} }
publication.finish();
publicationFinished = true;
try { try {
await host.openThreadInNewView(forkedThreadId); await host.openThreadInNewView(forkedThreadId);
} catch (error) { } catch (error) {
@ -177,6 +208,11 @@ async function forkThreadFromTurn(
} catch (error) { } catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error)); 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."); host.addSystemMessage("No completed turn to roll back.");
return; 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 { try {
host.setStatus(STATUS_ROLLBACK_STARTING); host.setStatus(STATUS_ROLLBACK_STARTING);
if (!(await host.threadTransport.ensureConnected())) return; if (!(await host.threadTransport.ensureConnected())) return;
if (!threadManagementScopeStillTargetsPanel(host, scope)) return; if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
const effect = await host.threadTransport.rollbackThread(threadId); publication = host.beginThreadForkPublication(threadId);
if (!effectCompleted(effect)) return; const effect = await host.threadTransport.forkThread(threadId, {
if (!effectCompletedInCurrentContext(effect)) return; position: { kind: "before-turn", turnId: candidate.turnId },
const snapshot = effect.value; deferGoalContinuation: true,
if (!threadManagementScopeStillTargetsPanel(host, scope)) return; runtime: runtimeOverrides,
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,
}); });
host.setComposerText(candidate.text); if (!effectCompleted(effect)) return;
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted."); const forkedThread = effect.value;
host.setStatus(STATUS_ROLLBACK_COMPLETE); if (effectCompletedInCurrentContext(effect)) publication.record(forkedThread);
host.notifyActiveThreadIdentityChanged(); else return;
host.recordThread(snapshot.thread); 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) { } catch (error) {
if (!threadManagementScopeStillTargetsPanel(host, scope)) return; if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error)); host.addSystemMessage(error instanceof Error ? error.message : String(error));
host.setStatus(STATUS_ROLLBACK_FAILED); 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 { function activePanelOperationBlocked(host: ThreadManagementActionsHost, threadId: string, operation: ActivePanelOperation): boolean {
const state = threadManagementState(host); const state = threadManagementState(host);
if (activeThreadId(state) !== threadId) return false; if (activeThreadId(state) !== threadId) return false;
@ -254,10 +346,6 @@ function threadManagementState(host: ThreadManagementActionsHost): ChatState {
return host.stateStore.getState(); return host.stateStore.getState();
} }
function threadManagementDispatch(host: ThreadManagementActionsHost, action: ChatAction): void {
host.stateStore.dispatch(action);
}
function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, targetThreadId: string): ThreadManagementPanelScope { function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, targetThreadId: string): ThreadManagementPanelScope {
return { return {
targetThreadId, 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 { Thread } from "../../../../domain/threads/model";
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
import type { EffectOutcome } from "../effect-outcome"; import type { EffectOutcome } from "../effect-outcome";
export interface ThreadRollbackSnapshot { type ThreadForkPosition =
thread: Thread; | { readonly kind: "through-turn"; readonly turnId: string }
items: ThreadStreamItem[]; | { 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 { export interface ThreadMutationTransport {
ensureConnected(): Promise<boolean>; ensureConnected(): Promise<boolean>;
compactThread(threadId: string): Promise<EffectOutcome<void>>; compactThread(threadId: string): Promise<EffectOutcome<void>>;
forkThread(threadId: string, lastTurnId?: string | null): Promise<EffectOutcome<Thread>>; forkThread(threadId: string, options?: ThreadForkOptions): Promise<EffectOutcome<Thread>>;
rollbackThread(threadId: string): Promise<EffectOutcome<ThreadRollbackSnapshot>>;
} }

View file

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

View file

@ -15,7 +15,6 @@ interface LocalUserDialogueParams {
text: string; text: string;
copyText?: string; copyText?: string;
turnId?: string; turnId?: string;
referencedThread?: ThreadStreamDialogueItem["referencedThread"];
referencedFiles?: readonly ThreadStreamFileReference[]; referencedFiles?: readonly ThreadStreamFileReference[];
contextAttachments?: ThreadStreamDialogueItem["contextAttachments"]; contextAttachments?: ThreadStreamDialogueItem["contextAttachments"];
} }
@ -64,7 +63,6 @@ function localUserDialogueItem(params: LocalUserDialogueParams): ThreadStreamDia
provenance: localUserDialogueProvenance(params.clientId ?? params.id, params.interaction), provenance: localUserDialogueProvenance(params.clientId ?? params.id, params.interaction),
...(params.clientId ? { clientId: params.clientId } : {}), ...(params.clientId ? { clientId: params.clientId } : {}),
...(params.turnId ? { turnId: params.turnId } : {}), ...(params.turnId ? { turnId: params.turnId } : {}),
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
...(referencedFiles.length > 0 ? { referencedFiles: [...referencedFiles] } : {}), ...(referencedFiles.length > 0 ? { referencedFiles: [...referencedFiles] } : {}),
...(contextAttachments.length > 0 ? { contextAttachments: [...contextAttachments] } : {}), ...(contextAttachments.length > 0 ? { contextAttachments: [...contextAttachments] } : {}),
}; };
@ -87,7 +85,6 @@ export function localUserDialogueItemFromInput(params: LocalUserDialogueFromInpu
text: userMessageDisplayText(params.text, params.codexInput), text: userMessageDisplayText(params.text, params.codexInput),
copyText: params.text, copyText: params.text,
...(params.turnId ? { turnId: params.turnId } : {}), ...(params.turnId ? { turnId: params.turnId } : {}),
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
referencedFiles: fileReferencesFromInput([...params.codexInput]), referencedFiles: fileReferencesFromInput([...params.codexInput]),
contextAttachments: contextAttachmentsFromInput(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 type { ThreadGoal } from "../../../../domain/threads/goal";
import { shortThreadId } from "../../../../domain/threads/id"; import { shortThreadId } from "../../../../domain/threads/id";
import type { Thread } from "../../../../domain/threads/model"; import type { Thread } from "../../../../domain/threads/model";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import { resolveThreadSearchQuery } from "../../../../domain/threads/search"; import { resolveThreadSearchQuery } from "../../../../domain/threads/search";
import { threadDisplayTitle } from "../../../../domain/threads/title"; import { threadDisplayTitle } from "../../../../domain/threads/title";
import { modelOverrideMessage, permissionProfileOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels"; import { modelOverrideMessage, permissionProfileOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
@ -79,14 +78,12 @@ export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts
export interface SlashCommandExecutionResult { export interface SlashCommandExecutionResult {
sendText?: string; sendText?: string;
sendInput?: CodexInput; sendInput?: CodexInput;
referencedThread?: ReferencedThreadMetadata;
composerDraft?: string; composerDraft?: string;
} }
export interface ThreadReferenceInput { export interface ThreadReferenceInput {
text: string; text: string;
input: CodexInput; input: CodexInput;
referencedThread: ReferencedThreadMetadata;
} }
export interface WebUrlInput { export interface WebUrlInput {
@ -150,7 +147,7 @@ export async function executeSlashCommand(
} }
const reference = await context.referThread(thread.thread, parsed.message, context.inputSnapshot); const reference = await context.referThread(thread.thread, parsed.message, context.inputSnapshot);
if (!reference) return; if (!reference) return;
return { sendText: reference.text, sendInput: reference.input, referencedThread: reference.referencedThread }; return { sendText: reference.text, sendInput: reference.input };
} }
case "web": { case "web": {
const parsed = parseWebCommandArgs(args); const parsed = parseWebCommandArgs(args);

View file

@ -1,5 +1,4 @@
import { type CodexInput, codexTextInput } from "../../../../domain/chat/input"; import { type CodexInput, codexTextInput } from "../../../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import type { ComposerInputSnapshot } from "../composer/input-snapshot"; import type { ComposerInputSnapshot } from "../composer/input-snapshot";
import type { LocalIdSource } from "../local-id-source"; import type { LocalIdSource } from "../local-id-source";
import { activePanelOperationDecision } from "../panel-operation-policy"; import { activePanelOperationDecision } from "../panel-operation-policy";
@ -59,7 +58,6 @@ export interface TurnSubmissionRequest {
text: string; text: string;
inputSnapshot?: ComposerInputSnapshot; inputSnapshot?: ComposerInputSnapshot;
codexInputOverride?: CodexInput; codexInputOverride?: CodexInput;
referencedThread?: ReferencedThreadMetadata;
preserveComposerContextOnFailure?: boolean; preserveComposerContextOnFailure?: boolean;
pendingSubmissionId?: string; pendingSubmissionId?: string;
failureDraft?: string; failureDraft?: string;
@ -85,7 +83,7 @@ async function sendTurnText(
localItemIds: LocalIdSource, localItemIds: LocalIdSource,
request: TurnSubmissionRequest, request: TurnSubmissionRequest,
): Promise<boolean> { ): Promise<boolean> {
const { text, inputSnapshot, codexInputOverride, referencedThread } = request; const { text, inputSnapshot, codexInputOverride } = request;
let panelTarget = capturePanelTargetLease(host.stateStore.getState()); let panelTarget = capturePanelTargetLease(host.stateStore.getState());
const prepared = codexInputOverride const prepared = codexInputOverride
? { text, input: codexInputOverride } ? { text, input: codexInputOverride }
@ -115,7 +113,7 @@ async function sendTurnText(
if (pendingRequestIsCurrent(host, request)) host.addSystemMessage(plan.message); if (pendingRequestIsCurrent(host, request)) host.addSystemMessage(plan.message);
return false; return false;
case "steer": 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": case "start-thread-then-turn":
if (!commitPendingRequest(host, request)) return false; if (!commitPendingRequest(host, request)) return false;
{ {
@ -163,7 +161,6 @@ async function sendTurnText(
...(request.pendingSubmissionId ? { clientId: clientUserMessageId } : {}), ...(request.pendingSubmissionId ? { clientId: clientUserMessageId } : {}),
text: prepared.text, text: prepared.text,
codexInput: prepared.input, codexInput: prepared.input,
referencedThread,
}); });
host.stateStore.dispatch({ host.stateStore.dispatch({
type: "turn/optimistic-started", type: "turn/optimistic-started",
@ -267,7 +264,6 @@ async function steerCurrentTurn(
text: string, text: string,
prepared: { text: string; input: CodexInput }, prepared: { text: string; input: CodexInput },
request: TurnSubmissionRequest, request: TurnSubmissionRequest,
referencedThread?: ReferencedThreadMetadata,
): Promise<boolean> { ): Promise<boolean> {
if (!pendingRequestIsCurrent(host, request)) return false; if (!pendingRequestIsCurrent(host, request)) return false;
if (!commitPendingRequest(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 } : {}), ...(request.pendingSubmissionId ? { clientId: localSteerId, interaction: "steer" as const } : {}),
text: prepared.text, text: prepared.text,
turnId: plan.turnId, turnId: plan.turnId,
referencedThread,
codexInput: prepared.input, codexInput: prepared.input,
}); });
host.stateStore.dispatch( 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 { RUNNING_EXECUTION_STATE, type ThreadStreamItem, type ThreadStreamItemKind } from "../items";
import type { ThreadStreamItem, ThreadStreamItemKind } from "../items";
export const STREAMED_COMMAND_RUNNING_TEXT = "Command running"; export const STREAMED_COMMAND_RUNNING_TEXT = "Command running";
export const STREAMED_MCP_PROGRESS_LABEL = "mcp progress"; 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 { CodexInputItem } from "../../../../../domain/chat/input";
import type { ThreadStreamContextAttachment } from "../items"; 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[] { export function contextAttachmentsFromInput(input: readonly CodexInputItem[]): ThreadStreamContextAttachment[] {
return input.flatMap((item) => { 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); const source = webContextSource(item.value);
return [{ label: "Web page", ...(source ? { detail: source } : {}) }]; 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 attachments: ThreadStreamContextAttachment[] = [];
const web = manifest?.contexts.find((context) => context.kind === "web"); const web = contexts.find((context) => context.kind === "web");
if (web) { if (web) {
const source = visibleWebSource(visibleText); const source = visibleWebSource(visibleText);
attachments.push({ label: web.truncated ? "Web page (truncated)" : "Web page", ...(source ? { detail: source } : {}) }); 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) { if (obsidian) {
attachments.push({ label: obsidian.inlineExcerpts ? "Obsidian excerpt (truncated)" : "Obsidian context (truncated)" }); 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 { interface UserMessageDisplayInputItem {
type: string; type: string;
name?: string; name?: string;
@ -12,7 +13,7 @@ export function userMessageDisplayText(text: string, input: readonly UserMessage
const codeRanges = markdownCodeRanges(text); const codeRanges = markdownCodeRanges(text);
return text.replace(pattern, (match: string, prefix: string, name: string, offset: number) => { return text.replace(pattern, (match: string, prefix: string, name: string, offset: number) => {
const dollarIndex = offset + prefix.length; 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}`; 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 { function escapeRegExp(value: string): string {
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
} }

View file

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

View file

@ -31,14 +31,14 @@ export function createChatComposerController(
contextReferenceProvider: new VaultComposerContextReferenceProvider(environment.obsidian.app), contextReferenceProvider: new VaultComposerContextReferenceProvider(environment.obsidian.app),
attachmentHandler: createVaultComposerAttachmentHandler({ attachmentHandler: createVaultComposerAttachmentHandler({
app: environment.obsidian.app, app: environment.obsidian.app,
attachmentFolder: () => environment.plugin.settingsRef.settings.attachmentFolder(), attachmentFolder: () => environment.plugin.settings.attachmentFolder(),
}), }),
sourcePath: () => environment.obsidian.app.workspace.getActiveFile()?.path ?? "", sourcePath: () => environment.obsidian.app.workspace.getActiveFile()?.path ?? "",
stateStore, stateStore,
viewId: environment.obsidian.viewId, viewId: environment.obsidian.viewId,
referenceActiveNoteOnSend: () => environment.plugin.settingsRef.settings.referenceActiveNoteOnSend(), referenceActiveNoteOnSend: () => environment.plugin.settings.referenceActiveNoteOnSend(),
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(), sendShortcut: () => environment.plugin.settings.sendShortcut(),
scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges(), scrollThreadFromComposerEdges: () => environment.plugin.settings.scrollThreadFromComposerEdges(),
canInterrupt: (model) => { canInterrupt: (model) => {
return model.turnBusy && Boolean(model.activeThreadId && model.activeTurnId); 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 { AppServerClient, AppServerServerRequestResponder } from "../../../../app-server/connection/client";
import { type ConnectionManager, StaleConnectionError } from "../../../../app-server/connection/connection-manager"; 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 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 ChatInboundHandler, createChatInboundHandler } from "../../app-server/inbound/handler";
import { type ChatConnectionActions, createChatConnectionActions } from "../../application/connection/connection-actions"; import { type ChatConnectionActions, createChatConnectionActions } from "../../application/connection/connection-actions";
import type { ServerDiagnosticsTransport } from "../../application/connection/metadata-transport"; import type { ServerDiagnosticsTransport } from "../../application/connection/metadata-transport";
@ -127,7 +127,7 @@ export function createConnectionBundle(
refreshAppServerMetadata: () => environment.plugin.appServerQueries.refreshAppServerMetadata(), refreshAppServerMetadata: () => environment.plugin.appServerQueries.refreshAppServerMetadata(),
refreshSkills: () => environment.plugin.appServerQueries.refreshSkills(), refreshSkills: () => environment.plugin.appServerQueries.refreshSkills(),
refreshRateLimits: () => environment.plugin.appServerQueries.refreshRateLimits(), refreshRateLimits: () => environment.plugin.appServerQueries.refreshRateLimits(),
isStaleResourceContextError: isStaleAppServerResourceContextError, isStaleRuntimeError: isStaleExecutionRuntimeError,
}); });
const serverDiagnostics = createServerDiagnosticsActions({ const serverDiagnostics = createServerDiagnosticsActions({
stateStore, stateStore,
@ -135,7 +135,7 @@ export function createConnectionBundle(
appServerMetadataSnapshot: () => environment.plugin.appServerQueries.appServerMetadataSnapshot(), appServerMetadataSnapshot: () => environment.plugin.appServerQueries.appServerMetadataSnapshot(),
}); });
const refreshSharedThreads = async (): Promise<void> => { const refreshSharedThreads = async (): Promise<void> => {
await environment.plugin.threadCatalog.refreshActive(); await environment.plugin.threadCatalog.refreshActiveThreads();
}; };
const inboundHandler = createChatInboundHandler( const inboundHandler = createChatInboundHandler(
stateStore, stateStore,
@ -153,8 +153,8 @@ export function createConnectionBundle(
maybeNameThread: (threadId, turnId, completedTurnTranscriptSummary) => { maybeNameThread: (threadId, turnId, completedTurnTranscriptSummary) => {
autoTitleCoordinator.maybeAutoTitleThread(threadId, turnId, completedTurnTranscriptSummary); autoTitleCoordinator.maybeAutoTitleThread(threadId, turnId, completedTurnTranscriptSummary);
}, },
applyThreadCatalogEvent: (event) => { applyThreadOperationEvent: (event) => {
environment.plugin.threadCatalog.apply(event); environment.plugin.threadOperationCoordinator.apply(event);
}, },
respondToServerRequest: (requestId, result) => serverRequestResponders.respond(requestId, result), respondToServerRequest: (requestId, result) => serverRequestResponders.respond(requestId, result),
rejectServerRequest: (requestId, code, message) => serverRequestResponders.reject(requestId, code, message), rejectServerRequest: (requestId, code, message) => serverRequestResponders.reject(requestId, code, message),
@ -222,9 +222,9 @@ export function createConnectionBundle(
}, },
setStatus: status.set, setStatus: status.set,
addSystemMessage: status.addSystemMessage, addSystemMessage: status.addSystemMessage,
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath(), configuredCommand: () => environment.plugin.appServerContext.codexPath,
isStaleConnectionError: (error) => error instanceof StaleConnectionError, isStaleConnectionError: (error) => error instanceof StaleConnectionError,
isStaleResourceContextError: isStaleAppServerResourceContextError, isStaleRuntimeError: isStaleExecutionRuntimeError,
notifyConnectionFailed: () => { notifyConnectionFailed: () => {
new Notice("Codex app-server connection failed."); new Notice("Codex app-server connection failed.");
}, },

View file

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

View file

@ -68,7 +68,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
toolbarPanel: toolbarPanelActions, toolbarPanel: toolbarPanelActions,
rename, rename,
navigation, navigation,
loadMoreThreads: () => environment.plugin.threadCatalog.loadMoreActive(), loadMoreThreads: () => environment.plugin.threadCatalog.loadMoreActiveThreads(),
openSideChat: () => { openSideChat: () => {
const state = stateStore.getState(); const state = stateStore.getState();
if (activePanelOperationDecision(state, "start-side-chat").kind !== "allowed") return; if (activePanelOperationDecision(state, "start-side-chat").kind !== "allowed") return;
@ -89,13 +89,13 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
nowMs: () => Date.now(), nowMs: () => Date.now(),
}, },
settings: { settings: {
vaultPath: () => environment.plugin.settingsRef.vaultPath, vaultPath: () => environment.plugin.appServerContext.vaultPath,
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath(), configuredCommand: () => environment.plugin.appServerContext.codexPath,
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(), archiveExportEnabled: () => environment.plugin.settings.archiveExportEnabled(),
}, },
}; };
const goalSurface: ChatPanelGoalSurface = { const goalSurface: ChatPanelGoalSurface = {
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(), sendShortcut: () => environment.plugin.settings.sendShortcut(),
actions: goals, actions: goals,
}; };
const threadStreamContext = createChatThreadStreamSurfaceContext({ const threadStreamContext = createChatThreadStreamSurfaceContext({
@ -103,12 +103,13 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
app: environment.obsidian.app, app: environment.obsidian.app,
owner: environment.obsidian.owner, owner: environment.obsidian.owner,
stateStore, stateStore,
vaultPath: environment.plugin.settingsRef.vaultPath, vaultPath: environment.plugin.appServerContext.vaultPath,
loadOlderTurns: () => void history.loadOlder(), loadOlderTurns: () => void history.loadOlder(),
actions: { actions: {
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId), rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource), forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
implementPlan: (itemId) => void turn.turnActions.planImplementation.implement(itemId), implementPlan: (itemId) => void turn.turnActions.planImplementation.implement(itemId),
openThreadInAvailableView: (threadId) => void environment.plugin.workspace.openThreadInAvailableView(threadId),
openThreadInNewView: (threadId) => void environment.plugin.workspace.openThreadInNewView(threadId), openThreadInNewView: (threadId) => void environment.plugin.workspace.openThreadInNewView(threadId),
openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state), 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 type ChatPanelThreadNavigationActions = ReturnType<typeof createThreadNavigationActions>;
export interface ChatPanelThreadLifecycle { export interface ChatPanelThreadLifecycle {
history: HistoryController;
restoration: RestorationController; restoration: RestorationController;
resume: ResumeActions; resume: ResumeActions;
identity: ActiveThreadIdentitySync; identity: ActiveThreadIdentitySync;
@ -58,6 +57,7 @@ interface ChatPanelThreadHost {
showLatest(): void; showLatest(): void;
}; };
getClosing: () => boolean; getClosing: () => boolean;
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
} }
interface ChatPanelThreadFoundationInput { interface ChatPanelThreadFoundationInput {
@ -80,19 +80,16 @@ interface ChatPanelThreadLifecycleInput {
appServer: ChatAppServerGateway; appServer: ChatAppServerGateway;
localItemIds: LocalIdSource; localItemIds: LocalIdSource;
ensureConnected: () => Promise<void>; ensureConnected: () => Promise<void>;
ensureInitialized: () => Promise<void>;
status: ChatPanelThreadStatus; status: ChatPanelThreadStatus;
threadStart: ThreadStartActions; threadStart: ThreadStartActions;
foundation: ChatPanelThreadFoundation; foundation: ChatPanelThreadFoundation;
notifyActiveThreadIdentityChanged: () => void; notifyActiveThreadIdentityChanged: () => void;
} }
interface ChatPanelThreadLifecycleBundle { interface ChatPanelThreadLifecycleBundle extends ChatPanelThreadLifecycle {
goals: ChatPanelGoalActions; goals: ChatPanelGoalActions;
rename: ThreadRenameEditorActions; rename: ThreadRenameEditorActions;
lifecycle: ChatPanelThreadLifecycle;
identity: ActiveThreadIdentitySync;
restoration: RestorationController;
resume: ResumeActions;
} }
interface ChatPanelThreadActionInput { interface ChatPanelThreadActionInput {
@ -125,13 +122,13 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan
transport: threadOperationsTransport, transport: threadOperationsTransport,
nameMutations: environment.plugin.threadNameMutations, nameMutations: environment.plugin.threadNameMutations,
archiveExport: { archiveExport: {
settings: () => environment.plugin.settingsRef.settings.archiveExportSettings(), settings: () => environment.plugin.settings.archiveExportSettings(),
enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(), enabled: () => environment.plugin.settings.archiveExportEnabled(),
vaultPath: environment.plugin.settingsRef.vaultPath, vaultPath: environment.plugin.appServerContext.vaultPath,
vaultConfigDir: environment.obsidian.app.vault.configDir, vaultConfigDir: environment.obsidian.app.vault.configDir,
}, },
archiveDestination: environment.obsidian.archiveDestination, archiveDestination: environment.obsidian.archiveDestination,
catalog: environment.plugin.threadCatalog, operationEvents: environment.plugin.threadOperationCoordinator,
referenceThreads: () => stateStore.getState().threadList.listedThreads, referenceThreads: () => stateStore.getState().threadList.listedThreads,
notice: (text) => { notice: (text) => {
new Notice(text); new Notice(text);
@ -196,9 +193,19 @@ export function createThreadLifecycleBundle(
host: ChatPanelThreadHost, host: ChatPanelThreadHost,
input: ChatPanelThreadLifecycleInput, input: ChatPanelThreadLifecycleInput,
): ChatPanelThreadLifecycleBundle { ): ChatPanelThreadLifecycleBundle {
const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, notifyActiveThreadIdentityChanged } = input; const {
appServer,
localItemIds,
ensureConnected,
ensureInitialized,
status,
threadStart,
foundation,
notifyActiveThreadIdentityChanged,
} = input;
const lifecycle = createSessionThreadLifecycle(host, { const lifecycle = createSessionThreadLifecycle(host, {
appServer, appServer,
ensureInitialized,
status, status,
goalSync: foundation.goalSync, goalSync: foundation.goalSync,
autoTitleCoordinator: foundation.autoTitleCoordinator, autoTitleCoordinator: foundation.autoTitleCoordinator,
@ -232,14 +239,14 @@ export function createThreadLifecycleBundle(
ensureConnected, ensureConnected,
addSystemMessage: status.addSystemMessage, addSystemMessage: status.addSystemMessage,
renameThread: (threadId, value) => foundation.threadOperations.renameThread(threadId, value), 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; const { identity, restoration, resume } = lifecycle;
return { return {
goals, goals,
rename, rename,
lifecycle,
identity, identity,
restoration, restoration,
resume, resume,
@ -247,7 +254,7 @@ export function createThreadLifecycleBundle(
} }
export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatPanelThreadActionInput): ChatPanelThreadActionBundle { 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 { environment, stateStore } = host;
const threadManagementHost: ThreadManagementActionsHost = { const threadManagementHost: ThreadManagementActionsHost = {
stateStore, stateStore,
@ -265,13 +272,25 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP
composerController.setDraft(text, { focus: true }); composerController.setDraft(text, { focus: true });
}, },
openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId), openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId),
openThreadInCurrentPanel: async (threadId) => { openThreadInCurrentPanel: async (threadId, onAdopted) => {
await lifecycle.resume.resumeThread(threadId); const activityPublication = host.beginPanelActivityPublication(threadId);
}, const adoption = { completed: false };
notifyActiveThreadIdentityChanged, try {
recordThread: (thread) => { await lifecycle.resume.resumeThread(threadId, undefined, {
environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread }); 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), threadHasPendingOrRunningPanel: (threadId) => environment.plugin.workspace.threadHasPendingOrRunningPanel(threadId),
}; };
const actions = createThreadManagementActions(threadManagementHost); const actions = createThreadManagementActions(threadManagementHost);
@ -303,6 +322,7 @@ function createSessionThreadLifecycle(
host: ChatPanelThreadHost, host: ChatPanelThreadHost,
input: { input: {
appServer: ChatAppServerGateway; appServer: ChatAppServerGateway;
ensureInitialized: () => Promise<void>;
status: ChatPanelThreadStatus; status: ChatPanelThreadStatus;
goalSync: ChatPanelGoalSyncActions; goalSync: ChatPanelGoalSyncActions;
autoTitleCoordinator: AutoTitleCoordinator; autoTitleCoordinator: AutoTitleCoordinator;
@ -311,7 +331,16 @@ function createSessionThreadLifecycle(
notifyActiveThreadIdentityChanged: () => void; notifyActiveThreadIdentityChanged: () => void;
}, },
): ChatPanelThreadLifecycle { ): ChatPanelThreadLifecycle {
const { appServer, status, goalSync, autoTitleCoordinator, history, invalidateThreadWork, notifyActiveThreadIdentityChanged } = input; const {
appServer,
ensureInitialized,
status,
goalSync,
autoTitleCoordinator,
history,
invalidateThreadWork,
notifyActiveThreadIdentityChanged,
} = input;
const restoration = new RestorationController({ const restoration = new RestorationController({
stateStore: host.stateStore, stateStore: host.stateStore,
}); });
@ -320,14 +349,20 @@ function createSessionThreadLifecycle(
}; };
const resume = createResumeActions({ const resume = createResumeActions({
stateStore: host.stateStore, stateStore: host.stateStore,
resumeTransport: appServer.threadResume, resumeTransport: {
ensureConnected: async () => {
await ensureInitialized();
return appServer.connectionAvailable();
},
resumeThread: (threadId) => appServer.threadResume.resumeThread(threadId),
},
resumeWork: host.resumeWork, resumeWork: host.resumeWork,
history, history,
closing: host.getClosing, closing: host.getClosing,
resetThreadTurnPresence, resetThreadTurnPresence,
notifyActiveThreadIdentityChanged, notifyActiveThreadIdentityChanged,
recordResumedThread: (thread) => { recordResumedThread: (thread) => {
host.environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread }); host.environment.plugin.threadOperationCoordinator.apply({ type: "thread-upserted", thread });
}, },
addSystemMessage: status.addSystemMessage, addSystemMessage: status.addSystemMessage,
syncThreadGoal: (threadId) => goalSync.syncThreadGoal(threadId), syncThreadGoal: (threadId) => goalSync.syncThreadGoal(threadId),
@ -342,7 +377,6 @@ function createSessionThreadLifecycle(
}); });
return { return {
history,
restoration, restoration,
resume, resume,
identity, identity,

View file

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

View file

@ -1,7 +1,7 @@
import { type App, moment, normalizePath, TFile } from "obsidian"; import { type App, moment, normalizePath, TFile } from "obsidian";
import { appHasDailyNotesPluginLoaded, getDailyNoteSettings, type IPeriodicNoteSettings } from "obsidian-daily-notes-interface"; 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"; import { displayNameForFile, linktextForFile } from "./vault-note-links.obsidian";
const RELATIVE_DAILY_NOTES = [ const RELATIVE_DAILY_NOTES = [

View file

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

View file

@ -49,7 +49,7 @@ async function readUrlToInput(
return { return {
text, text,
input: codexTextInputWithAttachments(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, ...messageInput.input,
]), ]),
}; };

View file

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

View file

@ -21,6 +21,8 @@ export class ChatPanelSession implements ChatPanelHandle {
private opened = false; private opened = false;
private closing = false; private closing = false;
private observedPanelActivity: PanelActivity | null = null; private observedPanelActivity: PanelActivity | null = null;
private readonly panelActivityPublicationSlots: PanelActivityPublicationSlot[] = [];
private activePanelActivityHold: PanelActivityHold | null = null;
private unsubscribePanelActivity: (() => void) | null = null; private unsubscribePanelActivity: (() => void) | null = null;
private pendingRuntimeRestore: ChatPanelRuntimeSnapshot | null; private pendingRuntimeRestore: ChatPanelRuntimeSnapshot | null;
@ -85,10 +87,6 @@ export class ChatPanelSession implements ChatPanelHandle {
this.mountOrRepairShell(); this.mountOrRepairShell();
} }
private async reconnect(): Promise<void> {
await this.runtime.actions.reconnect();
}
runtimeSnapshot(): ChatPanelRuntimeSnapshot { runtimeSnapshot(): ChatPanelRuntimeSnapshot {
const lifetime = activeThreadState(this.state)?.lifetime; const lifetime = activeThreadState(this.state)?.lifetime;
return { return {
@ -104,10 +102,15 @@ export class ChatPanelSession implements ChatPanelHandle {
openPanelSnapshot(): ChatWorkspacePanelSnapshot { openPanelSnapshot(): ChatWorkspacePanelSnapshot {
const activity = panelActivity(this.state); const activity = panelActivity(this.state);
const publishedActivity = this.activePanelActivityHold?.activity ?? activity;
return { return {
viewId: this.environment.obsidian.viewId, viewId: this.environment.obsidian.viewId,
...activity, ...activity,
threadId: this.closing ? null : activity.threadId, threadId: this.closing ? null : activity.threadId,
publishedActivity: {
...publishedActivity,
threadId: this.closing ? null : publishedActivity.threadId,
},
hasComposerDraft: this.state.composer.draft.trim().length > 0, hasComposerDraft: this.state.composer.draft.trim().length > 0,
connected: this.runtime.connection.manager.isConnected(), connected: this.runtime.connection.manager.isConnected(),
}; };
@ -214,7 +217,7 @@ export class ChatPanelSession implements ChatPanelHandle {
if (!root) return; if (!root) return;
renderChatPanelShell(root, { renderChatPanelShell(root, {
stateStore: this.stateStore, stateStore: this.stateStore,
showToolbar: this.environment.plugin.settingsRef.settings.showToolbar(), showToolbar: this.environment.plugin.settings.showToolbar(),
parts: this.runtime.shell.parts, parts: this.runtime.shell.parts,
}); });
} }
@ -264,6 +267,9 @@ export class ChatPanelSession implements ChatPanelHandle {
const next = panelActivity(this.state); const next = panelActivity(this.state);
if (panelActivityEquals(this.observedPanelActivity, next)) return; if (panelActivityEquals(this.observedPanelActivity, next)) return;
this.observedPanelActivity = next; 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();
}); });
this.notifyPanelActivityChanged(); this.notifyPanelActivityChanged();
@ -273,6 +279,47 @@ export class ChatPanelSession implements ChatPanelHandle {
this.environment.plugin.workspace.notifyPanelActivityChanged(); 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 { private activeThreadTitle(): string | null {
const activeThread = activeThreadState(this.state); const activeThread = activeThreadState(this.state);
if (!activeThread) return null; if (!activeThread) return null;
@ -310,7 +357,7 @@ export class ChatPanelSession implements ChatPanelHandle {
resumeWork: this.resumeWork, resumeWork: this.resumeWork,
threadStreamScrollBinding: this.threadStreamScrollBinding, threadStreamScrollBinding: this.threadStreamScrollBinding,
getClosing: () => this.closing, getClosing: () => this.closing,
reconnect: () => this.reconnect(), beginPanelActivityPublication: (replacementThreadId) => this.beginPanelActivityPublication(replacementThreadId),
}); });
} }
} }
@ -321,6 +368,17 @@ interface PanelActivity {
readonly pending: boolean; 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 { function panelActivity(state: ChatState): PanelActivity {
return { return {
threadId: panelThreadId(state), threadId: panelThreadId(state),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -206,11 +206,18 @@ function toolbarThreadRows(input: {
title: core.title, title: core.title,
threadId: core.threadId, threadId: core.threadId,
selected: core.selected, selected: core.selected,
disabled: input.turnBusy && threadId !== input.activeThreadId, renameDisabled: input.renameState.kind === "saving",
openDisabled: false, archiveDisabled: input.turnBusy,
canArchive: true, canArchive: true,
archiveConfirm: core.archiveConfirm, 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) => { cancel: (threadId) => {
deps.rename.cancel(threadId); deps.rename.cancel(threadId);
}, },
cancelAutoName: (threadId) => {
deps.rename.cancelAutoName(threadId);
},
autoName: (threadId) => { autoName: (threadId) => {
void deps.rename.autoNameDraft(threadId); void deps.rename.autoNameDraft(threadId);
}, },

View file

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

View file

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

View file

@ -12,13 +12,15 @@ export interface ToolbarThreadRow {
title: string; title: string;
threadId: string; threadId: string;
selected: boolean; selected: boolean;
disabled: boolean; renameDisabled: boolean;
openDisabled?: boolean; archiveDisabled: boolean;
canArchive: boolean; canArchive: boolean;
archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean }; archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean };
rename: { rename: {
draft: string; draft: string;
generating: boolean; generating: boolean;
saving: boolean;
autoNameDisabled: boolean;
} | null; } | null;
} }
@ -87,6 +89,7 @@ interface ToolbarThreadActions {
updateDraft: (threadId: string, value: string) => void; updateDraft: (threadId: string, value: string) => void;
save: (threadId: string, value: string) => void; save: (threadId: string, value: string) => void;
cancel: (threadId: string) => void; cancel: (threadId: string) => void;
cancelAutoName: (threadId: string) => void;
autoName: (threadId: string) => void; autoName: (threadId: string) => void;
}; };
} }
@ -365,7 +368,11 @@ function ThreadList({
{threads.map((thread) => ( {threads.map((thread) => (
<ThreadListRow key={thread.threadId} thread={thread} actions={actions} /> <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 ? ( {hasMore ? (
<ToolbarPanelItem <ToolbarPanelItem
label={loadingMore ? "Loading more threads…" : "Load more threads"} label={loadingMore ? "Loading more threads…" : "Load more threads"}
@ -404,7 +411,6 @@ function ThreadListRow({ thread, actions }: { thread: ToolbarThreadRow; actions:
label={thread.title} label={thread.title}
selected={thread.selected} selected={thread.selected}
selectionStyle="row" selectionStyle="row"
disabled={thread.openDisabled ?? thread.disabled}
className="codex-panel__thread" className="codex-panel__thread"
onClick={() => { onClick={() => {
actions.resume(thread.threadId); actions.resume(thread.threadId);
@ -415,7 +421,7 @@ function ThreadListRow({ thread, actions }: { thread: ToolbarThreadRow; actions:
icon="pencil" icon="pencil"
label="Rename thread" label="Rename thread"
className="codex-panel__thread-action" className="codex-panel__thread-action"
disabled={thread.disabled} disabled={thread.renameDisabled}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
actions.rename.start(thread.threadId); actions.rename.start(thread.threadId);
@ -437,7 +443,7 @@ function ArchiveControls({ thread, actions }: { thread: ToolbarThreadRow; action
icon="archive" icon="archive"
label="Archive thread" label="Archive thread"
className="codex-panel__thread-action" className="codex-panel__thread-action"
disabled={thread.disabled} disabled={thread.archiveDisabled}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
actions.archive.start(thread.threadId); actions.archive.start(thread.threadId);
@ -471,7 +477,7 @@ function ArchiveModeButton({
icon={saveMarkdown ? "save" : "trash"} icon={saveMarkdown ? "save" : "trash"}
label={label} label={label}
className={`codex-panel__thread-action ${primary ? "codex-panel__archive-default" : "codex-panel__archive-alternate"}`} className={`codex-panel__thread-action ${primary ? "codex-panel__archive-default" : "codex-panel__archive-alternate"}`}
disabled={thread.disabled} disabled={thread.archiveDisabled}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
actions.archive.confirm(thread.threadId, saveMarkdown); actions.archive.confirm(thread.threadId, saveMarkdown);
@ -483,10 +489,12 @@ function ArchiveModeButton({
function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; actions: ToolbarThreadActions }): UiNode { function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; actions: ToolbarThreadActions }): UiNode {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const generating = thread.rename?.generating ?? false; const generating = thread.rename?.generating ?? false;
const saving = thread.rename?.saving ?? false;
const renameBusy = generating || saving;
const draft = thread.rename?.draft ?? thread.title; const draft = thread.rename?.draft ?? thread.title;
useLayoutEffect(() => { useLayoutEffect(() => {
focusToolbarRenameInput(inputRef.current); if (!renameBusy) focusToolbarRenameInput(inputRef.current);
}, [draft]); }, [draft, renameBusy]);
return ( return (
<> <>
@ -501,39 +509,41 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action
className="codex-panel-ui__nav-inline-input codex-panel__thread-rename-input" className="codex-panel-ui__nav-inline-input codex-panel__thread-rename-input"
type="text" type="text"
value={draft} value={draft}
disabled={renameBusy}
onInput={(event) => { onInput={(event) => {
actions.rename.updateDraft(thread.threadId, event.currentTarget.value); actions.rename.updateDraft(thread.threadId, event.currentTarget.value);
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); 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; return;
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
actions.rename.cancel(thread.threadId); if (!renameBusy) actions.rename.cancel(thread.threadId);
} }
}} }}
onBlur={(event) => { onBlur={(event) => {
if (!generating) actions.rename.save(thread.threadId, event.currentTarget.value); if (!renameBusy) actions.rename.save(thread.threadId, event.currentTarget.value);
}} }}
/> />
</div> </div>
)} )}
/> />
<ToolbarRowActionButton <ToolbarRowActionButton
icon={generating ? "loader" : "sparkles"} icon={generating ? "x" : "sparkles"}
label="Auto-name thread" label={generating ? "Cancel auto-name" : "Auto-name thread"}
className="codex-panel__thread-action" className="codex-panel__thread-action"
disabled={generating} disabled={saving || (!generating && (thread.rename?.autoNameDisabled ?? true))}
onPointerDown={(event) => { onPointerDown={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}} }}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); 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, runEphemeralStructuredTurnForLastAgentText,
type StructuredTurnOutputSchema, type StructuredTurnOutputSchema,
} from "../../app-server/services/ephemeral-structured-turn"; } from "../../app-server/services/ephemeral-structured-turn";
import { SelectionRewriteOutputError, selectionRewriteOutputParseResultFromText } from "./output";
import { SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS, SELECTION_REWRITE_SERVICE_NAME } from "./prompt"; 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; const SELECTION_REWRITE_TIMEOUT_MS = 120_000;
@ -60,7 +64,19 @@ async function runAppServerSelectionRewrite(options: AppServerSelectionRewriteTr
}, },
options.runner, options.runner,
); );
const { output, rawText } = selectionRewriteOutputParseResultFromText(lastAgentText); const output = selectionRewriteOutputFromText(lastAgentText);
if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", rawText); if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", lastAgentText);
return output; 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({ plugin.addCommand({
id: "rewrite-selection", id: "rewrite-selection",
name: "Rewrite selection", name: "Rewrite selection",
editorCallback: (editor, view) => { editorCheckCallback: (checking, editor, view) => {
if (!(view instanceof MarkdownView) || !view.file) { if (!(view instanceof MarkdownView) || !view.file) return false;
new Notice("Select text in an active markdown note first.");
return;
}
const originalText = editor.getSelection(); const originalText = editor.getSelection();
if (!originalText.trim()) { if (!originalText.trim()) return false;
new Notice("Select text to rewrite first."); if (checking) return true;
return;
}
const viewDocument = view.containerEl.doc; const viewDocument = view.containerEl.doc;
const viewWindow = viewDocument.defaultView; const viewWindow = viewDocument.defaultView;
if (!viewWindow) { if (!viewWindow) {
new Notice("Could not open rewrite popover for this note."); new Notice("Could not open rewrite popover for this note.");
return; return false;
} }
const rewriteState: SelectionRewriteState = { const rewriteState: SelectionRewriteState = {
@ -75,6 +70,7 @@ export function registerSelectionRewriteCommand(
}); });
popover.open(); popover.open();
activePopovers.add(popover); 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 originalLines = textLines(originalText);
const replacementLines = textLines(replacementText); const replacementLines = textLines(replacementText);
const changes = lineChanges(originalLines, replacementLines); const changes = lineChanges(originalLines, replacementLines);
const oldCount = Math.max(originalLines.length, 1); return changes.map((change) => `${change.prefix}${change.text}`);
const newCount = Math.max(replacementLines.length, 1);
return [
`diff --git a/${filePath} b/${filePath}`,
`--- a/${filePath}`,
`+++ b/${filePath}`,
`@@ -1,${String(oldCount)} +1,${String(newCount)} @@`,
...changes.map((change) => `${change.prefix}${change.text}`),
].join("\n");
} }
const MAX_SELECTION_REWRITE_LCS_CELLS = 40_000; const MAX_SELECTION_REWRITE_EDIT_LENGTH = 400;
interface DiffLine { interface DiffLine {
prefix: " " | "+" | "-"; prefix: " " | "+" | "-";
@ -29,31 +22,17 @@ function textLines(text: string): string[] {
} }
function lineChanges(originalLines: string[], replacementLines: string[]): DiffLine[] { function lineChanges(originalLines: string[], replacementLines: string[]): DiffLine[] {
if (originalLines.length * replacementLines.length > MAX_SELECTION_REWRITE_LCS_CELLS) { const arrayChanges = diffArrays(originalLines, replacementLines, {
return linearLineChanges(originalLines, replacementLines); maxEditLength: MAX_SELECTION_REWRITE_EDIT_LENGTH,
} });
if (!arrayChanges) return linearLineChanges(originalLines, replacementLines);
const lengths = lcsLengths(originalLines, replacementLines); const changes = arrayChanges.flatMap<DiffLine>((change) =>
const changes: DiffLine[] = []; change.value.map((text) => ({
let oldIndex = 0; prefix: change.added ? "+" : change.removed ? "-" : " ",
let newIndex = 0; text,
})),
while (oldIndex < originalLines.length || newIndex < replacementLines.length) { );
if (oldIndex < originalLines.length && newIndex < replacementLines.length && originalLines[oldIndex] === replacementLines[newIndex]) {
changes.push({ prefix: " ", text: originalLines[oldIndex] ?? "" });
oldIndex += 1;
newIndex += 1;
} else if (
newIndex < replacementLines.length &&
(oldIndex === originalLines.length || (lengths[oldIndex]?.[newIndex + 1] ?? 0) > (lengths[oldIndex + 1]?.[newIndex] ?? 0))
) {
changes.push({ prefix: "+", text: replacementLines[newIndex] ?? "" });
newIndex += 1;
} else if (oldIndex < originalLines.length) {
changes.push({ prefix: "-", text: originalLines[oldIndex] ?? "" });
oldIndex += 1;
}
}
return changes.length > 0 ? changes : [{ prefix: " ", text: "" }]; 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] ?? "" }); 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 { syncTextareaHeight } from "../../shared/dom/textarea-autogrow.measure";
import { textareaCursorAtVisualBoundary } from "../../shared/dom/textarea-caret.measure"; import { textareaCursorAtVisualBoundary } from "../../shared/dom/textarea-caret.measure";
import { IconButton } from "../../shared/obsidian/components.obsidian"; import { IconButton } from "../../shared/obsidian/components.obsidian";
import { DiffLineList, unifiedDiffDisplayLines } from "../../shared/ui/diff-view"; import { DiffLineList } from "../../shared/ui/diff-view";
import { buildSelectionUnifiedDiff } from "./diff"; import { buildSelectionDiffLines } from "./diff";
import { import {
canApplySelectionRewrite, canApplySelectionRewrite,
type SelectionRewriteInstructionHistoryDirection, type SelectionRewriteInstructionHistoryDirection,
@ -201,7 +201,7 @@ export class SelectionRewritePopover {
elements.applyButton = element; elements.applyButton = element;
}} }}
debugText={state.debugText} 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"} generating={state.status === "generating"}
hasInstruction={this.session.hasInstruction} hasInstruction={this.session.hasInstruction}
hasReplacement={replacement !== null} hasReplacement={replacement !== null}
@ -306,7 +306,7 @@ function selectionRewriteInstructionCursorOnLogicalBoundary(
interface SelectionRewritePopoverViewProps { interface SelectionRewritePopoverViewProps {
applyButtonRef: (element: HTMLButtonElement | null) => void; applyButtonRef: (element: HTMLButtonElement | null) => void;
debugText: string | null; debugText: string | null;
diff: string | null; diffLines: readonly string[] | null;
generating: boolean; generating: boolean;
hasInstruction: boolean; hasInstruction: boolean;
hasReplacement: boolean; hasReplacement: boolean;
@ -324,7 +324,7 @@ interface SelectionRewritePopoverViewProps {
function SelectionRewritePopoverView({ function SelectionRewritePopoverView({
applyButtonRef, applyButtonRef,
debugText, debugText,
diff, diffLines,
generating, generating,
hasInstruction, hasInstruction,
hasReplacement, hasReplacement,
@ -365,7 +365,7 @@ function SelectionRewritePopoverView({
<SelectionRewriteStatus status={status} /> <SelectionRewriteStatus status={status} />
<pre className={`codex-panel-selection-rewrite__stream-preview${streamPreview ? "" : " is-hidden"}`}>{streamPreview}</pre> <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__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"> <div className="codex-panel-selection-rewrite__result-actions">
<IconButton <IconButton
buttonRef={applyButtonRef} buttonRef={applyButtonRef}
@ -407,11 +407,6 @@ function SelectionRewriteStatus({ status }: { status: SelectionRewriteSessionSta
); );
} }
function SelectionRewriteDiff({ diff }: { diff: string }): UiNode { function SelectionRewriteDiff({ lines }: { lines: readonly string[] }): UiNode {
return ( return <DiffLineList lines={lines.map((text) => ({ text }))} className="codex-panel-selection-rewrite__diff-body" />;
<DiffLineList
lines={unifiedDiffDisplayLines(diff).filter((line) => line.kind !== "file" && !line.text.startsWith("@@"))}
className="codex-panel-selection-rewrite__diff-body"
/>
);
} }

View file

@ -1,7 +1,6 @@
import type { SelectionRewriteInstructionHistoryDirection, SelectionRewriteRuntimeSettings, SelectionRewriteState } from "./model"; import type { SelectionRewriteInstructionHistoryDirection, SelectionRewriteRuntimeSettings, SelectionRewriteState } from "./model";
import { SelectionRewriteOutputError } from "./output";
import { buildSelectionRewritePrompt } from "./prompt"; 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; const MAX_SELECTION_REWRITE_INSTRUCTION_HISTORY = 20;

View file

@ -1,8 +1,21 @@
import type { SelectionRewriteRuntimeSettings } from "./model"; import type { SelectionRewriteRuntimeSettings } from "./model";
import type { SelectionRewriteOutput } from "./output";
export type SelectionRewriteActivity = "reasoning" | "writing"; 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 { export interface SelectionRewriteTransportRequest {
prompt: string; prompt: string;
runtimeSettings: SelectionRewriteRuntimeSettings; runtimeSettings: SelectionRewriteRuntimeSettings;

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,8 @@ interface ThreadRowCoreRenameProjection {
readonly active: boolean; readonly active: boolean;
readonly draft: string; readonly draft: string;
readonly generating: boolean; readonly generating: boolean;
readonly saving: boolean;
readonly autoNameDisabled: boolean;
} }
interface ThreadRowCoreArchiveConfirmProjection { interface ThreadRowCoreArchiveConfirmProjection {
@ -37,6 +39,8 @@ export function threadRowCoreProjection(input: {
active: rename !== undefined, active: rename !== undefined,
draft: rename?.draft ?? threadRenameDraftTitle(input.thread), draft: rename?.draft ?? threadRenameDraftTitle(input.thread),
generating: rename?.kind === "generating", generating: rename?.kind === "generating",
saving: rename?.kind === "saving",
autoNameDisabled: rename?.autoName.kind !== "ready",
}, },
archiveConfirm: { archiveConfirm: {
active: input.archiveConfirmActive ?? false, 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 { shortThreadId } from "../../../domain/threads/id";
import { threadArchiveTitle } from "../../../domain/threads/title"; import { threadArchiveTitle } from "../../../domain/threads/title";
import { import {
@ -19,6 +19,11 @@ export interface ArchiveExportResult {
path: string; path: string;
} }
export interface ArchiveExportSettings extends ArchiveMarkdownOptions {
archiveExportFolderTemplate: string;
archiveExportFilenameTemplate: string;
}
export type ArchiveExportDestination = VaultMarkdownDestination; export type ArchiveExportDestination = VaultMarkdownDestination;
interface TemplateContext { 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