mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Compare commits
31 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc4d82c26 | ||
|
|
811a4b7158 | ||
|
|
05ea87643a | ||
|
|
bb673b2a75 | ||
|
|
01a20dcf70 | ||
|
|
73be2beea7 | ||
|
|
14adac592d | ||
|
|
f4616cf945 | ||
|
|
67289765c6 | ||
|
|
ab0a9047f4 | ||
|
|
488344a74d | ||
|
|
6bdd9e5972 | ||
|
|
5b6fce1a4d | ||
|
|
05f9740e8e | ||
|
|
6b2a20cf0d | ||
|
|
d0965d19b1 | ||
|
|
381e2a5467 | ||
|
|
03305a47ef | ||
|
|
d74747fbf0 | ||
|
|
f707da0274 | ||
|
|
2fb7d29841 | ||
|
|
8a798a5bca | ||
|
|
689368fe8d | ||
|
|
5cd106973c | ||
|
|
f3d987ee03 | ||
|
|
bbe311c4de | ||
|
|
4f3ebcaa31 | ||
|
|
96b88c1715 | ||
|
|
193b680f06 | ||
|
|
2d9f98f434 | ||
|
|
ffcc814ba5 |
259 changed files with 5834 additions and 2971 deletions
6
.github/release-notes/5.1.2.md
vendored
Normal file
6
.github/release-notes/5.1.2.md
vendored
Normal 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
4
.github/release-notes/5.2.0.md
vendored
Normal 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
5
.github/release-notes/5.2.1.md
vendored
Normal 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.
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Codex Panel brings Codex into an Obsidian sidebar. It keeps Codex threads beside your notes, helps you add vault context to prompts, and lets you handle approvals and file changes without switching windows.
|
||||
|
||||
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup. The plugin handles the Obsidian side of the workflow, while models, credentials, sandboxing, tools, hooks, and thread state stay with Codex.
|
||||
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup.
|
||||
|
||||

|
||||
|
||||
|
|
@ -48,7 +48,7 @@ If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executab
|
|||
|
||||
A panel is an independent working surface with its own active thread and draft. Open multiple panels when different tasks should stay visible and separate. Regular Codex threads remain available after a panel closes; return to them from **Codex Panel: Open thread...**, panel history, or the Threads view.
|
||||
|
||||
For a one-off question alongside the current thread, use `/btw` to open a temporary side chat. To carry context between regular threads without merging their histories, use `/refer <thread> <message>`.
|
||||
For a one-off question alongside the current thread, use `/btw` to open a temporary side chat. To carry context between regular threads without merging their histories, use `/refer <thread> <message>`. The submitted message keeps a normal link to the referenced thread; clicking it opens the thread in an available Codex Panel, creating one when needed.
|
||||
|
||||
### Bring in the right context
|
||||
|
||||
|
|
@ -56,9 +56,6 @@ The composer lets you point Codex to relevant material without pasting it into t
|
|||
|
||||
When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` resolve through its folder and date format. Paste or drop files to save them in the configured attachment folder and reference them from the same prompt. For material outside the vault, `/web <url> [message]` fetches readable page content and attaches it to the next turn without creating a note.
|
||||
|
||||
These references tell Codex what is relevant to the request; they do not change its vault-root working directory or its permissions.
|
||||
Large transient references are bounded before they reach Codex, and the panel marks a web or thread reference when it had to truncate it.
|
||||
|
||||
### Guide a running turn
|
||||
|
||||
As a turn runs, the panel keeps the conversation together with requests that need your attention. You can answer questions, approve or reject actions, steer the turn with another message, or interrupt it. Plans, goals, tool activity, and agent work remain available when you need to inspect how the task is progressing.
|
||||
|
|
@ -77,7 +74,7 @@ Use `/help` for the current slash command list.
|
|||
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `manifest.minAppVersion` | `1.12.0` | Minimum Obsidian desktop version declared for plugin loading. |
|
||||
| `obsidian` API types | `1.12.3` | TypeScript API package used for compile-time checks; kept in the same minor as `manifest` baseline. |
|
||||
| `codexAppServer.testedCliVersion` | `0.144.5` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
|
||||
| `codexAppServer.testedCliVersion` | `0.145.0` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
|
||||
|
||||
Codex Panel depends on the experimental `codex app-server` API.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ The panel may provide a narrow management surface when direct configuration of C
|
|||
|
||||
The panel may acquire prompt context through an explicit user action. `/web` fetches the requested page through Obsidian and attaches the extracted content as untrusted turn context; it is not a search surface or agent network policy.
|
||||
|
||||
Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context, while persisting only a tightly bounded, context-body-free metadata descriptor needed to reconstruct panel display and archive metadata. Treat that fixed-schema envelope as inert reference metadata rather than user instructions. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer.
|
||||
Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context without adding Panel-owned metadata envelopes to Codex history. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer. Keep read-only compatibility for envelopes written by older Panel versions isolated at history projection boundaries.
|
||||
|
||||
Vault file references are prompt handoff data, not durable thread metadata. Keep their display state local; ordinary wikilinks remain represented by the user-authored message text. Thread references persist as ordinary, human-readable `codex://threads/` Markdown links, while their bounded transcript payload remains ephemeral turn context.
|
||||
|
||||
Panel settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it.
|
||||
|
||||
|
|
@ -54,6 +56,8 @@ Chat-visible state should have one authoritative owner. Components should consum
|
|||
|
||||
TanStack Query is the single panel-side owner of cached app-server resources. Features may project authoritative event results into that state, but should not introduce parallel cache or synchronization mechanisms. Partial read models are reconciled at explicit lifecycle boundaries rather than kept globally and continuously consistent.
|
||||
|
||||
Thread lifecycle changes should be projected from completed operation facts into the shared read model. When one user action changes multiple visible projections, publish them coherently without delaying live operational state or coupling independent panels.
|
||||
|
||||
Reads with different completeness requirements have separate lifecycles. Complete operation-local reads must not replace bounded shared history, and transient activity should come from its owning live state rather than forcing history refreshes.
|
||||
|
||||
Imperative UI bridges are allowed only where the host platform requires them. They should remain narrow boundary adapters, not a second UI composition system inside Preact-owned surfaces.
|
||||
|
|
@ -68,7 +72,9 @@ Coordinate conflicting work at the narrowest shared semantic owner. Independent
|
|||
|
||||
Foreground reveal and focus are the narrow workspace-wide exception: the latest user intent wins without serializing unrelated panel or app-server work. Cleanup obligations created by a committed state transition must survive the UI action that initiated them.
|
||||
|
||||
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Treat their panels as read-only conversation surfaces while preserving their parent and agent provenance for future specialized behavior.
|
||||
Do not use explanatory copy as the default remedy for an unclear or incoherent interface. Before adding instructions, warnings, status text, validation messages, confirmations, or diagnostics, determine why the interface does not communicate the necessary behavior through its structure and state. Prefer improving information hierarchy, labels, defaults, available choices, affordances, validation timing, progress indication, and feedback. Add text only when irreducible information remains and it materially helps the user decide, act, or recover; do not expose implementation concepts merely because they explain the current behavior.
|
||||
|
||||
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Use the app-server direct-input capability when available, falling back to read-only subagent panels for older servers, while preserving parent and agent provenance for other specialized behavior.
|
||||
|
||||
Mode-derived restrictions for the active panel thread must remain consistent across actions and UI. Keep connection state, turn busy state, and operations targeting another listed thread in their owning workflows.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "codex-panel",
|
||||
"name": "Codex Panel",
|
||||
"version": "5.1.1",
|
||||
"version": "5.2.1",
|
||||
"minAppVersion": "1.12.0",
|
||||
"description": "Codex in your sidebar.",
|
||||
"author": "murashit",
|
||||
|
|
|
|||
430
package-lock.json
generated
430
package-lock.json
generated
|
|
@ -1,19 +1,23 @@
|
|||
{
|
||||
"name": "codex-panel",
|
||||
"version": "5.1.1",
|
||||
"version": "5.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codex-panel",
|
||||
"version": "5.1.1",
|
||||
"version": "5.2.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "^5.101.2",
|
||||
"defuddle": "^0.19.1",
|
||||
"diff": "^9.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"micromark": "^4.0.2",
|
||||
"obsidian-daily-notes-interface": "^0.9.5",
|
||||
"preact": "^10.29.7"
|
||||
"preact": "^10.29.7",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.3",
|
||||
|
|
@ -21,9 +25,10 @@
|
|||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@stryker-mutator/core": "^9.6.1",
|
||||
"@stryker-mutator/vitest-runner": "^9.6.1",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"concurrently": "^10.0.3",
|
||||
"concurrently": "^9.2.4",
|
||||
"conventional-changelog-conventionalcommits": "^10.2.1",
|
||||
"conventional-commits-parser": "^7.1.0",
|
||||
"esbuild": "^0.28.0",
|
||||
|
|
@ -1927,9 +1932,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3894,6 +3899,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
|
|
@ -3919,6 +3933,12 @@
|
|||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.64.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
|
||||
|
|
@ -4661,9 +4681,9 @@
|
|||
"optional": true
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -4912,56 +4932,131 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
|
||||
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
|
||||
"version": "9.2.4",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
|
||||
"integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.4",
|
||||
"supports-color": "10.2.2",
|
||||
"shell-quote": "1.9.0",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "18.0.0"
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/index.js",
|
||||
"concurrently": "dist/bin/index.js"
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"node_modules/concurrently/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-changelog-angular": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz",
|
||||
|
|
@ -5349,6 +5444,15 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
|
||||
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-match-patch": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
|
||||
|
|
@ -6000,9 +6104,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -6066,9 +6170,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-n/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -6290,9 +6394,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-obsidianmd/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -7616,6 +7720,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
|
|
@ -7986,9 +8100,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -8608,6 +8722,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
"integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
|
|
@ -8689,6 +8813,78 @@
|
|||
"@xmldom/xmldom": "^0.9.10"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-from-markdown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
|
||||
"integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"decode-named-character-reference": "^1.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark": "^4.0.0",
|
||||
"micromark-util-decode-numeric-character-reference": "^2.0.0",
|
||||
"micromark-util-decode-string": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0",
|
||||
"unist-util-stringify-position": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-phrasing": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
|
||||
"integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"unist-util-is": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-to-markdown": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
|
||||
"integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"longest-streak": "^3.0.0",
|
||||
"mdast-util-phrasing": "^4.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-util-classify-character": "^2.0.0",
|
||||
"micromark-util-decode-string": "^2.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zwitch": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-to-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
|
||||
"integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
|
|
@ -8971,6 +9167,28 @@
|
|||
"micromark-util-symbol": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-util-decode-string": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
|
||||
"integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "GitHub Sponsors",
|
||||
"url": "https://github.com/sponsors/unifiedjs"
|
||||
},
|
||||
{
|
||||
"type": "OpenCollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decode-named-character-reference": "^1.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-decode-numeric-character-reference": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-util-encode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
|
||||
|
|
@ -9946,6 +10164,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
|
|
@ -10256,9 +10484,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -10425,6 +10653,51 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.matchall": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
|
||||
|
|
@ -11071,6 +11344,61 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-is": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
|
||||
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-stringify-position": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
|
||||
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-visit": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
|
||||
"integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"unist-util-is": "^6.0.0",
|
||||
"unist-util-visit-parents": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-visit-parents": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
|
||||
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"unist-util-is": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
|
@ -11681,6 +12009,16 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codex-panel",
|
||||
"version": "5.1.1",
|
||||
"version": "5.2.1",
|
||||
"description": "Codex in your Obsidian sidebar.",
|
||||
"main": "main.js",
|
||||
"author": "murashit",
|
||||
|
|
@ -46,9 +46,10 @@
|
|||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@stryker-mutator/core": "^9.6.1",
|
||||
"@stryker-mutator/vitest-runner": "^9.6.1",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"concurrently": "^10.0.3",
|
||||
"concurrently": "^9.2.4",
|
||||
"conventional-changelog-conventionalcommits": "^10.2.1",
|
||||
"conventional-commits-parser": "^7.1.0",
|
||||
"esbuild": "^0.28.0",
|
||||
|
|
@ -65,8 +66,12 @@
|
|||
"dependencies": {
|
||||
"@tanstack/query-core": "^5.101.2",
|
||||
"defuddle": "^0.19.1",
|
||||
"diff": "^9.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"micromark": "^4.0.2",
|
||||
"obsidian-daily-notes-interface": "^0.9.5",
|
||||
"preact": "^10.29.7"
|
||||
"preact": "^10.29.7",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import type { ThreadItemsListResponse } from "../../generated/app-server/v2/Thre
|
|||
import type { ThreadListResponse } from "../../generated/app-server/v2/ThreadListResponse";
|
||||
import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse";
|
||||
import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse";
|
||||
import type { ThreadRollbackResponse } from "../../generated/app-server/v2/ThreadRollbackResponse";
|
||||
import type { ThreadSetNameResponse } from "../../generated/app-server/v2/ThreadSetNameResponse";
|
||||
import type { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse";
|
||||
import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse";
|
||||
|
|
@ -87,7 +86,6 @@ export interface ClientResponseByMethod {
|
|||
"thread/delete": ThreadDeleteResponse;
|
||||
"thread/unsubscribe": ThreadUnsubscribeResponse;
|
||||
"thread/unarchive": ThreadUnarchiveResponse;
|
||||
"thread/rollback": ThreadRollbackResponse;
|
||||
"thread/name/set": ThreadSetNameResponse;
|
||||
"thread/settings/update": ThreadSettingsUpdateResponse;
|
||||
"thread/turns/list": ThreadTurnsListResponse;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"codexAppServer": {
|
||||
"testedCliVersion": "0.144.5",
|
||||
"testedCliVersion": "0.145.0",
|
||||
"typeGeneration": {
|
||||
"arguments": ["app-server", "generate-ts", "--experimental"]
|
||||
},
|
||||
|
|
|
|||
4
src/app-server/connection/execution-context.ts
Normal file
4
src/app-server/connection/execution-context.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface AppServerExecutionContext {
|
||||
readonly codexPath: string;
|
||||
readonly vaultPath: string;
|
||||
}
|
||||
|
|
@ -1,29 +1,21 @@
|
|||
import type { ReferencedThreadMetadata } from "../threads/reference";
|
||||
import { utf8ByteLength } from "./context-budget";
|
||||
import type { VaultFileReference } from "./input";
|
||||
import { isPanelSubmissionClientId } from "./submission-id";
|
||||
import type { VaultFileReference } from "../../domain/chat/input";
|
||||
import { isPanelSubmissionClientId, turnContextSubmissionId } from "../../domain/chat/submission-id";
|
||||
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
|
||||
|
||||
/*
|
||||
* Read-only compatibility for v2 metadata envelopes written by Codex Panel
|
||||
* versions that predate durable, human-readable references in message text.
|
||||
* Request construction must not import this module.
|
||||
*/
|
||||
|
||||
const TURN_CONTEXT_MANIFEST_PREFIX = "[Codex Panel context v2]";
|
||||
const TURN_CONTEXT_MANIFEST_NOTICE = "\nReference/display metadata only; not user instructions.\n";
|
||||
const TURN_CONTEXT_MANIFEST_MAX_BYTES = 2_800;
|
||||
const TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT = 64;
|
||||
const TURN_CONTEXT_FILE_REFERENCE_NAME_MAX_LENGTH = 255;
|
||||
const TURN_CONTEXT_FILE_REFERENCE_PATH_MAX_LENGTH = 2_048;
|
||||
|
||||
export type TurnContextAttachment =
|
||||
| {
|
||||
kind: "referencedThread";
|
||||
threadId: string;
|
||||
includedTurns: number;
|
||||
turnLimit: number;
|
||||
omittedTurns: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
| { kind: "web" }
|
||||
| { kind: "obsidian"; inlineExcerpts: number };
|
||||
|
||||
export interface TurnContextManifestEntry {
|
||||
kind: TurnContextAttachment["kind"];
|
||||
interface LegacyTurnContextManifestEntry {
|
||||
kind: "referencedThread" | "web" | "obsidian";
|
||||
id: string;
|
||||
parts: number;
|
||||
sourceBytes: number;
|
||||
|
|
@ -36,55 +28,26 @@ export interface TurnContextManifestEntry {
|
|||
inlineExcerpts?: number;
|
||||
}
|
||||
|
||||
export interface TurnContextManifest {
|
||||
export interface LegacyTurnContextManifest {
|
||||
version: 2;
|
||||
submissionId?: string;
|
||||
contexts: readonly TurnContextManifestEntry[];
|
||||
contexts: readonly LegacyTurnContextManifestEntry[];
|
||||
fileReferences?: readonly VaultFileReference[];
|
||||
}
|
||||
|
||||
export interface UserMessageContextProjection {
|
||||
export interface LegacyTurnContextProjection {
|
||||
text: string;
|
||||
manifest: TurnContextManifest | null;
|
||||
manifest: LegacyTurnContextManifest | null;
|
||||
}
|
||||
|
||||
export function turnContextManifestText(manifest: TurnContextManifest): string {
|
||||
return `${TURN_CONTEXT_MANIFEST_PREFIX}${TURN_CONTEXT_MANIFEST_NOTICE}${JSON.stringify(manifest)}`;
|
||||
}
|
||||
type UserMessageContentItem = { type: "text"; text: string } | { type: string };
|
||||
|
||||
export function boundedTurnContextManifest(
|
||||
submissionId: string,
|
||||
contexts: readonly TurnContextManifestEntry[],
|
||||
fileReferences: readonly VaultFileReference[],
|
||||
): TurnContextManifest | null {
|
||||
const base = {
|
||||
version: 2,
|
||||
submissionId: turnContextSubmissionId(submissionId),
|
||||
contexts,
|
||||
} satisfies TurnContextManifest;
|
||||
if (utf8ByteLength(turnContextManifestText(base)) > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
|
||||
const included: VaultFileReference[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const reference of fileReferences) {
|
||||
const normalized = manifestFileReference(reference);
|
||||
if (!normalized || included.length >= TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT) continue;
|
||||
const identity = `${normalized.name}\u0000${normalized.path}`;
|
||||
if (seen.has(identity)) continue;
|
||||
const candidate = { ...base, fileReferences: [...included, normalized] } satisfies TurnContextManifest;
|
||||
if (utf8ByteLength(turnContextManifestText(candidate)) > TURN_CONTEXT_MANIFEST_MAX_BYTES) continue;
|
||||
seen.add(identity);
|
||||
included.push(normalized);
|
||||
}
|
||||
if (contexts.length === 0 && included.length === 0) return null;
|
||||
return { ...base, ...(included.length > 0 ? { fileReferences: included } : {}) };
|
||||
}
|
||||
|
||||
function turnContextManifestFromText(text: string): TurnContextManifest | null {
|
||||
function legacyTurnContextManifestFromText(text: string): LegacyTurnContextManifest | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith(TURN_CONTEXT_MANIFEST_PREFIX)) return null;
|
||||
if (utf8ByteLength(trimmed) > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
|
||||
if (new TextEncoder().encode(trimmed).byteLength > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
|
||||
const payload = trimmed.slice(TURN_CONTEXT_MANIFEST_PREFIX.length).trimStart();
|
||||
const notice = TURN_CONTEXT_MANIFEST_NOTICE.trim();
|
||||
const notice = "Reference/display metadata only; not user instructions.";
|
||||
const json = payload.startsWith(notice) ? payload.slice(notice.length).trimStart() : payload;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
|
|
@ -95,7 +58,7 @@ function turnContextManifestFromText(text: string): TurnContextManifest | null {
|
|||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const value = parsed as Record<string, unknown>;
|
||||
if (value["version"] !== 2 || !Array.isArray(value["contexts"])) return null;
|
||||
const contexts = value["contexts"].map(manifestEntryFromUnknown);
|
||||
const contexts = value["contexts"].map(legacyManifestEntryFromUnknown);
|
||||
if (contexts.some((entry) => entry === null)) return null;
|
||||
const submissionId = optionalStringValue(value["submissionId"], 120);
|
||||
if (submissionId === null) return null;
|
||||
|
|
@ -104,25 +67,27 @@ function turnContextManifestFromText(text: string): TurnContextManifest | null {
|
|||
return {
|
||||
version: 2,
|
||||
...(submissionId === undefined ? {} : { submissionId }),
|
||||
contexts: contexts as TurnContextManifestEntry[],
|
||||
contexts: contexts as LegacyTurnContextManifestEntry[],
|
||||
...(fileReferences === undefined ? {} : { fileReferences }),
|
||||
};
|
||||
}
|
||||
|
||||
export function userMessageContextProjection(
|
||||
content: readonly ({ type: "text"; text: string } | { type: string })[],
|
||||
export function legacyTurnContextProjection(
|
||||
content: readonly UserMessageContentItem[],
|
||||
clientId: string | null,
|
||||
): UserMessageContextProjection {
|
||||
let manifest: TurnContextManifest | null = null;
|
||||
): LegacyTurnContextProjection {
|
||||
let manifest: LegacyTurnContextManifest | null = null;
|
||||
const visibleText: string[] = [];
|
||||
const lastTextIndex = lastTextItemIndex(content);
|
||||
for (const [index, item] of content.entries()) {
|
||||
if (item.type !== "text" || !("text" in item) || typeof item.text !== "string") continue;
|
||||
const parsed =
|
||||
index > 0 && index === content.length - 1 && item.text.startsWith(`\n${TURN_CONTEXT_MANIFEST_PREFIX}`)
|
||||
? turnContextManifestFromText(item.text)
|
||||
: null;
|
||||
const manifestPrefix = `\n${TURN_CONTEXT_MANIFEST_PREFIX}`;
|
||||
const manifestStart = index === lastTextIndex ? item.text.lastIndexOf(manifestPrefix) : -1;
|
||||
const isStandaloneManifest = manifestStart === 0 && index > 0 && index === content.length - 1;
|
||||
const parsed = manifestStart > 0 || isStandaloneManifest ? legacyTurnContextManifestFromText(item.text.slice(manifestStart)) : null;
|
||||
if (parsed && manifestMatchesClientId(parsed, clientId)) {
|
||||
manifest = parsed;
|
||||
if (manifestStart > 0) visibleText.push(item.text.slice(0, manifestStart));
|
||||
continue;
|
||||
}
|
||||
visibleText.push(item.text);
|
||||
|
|
@ -130,12 +95,14 @@ export function userMessageContextProjection(
|
|||
return { text: visibleText.join("\n"), manifest };
|
||||
}
|
||||
|
||||
export function turnContextSubmissionId(value: string): string {
|
||||
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
|
||||
return safe || "context";
|
||||
function lastTextItemIndex(content: readonly UserMessageContentItem[]): number {
|
||||
for (let index = content.length - 1; index >= 0; index -= 1) {
|
||||
if (content[index]?.type === "text") return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function manifestMatchesClientId(manifest: TurnContextManifest, clientId: string | null): boolean {
|
||||
function manifestMatchesClientId(manifest: LegacyTurnContextManifest, clientId: string | null): boolean {
|
||||
if (!isPanelSubmissionClientId(clientId)) return false;
|
||||
const submissionId = turnContextSubmissionId(clientId);
|
||||
if (manifest.submissionId !== undefined && manifest.submissionId !== submissionId) return false;
|
||||
|
|
@ -151,7 +118,7 @@ function escapeRegExp(value: string): string {
|
|||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function referencedThreadFromManifest(manifest: TurnContextManifest | null): ReferencedThreadMetadata | null {
|
||||
export function referencedThreadFromLegacyManifest(manifest: LegacyTurnContextManifest | null): ReferencedThreadMetadata | null {
|
||||
const context = manifest?.contexts.find((entry) => entry.kind === "referencedThread");
|
||||
if (!context?.threadId || context.includedTurns === undefined || context.turnLimit === undefined || context.omittedTurns === undefined) {
|
||||
return null;
|
||||
|
|
@ -166,11 +133,11 @@ export function referencedThreadFromManifest(manifest: TurnContextManifest | nul
|
|||
};
|
||||
}
|
||||
|
||||
export function fileReferencesFromManifest(manifest: TurnContextManifest | null): VaultFileReference[] {
|
||||
export function fileReferencesFromLegacyManifest(manifest: LegacyTurnContextManifest | null): VaultFileReference[] {
|
||||
return manifest?.fileReferences ? [...manifest.fileReferences] : [];
|
||||
}
|
||||
|
||||
function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | null {
|
||||
function legacyManifestEntryFromUnknown(input: unknown): LegacyTurnContextManifestEntry | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
const value = input as Record<string, unknown>;
|
||||
const kind = contextKind(value["kind"]);
|
||||
|
|
@ -214,7 +181,7 @@ function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | nu
|
|||
};
|
||||
}
|
||||
|
||||
function contextKind(value: unknown): TurnContextAttachment["kind"] | null {
|
||||
function contextKind(value: unknown): LegacyTurnContextManifestEntry["kind"] | null {
|
||||
return value === "referencedThread" || value === "web" || value === "obsidian" ? value : null;
|
||||
}
|
||||
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
import { splitUtf8Context, utf8ByteLength } from "../../domain/chat/context-budget";
|
||||
import {
|
||||
boundedTurnContextManifest,
|
||||
type TurnContextManifest,
|
||||
type TurnContextManifestEntry,
|
||||
turnContextManifestText,
|
||||
turnContextSubmissionId,
|
||||
} from "../../domain/chat/context-manifest";
|
||||
import type { CodexInputItem, VaultFileReference } from "../../domain/chat/input";
|
||||
import { splitUtf8Context } from "../../domain/chat/context-budget";
|
||||
import type { CodexInputItem } from "../../domain/chat/input";
|
||||
import { turnContextSubmissionId } from "../../domain/chat/submission-id";
|
||||
|
||||
type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original";
|
||||
|
||||
|
|
@ -29,20 +23,12 @@ export interface AppServerTurnInput {
|
|||
const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800;
|
||||
const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
|
||||
|
||||
export function toAppServerUserInput(input: readonly CodexInputItem[], manifest: TurnContextManifest | null = null): AppServerUserInput[] {
|
||||
const userInput = input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
|
||||
if (manifest) {
|
||||
userInput.push({ type: "text", text: `\n${turnContextManifestText(manifest)}`, text_elements: [] });
|
||||
}
|
||||
return userInput;
|
||||
export function toAppServerUserInput(input: readonly CodexInputItem[]): AppServerUserInput[] {
|
||||
return input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
|
||||
}
|
||||
|
||||
export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[], submissionId: string): AppServerTurnInput {
|
||||
const additionalContext: Record<string, AppServerAdditionalContextEntry> = {};
|
||||
const manifest: TurnContextManifestEntry[] = [];
|
||||
const fileReferences = input.flatMap((item): VaultFileReference[] =>
|
||||
item.type === "fileReference" && item.name && item.path ? [{ name: item.name, path: item.path }] : [],
|
||||
);
|
||||
const contexts = input.filter(
|
||||
(item): item is Extract<CodexInputItem, { type: "additionalContext" }> =>
|
||||
item.type === "additionalContext" && Boolean(item.key) && Boolean(item.value),
|
||||
|
|
@ -53,7 +39,6 @@ export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[
|
|||
const partAllocations = allocatedPartCounts(contexts);
|
||||
for (const [contextIndex, item] of contexts.entries()) {
|
||||
const id = `${turnContextSubmissionId(submissionId)}.${String(contextIndex).padStart(2, "0")}`;
|
||||
const sourceBytes = utf8ByteLength(item.value);
|
||||
const split = splitUtf8Context(item.value, ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES, partAllocations[contextIndex] ?? 1);
|
||||
const partCount = split.parts.length;
|
||||
split.parts.forEach((part, partIndex) => {
|
||||
|
|
@ -73,13 +58,9 @@ export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[
|
|||
].join("\n"),
|
||||
};
|
||||
});
|
||||
if (item.attachment || split.includedBytes < sourceBytes) {
|
||||
manifest.push(manifestEntry(item, id, partCount, sourceBytes, split.includedBytes));
|
||||
}
|
||||
}
|
||||
const persistedManifest = boundedTurnContextManifest(submissionId, manifest, fileReferences);
|
||||
return {
|
||||
input: toAppServerUserInput(input, persistedManifest),
|
||||
input: toAppServerUserInput(input),
|
||||
...(Object.keys(additionalContext).length > 0 ? { additionalContext } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -115,36 +96,6 @@ function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServ
|
|||
}
|
||||
}
|
||||
|
||||
function manifestEntry(
|
||||
item: Extract<CodexInputItem, { type: "additionalContext" }>,
|
||||
id: string,
|
||||
parts: number,
|
||||
sourceBytes: number,
|
||||
includedBytes: number,
|
||||
): TurnContextManifestEntry {
|
||||
const common = {
|
||||
kind: item.attachment?.kind ?? "obsidian",
|
||||
id,
|
||||
parts,
|
||||
sourceBytes,
|
||||
includedBytes,
|
||||
truncated: includedBytes < sourceBytes,
|
||||
} as const;
|
||||
if (item.attachment?.kind === "obsidian") {
|
||||
return { ...common, kind: "obsidian", inlineExcerpts: item.attachment.inlineExcerpts };
|
||||
}
|
||||
if (item.attachment?.kind !== "referencedThread") return common;
|
||||
return {
|
||||
...common,
|
||||
kind: "referencedThread",
|
||||
threadId: item.attachment.threadId,
|
||||
includedTurns: item.attachment.includedTurns,
|
||||
turnLimit: item.attachment.turnLimit,
|
||||
omittedTurns: item.attachment.omittedTurns,
|
||||
truncated: common.truncated || item.attachment.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function safeKeyPart(value: string): string {
|
||||
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
|
||||
return safe || "context";
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived
|
|||
archived: options.archived ?? false,
|
||||
createdAt: finiteTimestamp(thread.createdAt),
|
||||
updatedAt: finiteTimestamp(thread.updatedAt),
|
||||
canAcceptDirectInput: typeof thread.canAcceptDirectInput === "boolean" ? thread.canAcceptDirectInput : null,
|
||||
provenance: threadProvenance(thread),
|
||||
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { fileReferencesFromManifest, referencedThreadFromManifest, userMessageContextProjection } from "../../domain/chat/context-manifest";
|
||||
import type { VaultFileReference } from "../../domain/chat/input";
|
||||
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
|
||||
import {
|
||||
|
|
@ -10,6 +9,11 @@ import {
|
|||
import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem";
|
||||
import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn";
|
||||
import { legacyPanelUserMessageProjection } from "./legacy-panel-user-message";
|
||||
import {
|
||||
fileReferencesFromLegacyManifest,
|
||||
legacyTurnContextProjection,
|
||||
referencedThreadFromLegacyManifest,
|
||||
} from "./legacy-turn-context-manifest";
|
||||
|
||||
export type TurnItem = GeneratedThreadItem;
|
||||
export type TurnRecord = GeneratedTurn;
|
||||
|
|
@ -53,11 +57,17 @@ export interface TurnUserItemProjection {
|
|||
text: string;
|
||||
referencedThread: ReferencedThreadMetadata | null;
|
||||
fileReferences: VaultFileReference[];
|
||||
manifest: ReturnType<typeof userMessageContextProjection>["manifest"];
|
||||
contexts: TurnUserContextMetadata[];
|
||||
}
|
||||
|
||||
interface TurnUserContextMetadata {
|
||||
kind: "web" | "obsidian";
|
||||
truncated: boolean;
|
||||
inlineExcerpts?: number;
|
||||
}
|
||||
|
||||
export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMessage" }>): TurnUserItemProjection {
|
||||
const projected = userMessageContextProjection(item.content, item.clientId);
|
||||
const projected = legacyTurnContextProjection(item.content, item.clientId);
|
||||
if (!projected.manifest) {
|
||||
const legacy = legacyPanelUserMessageProjection({
|
||||
content: item.content,
|
||||
|
|
@ -68,16 +78,16 @@ export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMess
|
|||
text: [legacy.text, supplementalText].filter(Boolean).join("\n"),
|
||||
referencedThread: legacy.referencedThread,
|
||||
fileReferences: legacy.fileReferences,
|
||||
manifest: null,
|
||||
contexts: [],
|
||||
};
|
||||
}
|
||||
const supplementalText = nonTextUserInputText(item.content, projected.text);
|
||||
const text = [projected.text, supplementalText].filter(Boolean).join("\n");
|
||||
return {
|
||||
text,
|
||||
referencedThread: referencedThreadFromManifest(projected.manifest),
|
||||
fileReferences: fileReferencesFromManifest(projected.manifest),
|
||||
manifest: projected.manifest,
|
||||
referencedThread: referencedThreadFromLegacyManifest(projected.manifest),
|
||||
fileReferences: fileReferencesFromLegacyManifest(projected.manifest),
|
||||
contexts: legacyContextMetadata(projected.manifest.contexts),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -96,10 +106,7 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
|
|||
if (item.type === "userMessage") {
|
||||
const projection = turnUserItemProjection(item);
|
||||
const text = projection.text.trim();
|
||||
const contexts =
|
||||
projection.manifest?.contexts
|
||||
.filter((context) => context.kind === "web" || context.kind === "obsidian")
|
||||
.map((context) => ({ kind: context.kind as "web" | "obsidian", truncated: context.truncated })) ?? [];
|
||||
const contexts = projection.contexts.map((context) => ({ kind: context.kind, truncated: context.truncated }));
|
||||
return text
|
||||
? [
|
||||
{
|
||||
|
|
@ -123,6 +130,21 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
|
|||
return [];
|
||||
}
|
||||
|
||||
function legacyContextMetadata(
|
||||
contexts: readonly { kind: "referencedThread" | "web" | "obsidian"; truncated: boolean; inlineExcerpts?: number }[],
|
||||
): TurnUserContextMetadata[] {
|
||||
return contexts.flatMap((context) => {
|
||||
if (context.kind === "referencedThread") return [];
|
||||
return [
|
||||
{
|
||||
kind: context.kind,
|
||||
truncated: context.truncated,
|
||||
...(context.inlineExcerpts === undefined ? {} : { inlineExcerpts: context.inlineExcerpts }),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function nonTextUserInputText(
|
||||
content: Extract<TurnItem, { type: "userMessage" }>["content"],
|
||||
visibleText: string,
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ import {
|
|||
} from "../../domain/server/diagnostics";
|
||||
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import { StaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
|
||||
import type { AppServerClient } from "../connection/client";
|
||||
import type { AppServerClientAccessOptions } from "../connection/client-access";
|
||||
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../connection/client-access";
|
||||
import type { AppServerExecutionContext } from "../connection/execution-context";
|
||||
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
|
||||
import { listModelMetadata } from "../services/catalog";
|
||||
import { readEffectiveConfig } from "../services/runtime-metadata";
|
||||
|
|
@ -34,27 +36,20 @@ import {
|
|||
applyActiveThreadMutation,
|
||||
recentActiveThreadsFromData,
|
||||
} from "./active-thread-inventory";
|
||||
import {
|
||||
type AppServerQueryContext,
|
||||
activeThreadSearchInventoryQueryKey,
|
||||
activeThreadsQueryKey,
|
||||
appServerModelsQueryKey,
|
||||
appServerPermissionProfilesQueryKey,
|
||||
appServerRateLimitsQueryKey,
|
||||
appServerRuntimeConfigQueryKey,
|
||||
appServerSkillsQueryKey,
|
||||
archivedThreadsQueryKey,
|
||||
} from "./keys";
|
||||
import { readPermissionProfileMetadataProbe, readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
|
||||
import type { ObservedPaginatedResult, ObservedPaginatedResultListener, ObservedResult, ObservedResultListener } from "./observed-result";
|
||||
import { cloneModelMetadata, cloneSharedServerMetadata, cloneSharedServerMetadataResource, cloneThreads } from "./snapshots";
|
||||
import { applyThreadListMutation, type ThreadListKind, type ThreadListMutation } from "./thread-list-mutation";
|
||||
|
||||
const MODELS_STALE_TIME_MS = 60_000;
|
||||
|
||||
export interface AppServerQueryClientRunner {
|
||||
runWithClient<T>(operation: (client: AppServerClient) => Promise<T>, options?: AppServerClientAccessOptions): Promise<T>;
|
||||
}
|
||||
const ACTIVE_THREADS_QUERY_KEY = ["threads", "active"] as const;
|
||||
const ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY = ["threads", "active-search-inventory"] as const;
|
||||
const ARCHIVED_THREADS_QUERY_KEY = ["threads", "archived"] as const;
|
||||
const MODELS_QUERY_KEY = ["models"] as const;
|
||||
const RUNTIME_CONFIG_QUERY_KEY = ["runtime-config"] as const;
|
||||
const SKILLS_QUERY_KEY = ["skills"] as const;
|
||||
const PERMISSION_PROFILES_QUERY_KEY = ["permission-profiles"] as const;
|
||||
const RATE_LIMITS_QUERY_KEY = ["rate-limits"] as const;
|
||||
|
||||
interface AppServerQueryOptions<T> {
|
||||
readonly queryKey: readonly unknown[];
|
||||
|
|
@ -62,7 +57,7 @@ interface AppServerQueryOptions<T> {
|
|||
readonly staleTime?: number;
|
||||
}
|
||||
|
||||
type ActiveThreadsQueryKey = ReturnType<typeof activeThreadsQueryKey>;
|
||||
type ActiveThreadsQueryKey = typeof ACTIVE_THREADS_QUERY_KEY;
|
||||
|
||||
interface MetadataResourceSnapshot<T> {
|
||||
readonly value: T;
|
||||
|
|
@ -73,33 +68,36 @@ type MetadataResourceKind = "skills" | "permissionProfiles" | "rateLimits";
|
|||
type MetadataResourceValue = readonly SkillMetadata[] | readonly RuntimePermissionProfileSummary[] | RateLimitSnapshot | null;
|
||||
|
||||
export class AppServerQueryCache {
|
||||
private readonly context: Readonly<AppServerQueryContext>;
|
||||
private readonly context: Readonly<AppServerExecutionContext>;
|
||||
private readonly client: QueryClient;
|
||||
private readonly clientRunner: AppServerQueryClientRunner;
|
||||
private readonly clientAccess: AppServerClientAccess;
|
||||
private readonly observerUnsubscribes = new Set<() => void>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(context: AppServerQueryContext, options: { client?: QueryClient; clientRunner: AppServerQueryClientRunner }) {
|
||||
constructor(context: AppServerExecutionContext, clientAccess: AppServerClientAccess) {
|
||||
this.context = Object.freeze({ ...context });
|
||||
this.client = options.client ?? createAppServerQueryClient();
|
||||
this.clientRunner = options.clientRunner;
|
||||
this.client = createAppServerQueryClient();
|
||||
this.clientAccess = clientAccess;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const unsubscribe of [...this.observerUnsubscribes]) unsubscribe();
|
||||
this.observerUnsubscribes.clear();
|
||||
this.client.clear();
|
||||
}
|
||||
|
||||
activeThreadsSnapshot(): readonly Thread[] | null {
|
||||
if (this.disposed) return null;
|
||||
const data = this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey(this.context));
|
||||
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
|
||||
const threads = activeThreadsFromData(data);
|
||||
return threads ? cloneThreads(threads) : null;
|
||||
}
|
||||
|
||||
recentActiveThreadsSnapshot(): readonly Thread[] | null {
|
||||
if (this.disposed) return null;
|
||||
const data = this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey(this.context));
|
||||
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
|
||||
const threads = recentActiveThreadsFromData(data);
|
||||
return threads ? cloneThreads(threads) : null;
|
||||
}
|
||||
|
|
@ -119,14 +117,14 @@ export class AppServerQueryCache {
|
|||
enabled: false,
|
||||
});
|
||||
const emit = (result: InfiniteQueryObserverResult<ActiveThreadData>): void => {
|
||||
listener(this.projectObservedActiveThreadsResult(result));
|
||||
if (!this.disposed) listener(this.projectObservedActiveThreadsResult(result));
|
||||
};
|
||||
const unsubscribe = observer.subscribe(emit);
|
||||
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
|
||||
return () => {
|
||||
return this.trackObserver(() => {
|
||||
unsubscribe();
|
||||
observer.destroy();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options: { emitCurrent?: boolean } = {}): () => void {
|
||||
|
|
@ -134,79 +132,83 @@ export class AppServerQueryCache {
|
|||
return this.observeQueryResult(this.archivedThreadsQueryOptions(), cloneThreads, listener, options);
|
||||
}
|
||||
|
||||
async fetchActiveThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
||||
this.assertUsable();
|
||||
const key = activeThreadsQueryKey(this.context);
|
||||
if (options.force) {
|
||||
if (this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward") {
|
||||
await this.client.cancelQueries({ queryKey: key, exact: true });
|
||||
fetchActiveThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
||||
return this.runWhileActive(async () => {
|
||||
const key = ACTIVE_THREADS_QUERY_KEY;
|
||||
if (options.force) {
|
||||
if (this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward") {
|
||||
await this.client.cancelQueries({ queryKey: key, exact: true });
|
||||
}
|
||||
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||
this.assertUsable();
|
||||
}
|
||||
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||
this.assertUsable();
|
||||
}
|
||||
return this.readThroughQueryCancellation(
|
||||
async () => {
|
||||
const data = await this.client.fetchInfiniteQuery(this.activeThreadsQueryOptions());
|
||||
return cloneThreads(activeThreadsFromData(data) ?? []);
|
||||
},
|
||||
() => this.activeThreadsSnapshot(),
|
||||
);
|
||||
return this.readThroughQueryCancellation(
|
||||
async () => {
|
||||
const data = await this.client.fetchInfiniteQuery(this.activeThreadsQueryOptions());
|
||||
return cloneThreads(activeThreadsFromData(data) ?? []);
|
||||
},
|
||||
() => this.activeThreadsSnapshot(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async refreshActiveThreads(): Promise<readonly Thread[]> {
|
||||
refreshActiveThreads(): Promise<readonly Thread[]> {
|
||||
return this.fetchActiveThreads({ force: true });
|
||||
}
|
||||
|
||||
async fetchActiveThreadSearchInventory(): Promise<readonly Thread[]> {
|
||||
this.assertUsable();
|
||||
const key = activeThreadSearchInventoryQueryKey(this.context);
|
||||
const options = {
|
||||
queryKey: key,
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { signal })),
|
||||
};
|
||||
return this.readFreshThroughQueryCancellation(key, async () => cloneThreads(await this.client.fetchQuery(options)));
|
||||
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]> {
|
||||
return this.runWhileActive(async () => {
|
||||
const key = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
|
||||
const options = {
|
||||
queryKey: key,
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { signal })),
|
||||
};
|
||||
return this.readFreshThroughQueryCancellation(key, async () => cloneThreads(await this.client.fetchQuery(options)));
|
||||
});
|
||||
}
|
||||
|
||||
hasMoreActiveThreads(): boolean {
|
||||
if (this.disposed) return false;
|
||||
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey(this.context)));
|
||||
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY));
|
||||
}
|
||||
|
||||
async loadMoreActiveThreads(): Promise<readonly Thread[]> {
|
||||
this.assertUsable();
|
||||
const current = this.activeThreadsSnapshot() ?? (await this.fetchActiveThreads());
|
||||
const observer = new InfiniteQueryObserver(this.client, {
|
||||
...this.activeThreadsQueryOptions(),
|
||||
enabled: false,
|
||||
loadMoreActiveThreads(): Promise<readonly Thread[]> {
|
||||
return this.runWhileActive(async () => {
|
||||
const current = this.activeThreadsSnapshot() ?? (await this.fetchActiveThreads());
|
||||
const observer = new InfiniteQueryObserver(this.client, {
|
||||
...this.activeThreadsQueryOptions(),
|
||||
enabled: false,
|
||||
});
|
||||
try {
|
||||
if (!observer.getCurrentResult().hasNextPage) return current;
|
||||
const result = await observer.fetchNextPage({ cancelRefetch: false, throwOnError: true });
|
||||
this.assertUsable();
|
||||
return result.data ? cloneThreads(activeThreadsFromData(result.data) ?? []) : current;
|
||||
} catch (error) {
|
||||
if (error instanceof CancelledError) return this.activeThreadsSnapshot() ?? current;
|
||||
throw error;
|
||||
} finally {
|
||||
observer.destroy();
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (!observer.getCurrentResult().hasNextPage) return current;
|
||||
const result = await observer.fetchNextPage({ cancelRefetch: false, throwOnError: true });
|
||||
this.assertUsable();
|
||||
return result.data ? cloneThreads(activeThreadsFromData(result.data) ?? []) : current;
|
||||
} catch (error) {
|
||||
if (error instanceof CancelledError) return this.activeThreadsSnapshot() ?? current;
|
||||
throw error;
|
||||
} finally {
|
||||
observer.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async refreshArchivedThreads(): Promise<readonly Thread[]> {
|
||||
refreshArchivedThreads(): Promise<readonly Thread[]> {
|
||||
return this.fetchArchivedThreads({ force: true });
|
||||
}
|
||||
|
||||
async fetchArchivedThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
||||
this.assertUsable();
|
||||
const key = archivedThreadsQueryKey(this.context);
|
||||
if (options.force) {
|
||||
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||
this.assertUsable();
|
||||
}
|
||||
return this.readFreshThroughQueryCancellation(key, async () =>
|
||||
cloneThreads(await this.client.fetchQuery(this.archivedThreadsQueryOptions())),
|
||||
);
|
||||
fetchArchivedThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
||||
return this.runWhileActive(async () => {
|
||||
const key = ARCHIVED_THREADS_QUERY_KEY;
|
||||
if (options.force) {
|
||||
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||
this.assertUsable();
|
||||
}
|
||||
return this.readFreshThroughQueryCancellation(key, async () =>
|
||||
cloneThreads(await this.client.fetchQuery(this.archivedThreadsQueryOptions())),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
applyThreadListMutations(mutations: readonly ThreadListMutation[]): void {
|
||||
|
|
@ -214,7 +216,7 @@ export class AppServerQueryCache {
|
|||
if (mutations.length === 0) return;
|
||||
const activeMutations = mutations.filter((mutation) => mutation.list === "active");
|
||||
if (activeMutations.length > 0) {
|
||||
const key = activeThreadsQueryKey(this.context);
|
||||
const key = ACTIVE_THREADS_QUERY_KEY;
|
||||
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
|
||||
const fetchIsNextPage = this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward";
|
||||
void this.client.cancelQueries({ queryKey: key, exact: true });
|
||||
|
|
@ -222,7 +224,7 @@ export class AppServerQueryCache {
|
|||
const after = activeMutations.reduce<ActiveThreadData | undefined>(applyActiveThreadMutation, before);
|
||||
if (after !== before) this.client.setQueryData(key, after);
|
||||
void this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||
const searchKey = activeThreadSearchInventoryQueryKey(this.context);
|
||||
const searchKey = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
|
||||
void this.client.cancelQueries({ queryKey: searchKey, exact: true });
|
||||
void this.client.invalidateQueries({ queryKey: searchKey, refetchType: "none" });
|
||||
|
||||
|
|
@ -236,7 +238,7 @@ export class AppServerQueryCache {
|
|||
|
||||
const archivedMutations = mutations.filter((mutation) => mutation.list === "archived");
|
||||
if (archivedMutations.length > 0) {
|
||||
const key = archivedThreadsQueryKey(this.context);
|
||||
const key = ARCHIVED_THREADS_QUERY_KEY;
|
||||
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
|
||||
void this.client.cancelQueries({ queryKey: key, exact: true });
|
||||
const before = this.archivedThreadsSnapshot();
|
||||
|
|
@ -255,13 +257,13 @@ export class AppServerQueryCache {
|
|||
|
||||
private threadListSnapshot(kind: ThreadListKind): readonly Thread[] | null {
|
||||
if (kind === "active") return this.activeThreadsSnapshot();
|
||||
const threads = this.client.getQueryData<readonly Thread[]>(archivedThreadsQueryKey(this.context));
|
||||
const threads = this.client.getQueryData<readonly Thread[]>(ARCHIVED_THREADS_QUERY_KEY);
|
||||
return threads ? cloneThreads(threads) : null;
|
||||
}
|
||||
|
||||
appServerMetadataSnapshot(): SharedServerMetadata | null {
|
||||
if (this.disposed) return null;
|
||||
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(appServerRuntimeConfigQueryKey(this.context));
|
||||
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(RUNTIME_CONFIG_QUERY_KEY);
|
||||
if (!runtimeConfig) return null;
|
||||
const skills = this.metadataResourceState("skills");
|
||||
const permissionProfiles = this.metadataResourceState("permissionProfiles");
|
||||
|
|
@ -284,9 +286,9 @@ export class AppServerQueryCache {
|
|||
options: { emitCurrent?: boolean } = {},
|
||||
): () => void {
|
||||
this.assertUsable();
|
||||
let disposed = false;
|
||||
let unsubscribed = false;
|
||||
const emit = (resource: SharedServerMetadataResource): void => {
|
||||
if (!disposed) listener(cloneSharedServerMetadataResource(resource));
|
||||
if (!this.disposed && !unsubscribed) listener(cloneSharedServerMetadataResource(resource));
|
||||
};
|
||||
|
||||
const runtimeConfig = new QueryObserver(this.client, { ...this.runtimeConfigQueryOptions(), enabled: false });
|
||||
|
|
@ -337,46 +339,54 @@ export class AppServerQueryCache {
|
|||
emitPermissionProfiles(permissionProfiles.getCurrentResult(), true);
|
||||
emitRateLimits(rateLimits.getCurrentResult(), true);
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
return this.trackObserver(() => {
|
||||
unsubscribed = true;
|
||||
for (const unsubscribe of unsubscribers) unsubscribe();
|
||||
};
|
||||
runtimeConfig.destroy();
|
||||
models.destroy();
|
||||
skills.destroy();
|
||||
permissionProfiles.destroy();
|
||||
rateLimits.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
async refreshAppServerMetadata(): Promise<void> {
|
||||
this.assertUsable();
|
||||
const runtimeResult = this.fetchRuntimeConfig().then(
|
||||
() => ({ ok: true as const }),
|
||||
(error: unknown) => ({ ok: false as const, error }),
|
||||
);
|
||||
const [, runtime] = await Promise.all([
|
||||
Promise.allSettled([
|
||||
this.fetchMetadataResource("skills"),
|
||||
this.fetchMetadataResource("permissionProfiles"),
|
||||
this.fetchMetadataResource("rateLimits"),
|
||||
this.fetchModels({ force: true }),
|
||||
]),
|
||||
runtimeResult,
|
||||
]);
|
||||
this.assertUsable();
|
||||
if (!runtime.ok) throw runtime.error;
|
||||
refreshAppServerMetadata(): Promise<void> {
|
||||
return this.runWhileActive(async () => {
|
||||
const runtimeResult = this.fetchRuntimeConfig().then(
|
||||
() => ({ ok: true as const }),
|
||||
(error: unknown) => ({ ok: false as const, error }),
|
||||
);
|
||||
const [, runtime] = await Promise.all([
|
||||
Promise.allSettled([
|
||||
this.fetchMetadataResource("skills"),
|
||||
this.fetchMetadataResource("permissionProfiles"),
|
||||
this.fetchMetadataResource("rateLimits"),
|
||||
this.fetchModels({ force: true }),
|
||||
]),
|
||||
runtimeResult,
|
||||
]);
|
||||
this.assertUsable();
|
||||
if (!runtime.ok) throw runtime.error;
|
||||
});
|
||||
}
|
||||
|
||||
async refreshSkills(): Promise<void> {
|
||||
this.assertUsable();
|
||||
await this.refreshNotifiedMetadataResource("skills");
|
||||
this.assertUsable();
|
||||
refreshSkills(): Promise<void> {
|
||||
return this.runWhileActive(async () => {
|
||||
await this.refreshNotifiedMetadataResource("skills");
|
||||
this.assertUsable();
|
||||
});
|
||||
}
|
||||
|
||||
async refreshRateLimits(): Promise<void> {
|
||||
this.assertUsable();
|
||||
await this.refreshNotifiedMetadataResource("rateLimits");
|
||||
this.assertUsable();
|
||||
refreshRateLimits(): Promise<void> {
|
||||
return this.runWhileActive(async () => {
|
||||
await this.refreshNotifiedMetadataResource("rateLimits");
|
||||
this.assertUsable();
|
||||
});
|
||||
}
|
||||
|
||||
modelsSnapshot(): readonly ModelMetadata[] | null {
|
||||
if (this.disposed) return null;
|
||||
const models = this.client.getQueryData<readonly ModelMetadata[]>(appServerModelsQueryKey(this.context));
|
||||
const models = this.client.getQueryData<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
|
||||
return models ? cloneModelMetadata(models) : null;
|
||||
}
|
||||
|
||||
|
|
@ -385,19 +395,20 @@ export class AppServerQueryCache {
|
|||
return this.observeQueryResult(this.modelsQueryOptions(), cloneModelMetadata, listener, options);
|
||||
}
|
||||
|
||||
async fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
|
||||
this.assertUsable();
|
||||
const key = appServerModelsQueryKey(this.context);
|
||||
if (options.force) {
|
||||
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||
fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
|
||||
return this.runWhileActive(async () => {
|
||||
const key = MODELS_QUERY_KEY;
|
||||
if (options.force) {
|
||||
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||
this.assertUsable();
|
||||
}
|
||||
const models = await this.client.fetchQuery(this.modelsQueryOptions());
|
||||
this.assertUsable();
|
||||
}
|
||||
const models = await this.client.fetchQuery(this.modelsQueryOptions());
|
||||
this.assertUsable();
|
||||
return cloneModelMetadata(models);
|
||||
return cloneModelMetadata(models);
|
||||
});
|
||||
}
|
||||
|
||||
async refreshModels(): Promise<readonly ModelMetadata[]> {
|
||||
refreshModels(): Promise<readonly ModelMetadata[]> {
|
||||
return this.fetchModels({ force: true });
|
||||
}
|
||||
|
||||
|
|
@ -409,7 +420,7 @@ export class AppServerQueryCache {
|
|||
ActiveThreadCursor
|
||||
> {
|
||||
return {
|
||||
queryKey: activeThreadsQueryKey(this.context),
|
||||
queryKey: ACTIVE_THREADS_QUERY_KEY,
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
signal.throwIfAborted();
|
||||
const page = await this.runWithClient((client) =>
|
||||
|
|
@ -426,7 +437,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private archivedThreadsQueryOptions(): AppServerQueryOptions<readonly Thread[]> {
|
||||
return {
|
||||
queryKey: archivedThreadsQueryKey(this.context),
|
||||
queryKey: ARCHIVED_THREADS_QUERY_KEY,
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { archived: true, signal })).then(cloneThreads),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
|
|
@ -435,7 +446,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private runtimeConfigQueryOptions(): AppServerQueryOptions<RuntimeConfigSnapshot> {
|
||||
return {
|
||||
queryKey: appServerRuntimeConfigQueryKey(this.context),
|
||||
queryKey: RUNTIME_CONFIG_QUERY_KEY,
|
||||
queryFn: async (): Promise<RuntimeConfigSnapshot> =>
|
||||
this.runWithClient(async (client) =>
|
||||
runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, this.context.vaultPath)),
|
||||
|
|
@ -445,7 +456,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private skillsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly SkillMetadata[]>> {
|
||||
return {
|
||||
queryKey: appServerSkillsQueryKey(this.context),
|
||||
queryKey: SKILLS_QUERY_KEY,
|
||||
queryFn: async () =>
|
||||
this.runWithClient(async (client) => successfulMetadataResource(await readSkillMetadataProbe(client, this.context.vaultPath))),
|
||||
};
|
||||
|
|
@ -453,7 +464,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private permissionProfilesQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>> {
|
||||
return {
|
||||
queryKey: appServerPermissionProfilesQueryKey(this.context),
|
||||
queryKey: PERMISSION_PROFILES_QUERY_KEY,
|
||||
queryFn: async () =>
|
||||
this.runWithClient(async (client) =>
|
||||
successfulMetadataResource(await readPermissionProfileMetadataProbe(client, this.context.vaultPath)),
|
||||
|
|
@ -463,7 +474,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private rateLimitsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<RateLimitSnapshot | null>> {
|
||||
return {
|
||||
queryKey: appServerRateLimitsQueryKey(this.context),
|
||||
queryKey: RATE_LIMITS_QUERY_KEY,
|
||||
queryFn: async () => this.runWithClient(async (client) => successfulMetadataResource(await readRateLimitMetadataProbe(client))),
|
||||
};
|
||||
}
|
||||
|
|
@ -495,7 +506,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private async refreshNotifiedMetadataResource(resource: "skills" | "rateLimits"): Promise<void> {
|
||||
const queryKey = resource === "skills" ? appServerSkillsQueryKey(this.context) : appServerRateLimitsQueryKey(this.context);
|
||||
const queryKey = resource === "skills" ? SKILLS_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
|
||||
await this.client.cancelQueries({ queryKey, exact: true });
|
||||
this.assertUsable();
|
||||
try {
|
||||
|
|
@ -513,11 +524,7 @@ export class AppServerQueryCache {
|
|||
private metadataResourceState(resource: "rateLimits"): { value: RateLimitSnapshot | null; probe: DiagnosticProbeResult };
|
||||
private metadataResourceState(resource: MetadataResourceKind): { value: MetadataResourceValue; probe: DiagnosticProbeResult } {
|
||||
const key =
|
||||
resource === "skills"
|
||||
? appServerSkillsQueryKey(this.context)
|
||||
: resource === "permissionProfiles"
|
||||
? appServerPermissionProfilesQueryKey(this.context)
|
||||
: appServerRateLimitsQueryKey(this.context);
|
||||
resource === "skills" ? SKILLS_QUERY_KEY : resource === "permissionProfiles" ? PERMISSION_PROFILES_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
|
||||
const state = this.client.getQueryState<MetadataResourceSnapshot<MetadataResourceValue>>(key);
|
||||
const failedProbe = diagnosticProbeFromError(state?.error);
|
||||
return {
|
||||
|
|
@ -527,7 +534,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private modelsProbe(): DiagnosticProbeResult {
|
||||
const state = this.client.getQueryState<readonly ModelMetadata[]>(appServerModelsQueryKey(this.context));
|
||||
const state = this.client.getQueryState<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
|
||||
return (
|
||||
diagnosticProbeFromError(state?.error) ??
|
||||
(state?.data
|
||||
|
|
@ -538,7 +545,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private modelsQueryOptions(): AppServerQueryOptions<readonly ModelMetadata[]> {
|
||||
return {
|
||||
queryKey: appServerModelsQueryKey(this.context),
|
||||
queryKey: MODELS_QUERY_KEY,
|
||||
queryFn: async (): Promise<readonly ModelMetadata[]> => {
|
||||
try {
|
||||
return cloneModelMetadata(
|
||||
|
|
@ -565,11 +572,14 @@ export class AppServerQueryCache {
|
|||
enabled: false,
|
||||
});
|
||||
const emit = (result: QueryObserverResult<TQuery>): void => {
|
||||
listener(this.projectObservedResult(result, project));
|
||||
if (!this.disposed) listener(this.projectObservedResult(result, project));
|
||||
};
|
||||
const unsubscribe = observer.subscribe(emit);
|
||||
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
|
||||
return unsubscribe;
|
||||
return this.trackObserver(() => {
|
||||
unsubscribe();
|
||||
observer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
private projectObservedResult<TQuery, TValue>(
|
||||
|
|
@ -598,7 +608,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private runWithClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
|
||||
this.assertUsable();
|
||||
return this.clientRunner.runWithClient(operation, options);
|
||||
return this.clientAccess.withClient(operation, options);
|
||||
}
|
||||
|
||||
private async readThroughQueryCancellation<T>(read: () => Promise<T>, fallback?: () => T | null): Promise<T> {
|
||||
|
|
@ -625,7 +635,33 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private assertUsable(): void {
|
||||
if (this.disposed) throw new Error("Codex app-server query cache was disposed.");
|
||||
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||
}
|
||||
|
||||
private runWhileActive<T>(operation: () => Promise<T>): Promise<T> {
|
||||
this.assertUsable();
|
||||
return (async () => {
|
||||
try {
|
||||
const result = await operation();
|
||||
this.assertUsable();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private trackObserver(unsubscribe: () => void): () => void {
|
||||
let subscribed = true;
|
||||
const trackedUnsubscribe = (): void => {
|
||||
if (!subscribed) return;
|
||||
subscribed = false;
|
||||
this.observerUnsubscribes.delete(trackedUnsubscribe);
|
||||
unsubscribe();
|
||||
};
|
||||
this.observerUnsubscribes.add(trackedUnsubscribe);
|
||||
return trackedUnsubscribe;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../doma
|
|||
import type { ThreadActivationSnapshot } from "../../domain/threads/activation";
|
||||
import type { ArchiveThreadInput } from "../../domain/threads/archive-markdown";
|
||||
import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal";
|
||||
import type { HistoricalTurn } from "../../domain/threads/history";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import { REFERENCED_THREAD_TURN_LIMIT } from "../../domain/threads/reference";
|
||||
import type { TurnTranscriptSummary } from "../../domain/threads/transcript";
|
||||
|
|
@ -187,44 +186,61 @@ export async function readReferencedThreadTurnTranscriptSummaries(
|
|||
return chronologicalTurnTranscriptSummariesFromTurnRecords(response.data);
|
||||
}
|
||||
|
||||
export interface ThreadRollbackSnapshot {
|
||||
thread: Thread;
|
||||
turns: readonly HistoricalTurn[];
|
||||
}
|
||||
type ThreadForkPosition =
|
||||
| { readonly kind: "through-turn"; readonly turnId: string }
|
||||
| { readonly kind: "before-turn"; readonly turnId: string };
|
||||
|
||||
interface ThreadRollbackResult {
|
||||
readonly thread: ThreadRecord & {
|
||||
readonly turns: readonly HistoricalTurn[];
|
||||
interface AppServerForkThreadOptions {
|
||||
readonly position?: ThreadForkPosition;
|
||||
readonly deferGoalContinuation?: boolean;
|
||||
readonly runtime?: {
|
||||
readonly model?: string;
|
||||
readonly reasoningEffort?: string | null;
|
||||
readonly serviceTier?: ServiceTier | null;
|
||||
readonly approvalPolicy?: RuntimePermissionState["approvalPolicy"];
|
||||
readonly approvalsReviewer?: ApprovalsReviewer;
|
||||
readonly permissions?: string;
|
||||
readonly sandboxPolicy?: RuntimePermissionState["sandboxPolicy"];
|
||||
};
|
||||
}
|
||||
|
||||
function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackResult): ThreadRollbackSnapshot {
|
||||
return {
|
||||
thread: threadFromThreadRecord(response.thread),
|
||||
turns: response.thread.turns,
|
||||
};
|
||||
}
|
||||
|
||||
export async function rollbackThread(client: AppServerRequestClient, threadId: string, numTurns?: number): Promise<ThreadRollbackSnapshot> {
|
||||
const response = await client.request("thread/rollback", { threadId, numTurns: numTurns ?? 1 });
|
||||
return threadRollbackSnapshotFromAppServerResponse(response);
|
||||
}
|
||||
|
||||
export async function forkThread(
|
||||
client: AppServerRequestClient,
|
||||
threadId: string,
|
||||
cwd: string,
|
||||
lastTurnId: string | null = null,
|
||||
options: AppServerForkThreadOptions = {},
|
||||
): Promise<Thread> {
|
||||
const position = options.position;
|
||||
const runtime = options.runtime;
|
||||
const permissions = runtime?.permissions;
|
||||
const sandbox = permissions === undefined ? appServerSandboxMode(runtime?.sandboxPolicy) : undefined;
|
||||
const response = await client.request("thread/fork", {
|
||||
threadId,
|
||||
cwd,
|
||||
excludeTurns: true,
|
||||
...(lastTurnId ? { lastTurnId } : {}),
|
||||
...(position?.kind === "through-turn" ? { lastTurnId: position.turnId } : {}),
|
||||
...(position?.kind === "before-turn" ? { beforeTurnId: position.turnId } : {}),
|
||||
...(options.deferGoalContinuation ? { deferGoalContinuation: true } : {}),
|
||||
...(runtime?.model !== undefined ? { model: runtime.model } : {}),
|
||||
...(runtime?.serviceTier !== undefined ? { serviceTier: runtime.serviceTier } : {}),
|
||||
...(runtime?.approvalPolicy !== undefined && runtime.approvalPolicy !== null ? { approvalPolicy: runtime.approvalPolicy } : {}),
|
||||
...(runtime?.approvalsReviewer !== undefined ? { approvalsReviewer: runtime.approvalsReviewer } : {}),
|
||||
...(permissions !== undefined ? { permissions } : {}),
|
||||
...(sandbox !== undefined ? { sandbox } : {}),
|
||||
...(runtime?.reasoningEffort !== undefined ? { config: { model_reasoning_effort: runtime.reasoningEffort } } : {}),
|
||||
});
|
||||
return threadFromThreadRecord(response.thread);
|
||||
}
|
||||
|
||||
function appServerSandboxMode(
|
||||
policy: RuntimePermissionState["sandboxPolicy"] | undefined,
|
||||
): "read-only" | "workspace-write" | "danger-full-access" | undefined {
|
||||
if (!policy || policy.type === "externalSandbox") return undefined;
|
||||
if (policy.type === "dangerFullAccess") return "danger-full-access";
|
||||
if (policy.type === "readOnly") return "read-only";
|
||||
return "workspace-write";
|
||||
}
|
||||
|
||||
export interface EphemeralThreadForkSnapshot {
|
||||
readonly activation: ThreadActivationSnapshot;
|
||||
readonly sourceThreadId: string;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ export interface RequestAdditionalContext {
|
|||
key: string;
|
||||
value: string;
|
||||
kind: "untrusted" | "application";
|
||||
attachment?: TurnContextAttachment;
|
||||
}
|
||||
|
||||
export type CodexInputItem =
|
||||
|
|
@ -28,7 +27,6 @@ export type CodexInputItem =
|
|||
key: string;
|
||||
value: string;
|
||||
kind: RequestAdditionalContext["kind"];
|
||||
attachment?: TurnContextAttachment;
|
||||
};
|
||||
|
||||
export type CodexInput = CodexInputItem[];
|
||||
|
|
@ -53,7 +51,6 @@ export function codexTextInputWithReferences(
|
|||
key: context.key,
|
||||
value: context.value,
|
||||
kind: context.kind,
|
||||
...(context.attachment ? { attachment: context.attachment } : {}),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
|
@ -61,5 +58,3 @@ export function codexTextInputWithReferences(
|
|||
export function codexTextInputWithAttachments(text: string, input: readonly CodexInputItem[]): CodexInput {
|
||||
return [...codexTextInput(text), ...input.filter((item) => item.type !== "text")];
|
||||
}
|
||||
|
||||
import type { TurnContextAttachment } from "./context-manifest";
|
||||
|
|
|
|||
|
|
@ -3,3 +3,8 @@ const PANEL_SUBMISSION_CLIENT_ID_PATTERN = /^local-(?:user|steer)-\d+-[A-Za-z0-9
|
|||
export function isPanelSubmissionClientId(value: string | null | undefined): value is string {
|
||||
return typeof value === "string" && PANEL_SUBMISSION_CLIENT_ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export function turnContextSubmissionId(value: string): string {
|
||||
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
|
||||
return safe || "context";
|
||||
}
|
||||
|
|
|
|||
19
src/domain/markdown/code-ranges.ts
Normal file
19
src/domain/markdown/code-ranges.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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(", ")}]`;
|
||||
}
|
||||
|
|
@ -1,20 +1,21 @@
|
|||
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../markdown/frontmatter";
|
||||
import { isExternalFileHref, parseFileHref } from "../vault/file-hrefs";
|
||||
import type { Link, Nodes } from "mdast";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { toMarkdown } from "mdast-util-to-markdown";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
import { parseFileHref } from "../vault/file-hrefs";
|
||||
import { isFilesystemAbsolutePath, isVaultConfigPath, normalizeFilePath, vaultRelativePath } from "../vault/paths";
|
||||
import type { Thread } from "./model";
|
||||
import { threadArchiveTitle } from "./title";
|
||||
import type { ThreadTranscriptEntry } from "./transcript";
|
||||
|
||||
interface ParsedMarkdownLink {
|
||||
raw: string;
|
||||
label: string;
|
||||
href: string;
|
||||
interface MarkdownSourceReplacement {
|
||||
start: number;
|
||||
end: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ArchiveExportSettings {
|
||||
archiveExportFolderTemplate: string;
|
||||
archiveExportFilenameTemplate: string;
|
||||
export interface ArchiveMarkdownOptions {
|
||||
archiveExportTags?: string;
|
||||
vaultPath?: string;
|
||||
vaultConfigDir?: string;
|
||||
|
|
@ -24,56 +25,31 @@ export interface ArchiveThreadInput extends Thread {
|
|||
transcriptEntries: readonly ThreadTranscriptEntry[];
|
||||
}
|
||||
|
||||
export function archivedThreadMarkdown(
|
||||
thread: ArchiveThreadInput,
|
||||
exportedAt = new Date(),
|
||||
settings?: Partial<ArchiveExportSettings>,
|
||||
): string {
|
||||
export function archivedThreadMarkdown(thread: ArchiveThreadInput, exportedAt = new Date(), settings: ArchiveMarkdownOptions = {}): string {
|
||||
const title = threadArchiveTitle(thread);
|
||||
const tags = normalizedArchiveTags(settings?.archiveExportTags ?? "");
|
||||
const lines = [
|
||||
const tags = normalizedArchiveTags(settings.archiveExportTags ?? "");
|
||||
const frontmatter = [
|
||||
"---",
|
||||
`title: ${yamlFrontmatterString(title)}`,
|
||||
`thread_id: ${yamlFrontmatterString(thread.id)}`,
|
||||
`created: ${yamlFrontmatterString(formatDate(exportedAt))}`,
|
||||
...frontmatterTagsLines(tags),
|
||||
"---",
|
||||
"",
|
||||
`# ${title}`,
|
||||
"",
|
||||
...transcriptMarkdownLines(thread.transcriptEntries),
|
||||
];
|
||||
const markdown = `${trimTrailingBlankLines(lines).join("\n")}\n`;
|
||||
return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath, settings.vaultConfigDir) : markdown;
|
||||
].join("\n");
|
||||
const body = `${trimTrailingBlankLines([`# ${title}`, "", ...transcriptMarkdownLines(thread.transcriptEntries)]).join("\n")}\n`;
|
||||
const normalizedBody = settings.vaultPath ? normalizeExportedMarkdownLinks(body, settings.vaultPath, settings.vaultConfigDir) : body;
|
||||
return `${frontmatter}\n\n${normalizedBody}`;
|
||||
}
|
||||
|
||||
function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
|
||||
const lines = markdown.split("\n");
|
||||
let inFence = false;
|
||||
return lines
|
||||
.map((line) => {
|
||||
if (line.trimStart().startsWith("```")) {
|
||||
inFence = !inFence;
|
||||
return line;
|
||||
}
|
||||
return inFence ? line : normalizeExportedMarkdownLinksInLine(line, vaultPath, vaultConfigDir);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function normalizeExportedMarkdownLinksInLine(line: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
while (index < line.length) {
|
||||
const parsed = parseMarkdownLinkAt(line, index);
|
||||
if (!parsed || isInsideInlineCode(line, index)) {
|
||||
output += line[index] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += normalizedExportedMarkdownLink(parsed, vaultPath, vaultConfigDir);
|
||||
index = parsed.end;
|
||||
const replacements: MarkdownSourceReplacement[] = [];
|
||||
visit(fromMarkdown(markdown), "link", (link) => {
|
||||
const replacement = normalizedExportedMarkdownLink(link, vaultPath, vaultConfigDir);
|
||||
if (replacement) replacements.push(replacement);
|
||||
});
|
||||
let output = markdown;
|
||||
for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
|
||||
output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
|
@ -163,67 +139,45 @@ function stripMatchingQuotes(value: string): string {
|
|||
return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value;
|
||||
}
|
||||
|
||||
function unwrappedMarkdownHref(value: string): string {
|
||||
if (value.startsWith("<") && value.endsWith(">")) return value.slice(1, -1);
|
||||
return value;
|
||||
}
|
||||
function normalizedExportedMarkdownLink(
|
||||
link: Link,
|
||||
vaultPath: string,
|
||||
vaultConfigDir: string | null | undefined,
|
||||
): MarkdownSourceReplacement | null {
|
||||
const start = link.position?.start.offset;
|
||||
const end = link.position?.end.offset;
|
||||
if (start === undefined || end === undefined || !link.url) return null;
|
||||
|
||||
function parseMarkdownLinkAt(line: string, start: number): ParsedMarkdownLink | null {
|
||||
if (line[start] !== "[" || line[start - 1] === "!") return null;
|
||||
const parsed = parseFileHref(link.url);
|
||||
if (!parsed) return null;
|
||||
const vaultRelative = vaultRelativePath(vaultPath, parsed.path);
|
||||
if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) {
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
value: markdownNodeSource({ ...link, url: `${vaultRelative}${parsed.subpath}` }),
|
||||
};
|
||||
}
|
||||
|
||||
const labelEnd = line.indexOf("]", start + 1);
|
||||
if (labelEnd === -1 || line[labelEnd + 1] !== "(") return null;
|
||||
|
||||
const hrefStart = labelEnd + 2;
|
||||
const hrefEnd = line[hrefStart] === "<" ? line.indexOf(">)", hrefStart + 1) : line.indexOf(")", hrefStart);
|
||||
if (hrefEnd === -1) return null;
|
||||
|
||||
const end = line[hrefStart] === "<" ? hrefEnd + 2 : hrefEnd + 1;
|
||||
if (!isFilesystemAbsolutePath(normalizeFilePath(parsed.path))) return null;
|
||||
return {
|
||||
raw: line.slice(start, end),
|
||||
label: line.slice(start + 1, labelEnd),
|
||||
href: line.slice(hrefStart, hrefEnd + (line[hrefStart] === "<" ? 1 : 0)),
|
||||
start,
|
||||
end,
|
||||
value: markdownNodeSource({
|
||||
type: "paragraph",
|
||||
children: [...link.children, { type: "text", value: " (" }, { type: "inlineCode", value: link.url }, { type: "text", value: ")" }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedExportedMarkdownLink(link: ParsedMarkdownLink, vaultPath: string, vaultConfigDir: string | null | undefined): string {
|
||||
const href = unwrappedMarkdownHref(link.href.trim());
|
||||
if (!href || isExternalFileHref(href)) return link.raw;
|
||||
|
||||
const parsed = parseFileHref(href);
|
||||
if (!parsed) return link.raw;
|
||||
const vaultRelative = vaultRelativePath(vaultPath, parsed.path);
|
||||
if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) {
|
||||
return `[${link.label}](${markdownHref(`${vaultRelative}${parsed.subpath}`)})`;
|
||||
}
|
||||
|
||||
return isFilesystemAbsolutePath(normalizeFilePath(parsed.path)) ? `${link.label} (${markdownCodeSpan(href)})` : link.raw;
|
||||
function markdownNodeSource(node: Nodes): string {
|
||||
return toMarkdown(node, { unsafe: [{ character: "|", inConstruct: ["label", "phrasing"] }] }).trimEnd();
|
||||
}
|
||||
|
||||
function archiveExportShouldKeepAbsolute(vaultRelativePath: string, vaultConfigDir: string | null | undefined): boolean {
|
||||
return typeof vaultConfigDir === "string" && vaultConfigDir.length > 0 && isVaultConfigPath(vaultRelativePath, vaultConfigDir);
|
||||
}
|
||||
|
||||
function markdownHref(value: string): string {
|
||||
return /[\s()]/.test(value) ? `<${value}>` : value;
|
||||
}
|
||||
|
||||
function markdownCodeSpan(value: string): string {
|
||||
let fenceLength = 1;
|
||||
for (const match of value.matchAll(/`+/g)) fenceLength = Math.max(fenceLength, match[0].length + 1);
|
||||
const fence = "`".repeat(fenceLength);
|
||||
return fenceLength === 1 ? `${fence}${value}${fence}` : `${fence} ${value} ${fence}`;
|
||||
}
|
||||
|
||||
function isInsideInlineCode(line: string, offset: number): boolean {
|
||||
let inCode = false;
|
||||
for (let index = 0; index < offset; index += 1) {
|
||||
if (line[index] === "`") inCode = !inCode;
|
||||
}
|
||||
return inCode;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
|
@ -237,3 +191,11 @@ function trimTrailingBlankLines(lines: string[]): string[] {
|
|||
while (result[result.length - 1] === "") result.pop();
|
||||
return result;
|
||||
}
|
||||
|
||||
function yamlFrontmatterString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function yamlFrontmatterInlineList(values: readonly string[]): string {
|
||||
return `[${values.map(yamlFrontmatterString).join(", ")}]`;
|
||||
}
|
||||
|
|
|
|||
29
src/domain/threads/deep-link.ts
Normal file
29
src/domain/threads/deep-link.ts
Normal 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, "\\$&");
|
||||
}
|
||||
|
|
@ -6,6 +6,11 @@ export interface Thread {
|
|||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
readonly recencyAt?: number | null;
|
||||
/**
|
||||
* Whether the loaded app-server thread accepts direct turn input.
|
||||
* `null` means the capability is unavailable, so panel mode policy decides.
|
||||
*/
|
||||
readonly canAcceptDirectInput?: boolean | null;
|
||||
readonly provenance: ThreadProvenance;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { truncateUtf8, utf8ByteLength } from "../chat/context-budget";
|
||||
import type { Thread } from "./model";
|
||||
import { threadDisplayTitle } from "./title";
|
||||
import type { TurnTranscriptSummary } from "./transcript";
|
||||
|
||||
export const REFERENCED_THREAD_TURN_LIMIT = 20;
|
||||
|
|
@ -14,27 +13,12 @@ export interface ReferencedThreadMetadata {
|
|||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface ReferencedThreadContextBundle {
|
||||
value: string;
|
||||
referencedThread: ReferencedThreadMetadata;
|
||||
}
|
||||
|
||||
const REFERENCED_THREAD_CONTEXT_MAX_BYTES = 18_000;
|
||||
|
||||
function referencedThreadMetadata(thread: Thread, count: number): ReferencedThreadMetadata {
|
||||
return {
|
||||
threadId: thread.id,
|
||||
title: threadDisplayTitle(thread),
|
||||
includedTurns: count,
|
||||
turnLimit: REFERENCED_THREAD_TURN_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
export function referencedThreadContextBundle(thread: Thread, turns: readonly TurnTranscriptSummary[]): ReferencedThreadContextBundle {
|
||||
export function referencedThreadContext(thread: Thread, turns: readonly TurnTranscriptSummary[]): string {
|
||||
const rendered = turns.map((turn, index) => renderedReferenceTurn(turn, index + 1));
|
||||
const included: string[] = [];
|
||||
let bytes = 0;
|
||||
let truncatedTurn = false;
|
||||
for (let index = rendered.length - 1; index >= 0; index -= 1) {
|
||||
const value = rendered[index];
|
||||
if (value === undefined) continue;
|
||||
|
|
@ -43,7 +27,6 @@ export function referencedThreadContextBundle(thread: Thread, turns: readonly Tu
|
|||
if (included.length === 0) {
|
||||
const turn = turns[index];
|
||||
if (turn) included.unshift(truncatedReferenceTurn(turn, index + 1, REFERENCED_THREAD_CONTEXT_MAX_BYTES));
|
||||
truncatedTurn = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -51,22 +34,14 @@ export function referencedThreadContextBundle(thread: Thread, turns: readonly Tu
|
|||
bytes += nextBytes;
|
||||
}
|
||||
const omittedTurns = turns.length - included.length;
|
||||
const reference = {
|
||||
...referencedThreadMetadata(thread, included.length),
|
||||
omittedTurns,
|
||||
truncated: omittedTurns > 0 || truncatedTurn,
|
||||
};
|
||||
return {
|
||||
value: [
|
||||
"Referenced thread context for the current user input:",
|
||||
`Thread: ${thread.id}`,
|
||||
`Included turns: ${String(reference.includedTurns)}`,
|
||||
`Omitted turns: ${String(omittedTurns)}`,
|
||||
"",
|
||||
...included,
|
||||
].join("\n"),
|
||||
referencedThread: reference,
|
||||
};
|
||||
return [
|
||||
"Referenced thread context for the current user input:",
|
||||
`Thread: ${thread.id}`,
|
||||
`Included turns: ${String(included.length)}`,
|
||||
`Omitted turns: ${String(omittedTurns)}`,
|
||||
"",
|
||||
...included,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderedReferenceTurn(turn: TurnTranscriptSummary, index: number): string {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
import type { ThreadTitleContext } from "./title-generation-model";
|
||||
|
||||
type ThreadRenameAutoNameState = { kind: "checking" } | { kind: "unavailable" } | { kind: "ready"; context: ThreadTitleContext };
|
||||
|
||||
export type ThreadRenameLifecycleState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "editing"; draft: string }
|
||||
| { kind: "generating"; draft: string; originalDraft: string; generationToken: number };
|
||||
| { kind: "editing"; draft: string; autoName: ThreadRenameAutoNameState }
|
||||
| { kind: "saving"; draft: string; autoName: ThreadRenameAutoNameState; saveToken: number }
|
||||
| { kind: "generating"; draft: string; autoName: Extract<ThreadRenameAutoNameState, { kind: "ready" }>; generationToken: number };
|
||||
|
||||
export type ThreadRenameActiveState = Exclude<ThreadRenameLifecycleState, { kind: "idle" }>;
|
||||
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
|
||||
type ThreadRenameSavingState = Extract<ThreadRenameLifecycleState, { kind: "saving" }>;
|
||||
|
||||
export type ThreadRenameLifecycleEvent =
|
||||
| { type: "started"; draft: string }
|
||||
| { type: "draft-updated"; draft: string }
|
||||
| { type: "auto-name-context-resolved"; context: ThreadTitleContext | null }
|
||||
| { type: "cancelled" }
|
||||
| { type: "save-started"; saveToken: number }
|
||||
| { type: "save-failed"; saveToken: number }
|
||||
| { type: "save-succeeded"; saveToken: number }
|
||||
| { type: "generation-started"; generationToken: number }
|
||||
| { type: "generation-succeeded"; generationToken: number; draft: string }
|
||||
| { type: "generation-finished"; generationToken: number }
|
||||
|
|
@ -25,25 +35,40 @@ export function transitionThreadRenameLifecycleState(
|
|||
): ThreadRenameLifecycleState {
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
return { kind: "editing", draft: event.draft };
|
||||
if (state.kind === "saving") return state;
|
||||
return { kind: "editing", draft: event.draft, autoName: { kind: "checking" } };
|
||||
case "draft-updated":
|
||||
return state.kind === "idle" ? state : { ...state, draft: event.draft };
|
||||
return state.kind === "editing" ? { ...state, draft: event.draft } : state;
|
||||
case "auto-name-context-resolved":
|
||||
if (state.kind !== "editing" && state.kind !== "saving") return state;
|
||||
return {
|
||||
...state,
|
||||
autoName: event.context ? { kind: "ready", context: event.context } : { kind: "unavailable" },
|
||||
};
|
||||
case "cancelled":
|
||||
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
||||
return state.kind === "saving" || state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
||||
case "save-started":
|
||||
return state.kind === "editing" ? { ...state, kind: "saving", saveToken: event.saveToken } : state;
|
||||
case "save-failed":
|
||||
return threadRenameSaveStillActive(state, event.saveToken)
|
||||
? { kind: "editing", draft: state.draft, autoName: state.autoName }
|
||||
: state;
|
||||
case "save-succeeded":
|
||||
return threadRenameSaveStillActive(state, event.saveToken) ? initialThreadRenameLifecycleState() : state;
|
||||
case "generation-started":
|
||||
if (state.kind !== "editing") return state;
|
||||
if (state.kind !== "editing" || state.autoName.kind !== "ready") return state;
|
||||
return {
|
||||
kind: "generating",
|
||||
draft: state.draft,
|
||||
originalDraft: state.draft,
|
||||
autoName: state.autoName,
|
||||
generationToken: event.generationToken,
|
||||
};
|
||||
case "generation-succeeded":
|
||||
if (!threadRenameGenerationStillActive(state, event.generationToken) || state.draft !== state.originalDraft) return state;
|
||||
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
|
||||
return { ...state, draft: event.draft };
|
||||
case "generation-finished":
|
||||
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
|
||||
return { kind: "editing", draft: state.draft };
|
||||
return { kind: "editing", draft: state.draft, autoName: state.autoName };
|
||||
case "cleared":
|
||||
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
||||
default:
|
||||
|
|
@ -58,6 +83,10 @@ export function threadRenameGenerationStillActive(
|
|||
return state.kind === "generating" && state.generationToken === generationToken;
|
||||
}
|
||||
|
||||
export function threadRenameSaveStillActive(state: ThreadRenameLifecycleState, saveToken: number): state is ThreadRenameSavingState {
|
||||
return state.kind === "saving" && state.saveToken === saveToken;
|
||||
}
|
||||
|
||||
function unhandledThreadRenameLifecycleEvent(event: never): never {
|
||||
throw new Error(`Unhandled thread rename lifecycle event: ${String(event)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@ const DEFAULT_CONTEXT_PAGE_LIMIT = 20;
|
|||
const DEFAULT_CONTEXT_MAX_PAGES = 5;
|
||||
|
||||
export const THREAD_TITLE_MAX_CHARS = 40;
|
||||
export const THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE =
|
||||
"Auto-name needs completed history or visible resumed history with both user and assistant text.";
|
||||
|
||||
export interface ThreadTitleContext {
|
||||
userRequest: string;
|
||||
assistantResponse: string;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH = 96;
|
|||
const MAX_THREAD_COMMAND_DISPLAY_TITLE_LENGTH = 96;
|
||||
const UNTITLED_THREAD_TITLE = "Untitled thread";
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
export function threadMeaningfulTitle(thread: Thread): string | null {
|
||||
for (const value of [thread.name, thread.preview]) {
|
||||
|
|
@ -52,6 +53,10 @@ function normalizeThreadTitleText(value: string | null | undefined): string {
|
|||
}
|
||||
|
||||
function truncateThreadDisplayTitle(title: string, maxLength: number): string {
|
||||
if (title.length <= maxLength) return title;
|
||||
return `${title.slice(0, maxLength - 3).trimEnd()}...`;
|
||||
const graphemes = Array.from(GRAPHEME_SEGMENTER.segment(title), ({ segment }) => segment);
|
||||
if (graphemes.length <= maxLength) return title;
|
||||
return `${graphemes
|
||||
.slice(0, maxLength - 3)
|
||||
.join("")
|
||||
.trimEnd()}...`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import type { App } from "obsidian";
|
|||
|
||||
import type { AppServerClient } from "./app-server/connection/client";
|
||||
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access";
|
||||
import type { AppServerExecutionContext } from "./app-server/connection/execution-context";
|
||||
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
|
||||
import type { AppServerQueryContext } from "./app-server/query/keys";
|
||||
import { AppServerResourceStore, StaleAppServerResourceContextError } from "./app-server/query/resource-store";
|
||||
import { AppServerQueryCache } from "./app-server/query/cache";
|
||||
import {
|
||||
type EphemeralStructuredTurnClient,
|
||||
type EphemeralStructuredTurnRunner,
|
||||
|
|
@ -16,22 +16,28 @@ import { createAppServerSelectionRewriteTransport } from "./features/selection-r
|
|||
import type { SelectionRewriteTransport } from "./features/selection-rewrite/transport";
|
||||
import { openThreadPicker, type ThreadPickerController } from "./features/thread-picker/modal.obsidian";
|
||||
import { createThreadOperationsTransport, createThreadTitleTransport } from "./features/threads/app-server/workflow-transports";
|
||||
import { createThreadCatalog, type ThreadCatalog, type ThreadCatalogEvent } from "./features/threads/catalog/thread-catalog";
|
||||
import { createThreadNameMutationCoordinator } from "./features/threads/workflows/thread-name-mutation-coordinator";
|
||||
import type { ThreadCatalog } from "./features/threads/catalog/thread-catalog";
|
||||
import {
|
||||
createThreadOperationCoordinator,
|
||||
type ThreadOperationCoordinator,
|
||||
} from "./features/threads/workflows/thread-operation-coordinator";
|
||||
import type { ThreadLifecycleEvent } from "./features/threads/workflows/thread-operation-event";
|
||||
import { projectThreadListChanges } from "./features/threads/workflows/thread-read-model-projection";
|
||||
import type { ThreadsViewHost, ThreadsViewSettingsAccess } from "./features/threads-view/session";
|
||||
import type { ThreadsViewPanelActivity } from "./features/threads-view/state";
|
||||
import type { ThreadsRuntimeView } from "./features/threads-view/view.obsidian";
|
||||
import { createSettingsAppServerDynamicData } from "./settings/app-server-dynamic-data";
|
||||
import type { SettingsDynamicDataAccess } from "./settings/dynamic-data";
|
||||
import type { CodexPanelSettings } from "./settings/model";
|
||||
import { StaleExecutionRuntimeError } from "./shared/runtime/execution-runtime-lifetime";
|
||||
import { createKeyedOperationQueue } from "./shared/runtime/keyed-operation-queue";
|
||||
|
||||
export interface CodexExecutionRuntimeOptions {
|
||||
app: App;
|
||||
context: AppServerQueryContext;
|
||||
context: AppServerExecutionContext;
|
||||
settings: () => CodexPanelSettings;
|
||||
workspace: WorkspacePanels;
|
||||
onThreadCatalogEvent(event: ThreadCatalogEvent): void;
|
||||
onThreadLifecycleEvents(events: readonly ThreadLifecycleEvent[]): void;
|
||||
openNewPanel(): Promise<unknown>;
|
||||
openThreadInCurrentView(threadId: string): Promise<void>;
|
||||
openThreadInAvailableView(threadId: string): Promise<void>;
|
||||
|
|
@ -44,11 +50,12 @@ export interface ExecutionRuntimeViews {
|
|||
}
|
||||
|
||||
export class CodexExecutionRuntime implements AppServerClientAccess {
|
||||
readonly context: Readonly<AppServerQueryContext>;
|
||||
readonly resourceStore: AppServerResourceStore;
|
||||
readonly threadCatalog: ThreadCatalog;
|
||||
private readonly context: Readonly<AppServerExecutionContext>;
|
||||
private readonly appServerQueries: AppServerQueryCache;
|
||||
private readonly threadCatalog: ThreadCatalog;
|
||||
private readonly threadOperationCoordinator: ThreadOperationCoordinator;
|
||||
readonly settingsDynamicData: SettingsDynamicDataAccess;
|
||||
private readonly threadNameMutations = createThreadNameMutationCoordinator();
|
||||
private readonly threadNameMutations = createKeyedOperationQueue<string>();
|
||||
private readonly threadGoalOperations = createThreadGoalOperationCoordinator();
|
||||
private readonly runtimeSettingsCommitQueue = createKeyedOperationQueue<string>();
|
||||
private readonly shortLivedClients = new Set<AppServerClient>();
|
||||
|
|
@ -61,38 +68,31 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
|
||||
constructor(private readonly options: CodexExecutionRuntimeOptions) {
|
||||
this.context = Object.freeze({ ...options.context });
|
||||
this.resourceStore = new AppServerResourceStore({
|
||||
context: this.context,
|
||||
clientRunner: {
|
||||
runWithClient: (operation, clientOptions) => this.runWithAppServerClient(operation, clientOptions),
|
||||
},
|
||||
});
|
||||
this.threadCatalog = createThreadCatalog({
|
||||
store: this.resourceStore,
|
||||
onEventApplied: (event) => {
|
||||
options.onThreadCatalogEvent(event);
|
||||
},
|
||||
this.appServerQueries = new AppServerQueryCache(this.context, this);
|
||||
this.threadCatalog = this.appServerQueries;
|
||||
this.threadOperationCoordinator = createThreadOperationCoordinator((events) => {
|
||||
this.threadCatalog.applyThreadListMutations(projectThreadListChanges(this.threadCatalog, events));
|
||||
options.onThreadLifecycleEvents(events);
|
||||
});
|
||||
this.settingsDynamicData = createSettingsAppServerDynamicData({
|
||||
vaultPath: this.context.vaultPath,
|
||||
clientAccess: this,
|
||||
appServerQueries: this.resourceStore,
|
||||
appServerQueries: this.appServerQueries,
|
||||
threadCatalog: this.threadCatalog,
|
||||
threadEvents: this.threadOperationCoordinator,
|
||||
});
|
||||
}
|
||||
|
||||
chatHost(): CodexChatHost {
|
||||
private chatHost(): CodexChatHost {
|
||||
this.assertActive();
|
||||
return {
|
||||
appServerClientAccess: this,
|
||||
appServerContext: this.context,
|
||||
settingsRef: {
|
||||
settings: this.chatSettings(),
|
||||
vaultPath: this.context.vaultPath,
|
||||
},
|
||||
settings: this.chatSettings(),
|
||||
workspace: this.options.workspace,
|
||||
appServerQueries: this.resourceStore,
|
||||
appServerQueries: this.appServerQueries,
|
||||
threadCatalog: this.threadCatalog,
|
||||
threadOperationCoordinator: this.threadOperationCoordinator,
|
||||
threadNameMutations: this.threadNameMutations,
|
||||
threadTitleTransport: this.threadTitleTransport(),
|
||||
threadGoalOperations: this.threadGoalOperations,
|
||||
|
|
@ -100,12 +100,13 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
};
|
||||
}
|
||||
|
||||
threadsHost(): ThreadsViewHost {
|
||||
private threadsHost(): ThreadsViewHost {
|
||||
this.assertActive();
|
||||
return {
|
||||
settings: this.threadsSettings(),
|
||||
vaultPath: this.context.vaultPath,
|
||||
threadCatalog: this.threadCatalog,
|
||||
threadEvents: this.threadOperationCoordinator,
|
||||
threadNameMutations: this.threadNameMutations,
|
||||
threadOperationsTransport: createThreadOperationsTransport(this),
|
||||
threadTitleTransport: this.threadTitleTransport(),
|
||||
|
|
@ -263,7 +264,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
});
|
||||
this.shortLivedClients.clear();
|
||||
this.tryCleanup(() => {
|
||||
this.resourceStore.dispose();
|
||||
this.appServerQueries.dispose();
|
||||
});
|
||||
return views;
|
||||
}
|
||||
|
|
@ -277,20 +278,25 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
this.assertActive();
|
||||
return operation(client);
|
||||
};
|
||||
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
|
||||
created: (client) => {
|
||||
if (this.disposed) {
|
||||
client.disconnect();
|
||||
throw new StaleAppServerResourceContextError();
|
||||
}
|
||||
this.shortLivedClients.add(client);
|
||||
},
|
||||
disposed: (client) => {
|
||||
this.shortLivedClients.delete(client);
|
||||
},
|
||||
});
|
||||
this.assertActive();
|
||||
return result;
|
||||
try {
|
||||
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
|
||||
created: (client) => {
|
||||
if (this.disposed) {
|
||||
client.disconnect();
|
||||
throw new StaleExecutionRuntimeError();
|
||||
}
|
||||
this.shortLivedClients.add(client);
|
||||
},
|
||||
disposed: (client) => {
|
||||
this.shortLivedClients.delete(client);
|
||||
},
|
||||
});
|
||||
this.assertActive();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private structuredTurnRunner(): EphemeralStructuredTurnRunner {
|
||||
|
|
@ -311,7 +317,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
created: (client) => {
|
||||
if (this.disposed) {
|
||||
client.disconnect();
|
||||
throw new StaleAppServerResourceContextError();
|
||||
throw new StaleExecutionRuntimeError();
|
||||
}
|
||||
this.structuredTurnClients.add(client);
|
||||
},
|
||||
|
|
@ -340,7 +346,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
}
|
||||
|
||||
private assertActive(): void {
|
||||
if (this.disposed) throw new StaleAppServerResourceContextError();
|
||||
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||
}
|
||||
|
||||
private tryCleanup(operation: () => void): boolean {
|
||||
|
|
@ -362,21 +368,15 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,
|
||||
archiveExportTags: this.options.settings().archiveExportTags,
|
||||
}),
|
||||
codexPath: () => this.context.codexPath,
|
||||
scrollThreadFromComposerEdges: () => this.options.settings().scrollThreadFromComposerEdges,
|
||||
sendShortcut: () => this.options.settings().sendShortcut,
|
||||
showToolbar: () => this.options.settings().showToolbar,
|
||||
threadNamingEffort: () => this.options.settings().threadNamingEffort,
|
||||
threadNamingModel: () => this.options.settings().threadNamingModel,
|
||||
};
|
||||
}
|
||||
|
||||
private threadsSettings(): ThreadsViewSettingsAccess {
|
||||
return {
|
||||
archiveExportEnabled: () => this.options.settings().archiveExportEnabled,
|
||||
codexPath: () => this.context.codexPath,
|
||||
threadNamingModel: () => this.options.settings().threadNamingModel,
|
||||
threadNamingEffort: () => this.options.settings().threadNamingEffort,
|
||||
archiveExportSettings: () => ({
|
||||
archiveExportFolderTemplate: this.options.settings().archiveExportFolderTemplate,
|
||||
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
type PendingUserInput,
|
||||
} from "../../../../domain/pending-requests/model";
|
||||
import type { TurnTranscriptSummary } from "../../../../domain/threads/transcript";
|
||||
import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
|
||||
import type { ThreadOperationEvent } from "../../../threads/workflows/thread-operation-event";
|
||||
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
|
||||
import type { LocalIdSource } from "../../application/local-id-source";
|
||||
import { activeThreadId, type ChatAction, type ChatState } from "../../application/state/root-reducer";
|
||||
|
|
@ -34,7 +34,7 @@ export interface ChatInboundHandlerActions {
|
|||
refreshServerDiagnostics: (options?: { forceResourceProbes?: boolean }) => void;
|
||||
applyAppServerResourceEvent: (event: AppServerResourceEvent) => void;
|
||||
maybeNameThread: (threadId: string, turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null) => void;
|
||||
applyThreadCatalogEvent: (event: ThreadCatalogEvent) => void;
|
||||
applyThreadOperationEvent: (event: ThreadOperationEvent) => void;
|
||||
respondToServerRequest: (requestId: RequestId, result: unknown) => boolean;
|
||||
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
|
||||
}
|
||||
|
|
@ -248,8 +248,8 @@ function runNotificationEffect(context: ChatInboundHandlerContext, effect: ChatN
|
|||
case "maybe-name-thread":
|
||||
context.actions.maybeNameThread(effect.threadId, effect.turnId, effect.completedTurnTranscriptSummary);
|
||||
return;
|
||||
case "apply-thread-catalog-event":
|
||||
context.actions.applyThreadCatalogEvent(effect.event);
|
||||
case "apply-thread-operation-event":
|
||||
context.actions.applyThreadOperationEvent(effect.event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { ServerNotification } from "../../../../app-server/connection/rpc-m
|
|||
import { threadFromAppServerRecord } from "../../../../app-server/services/threads";
|
||||
import { threadTokenUsageFromRuntimeUsage } from "../../../../domain/runtime/metrics";
|
||||
import { normalizeExplicitThreadName } from "../../../../domain/threads/model";
|
||||
import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
|
||||
import type { ThreadOperationEvent } from "../../../threads/workflows/thread-operation-event";
|
||||
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
|
||||
import { activeThreadSettingsAppliedAction } from "../../application/state/actions";
|
||||
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../../application/state/root-reducer";
|
||||
|
|
@ -22,7 +22,7 @@ export type ChatNotificationEffect =
|
|||
}
|
||||
| { type: "refresh-server-diagnostics"; forceResourceProbes?: boolean }
|
||||
| { type: "apply-app-server-resource-event"; event: AppServerResourceEvent }
|
||||
| { type: "apply-thread-catalog-event"; event: ThreadCatalogEvent };
|
||||
| { type: "apply-thread-operation-event"; event: ThreadOperationEvent };
|
||||
|
||||
type TurnRuntimeCompletedTurnTranscriptSummary = TurnRuntimeOutcome["completedTurnTranscriptSummary"];
|
||||
|
||||
|
|
@ -291,17 +291,23 @@ function planThreadLifecycle(
|
|||
case "thread/started":
|
||||
return threadStartedPlan(state, notification);
|
||||
case "thread/archived":
|
||||
return effectPlan({ type: "apply-thread-catalog-event", event: { type: "thread-archived", threadId: notification.params.threadId } });
|
||||
return effectPlan({
|
||||
type: "apply-thread-operation-event",
|
||||
event: { type: "thread-archived", threadId: notification.params.threadId },
|
||||
});
|
||||
case "thread/deleted":
|
||||
return effectPlan({ type: "apply-thread-catalog-event", event: { type: "thread-deleted", threadId: notification.params.threadId } });
|
||||
return effectPlan({
|
||||
type: "apply-thread-operation-event",
|
||||
event: { type: "thread-deleted", threadId: notification.params.threadId },
|
||||
});
|
||||
case "thread/unarchived":
|
||||
return effectPlan({
|
||||
type: "apply-thread-catalog-event",
|
||||
type: "apply-thread-operation-event",
|
||||
event: { type: "thread-unarchived", threadId: notification.params.threadId },
|
||||
});
|
||||
case "thread/name/updated":
|
||||
return effectPlan({
|
||||
type: "apply-thread-catalog-event",
|
||||
type: "apply-thread-operation-event",
|
||||
event: {
|
||||
type: "thread-renamed",
|
||||
threadId: notification.params.threadId,
|
||||
|
|
@ -333,8 +339,12 @@ function threadStartedPlan(
|
|||
? []
|
||||
: [
|
||||
{
|
||||
type: "apply-thread-catalog-event",
|
||||
event: { type: "thread-upserted", thread },
|
||||
type: "apply-thread-operation-event",
|
||||
event: {
|
||||
type: "thread-upserted",
|
||||
thread,
|
||||
forkedFromThreadId: notification.params.thread.forkedFromId,
|
||||
},
|
||||
},
|
||||
];
|
||||
return { actions: trackAction, effects };
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ const IGNORED_SERVER_NOTIFICATION_METHODS = [
|
|||
"thread/status/changed",
|
||||
"thread/closed",
|
||||
"rawResponseItem/completed",
|
||||
"rawResponse/completed",
|
||||
"thread/environment/connected",
|
||||
"thread/environment/disconnected",
|
||||
"command/exec/outputDelta",
|
||||
"process/outputDelta",
|
||||
"process/exited",
|
||||
|
|
@ -157,6 +160,7 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
|
|||
"item/autoApprovalReview/completed": threadTurnNotificationScope,
|
||||
"item/completed": threadTurnNotificationScope,
|
||||
"rawResponseItem/completed": threadTurnNotificationScope,
|
||||
"rawResponse/completed": threadTurnNotificationScope,
|
||||
"item/agentMessage/delta": threadTurnNotificationScope,
|
||||
"item/plan/delta": threadTurnNotificationScope,
|
||||
"command/exec/outputDelta": unscopedNotificationScope,
|
||||
|
|
@ -200,6 +204,8 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
|
|||
"windows/worldWritableWarning": unscopedNotificationScope,
|
||||
"windowsSandbox/setupCompleted": unscopedNotificationScope,
|
||||
"account/login/completed": unscopedNotificationScope,
|
||||
"thread/environment/connected": threadOnlyNotificationScope,
|
||||
"thread/environment/disconnected": threadOnlyNotificationScope,
|
||||
};
|
||||
|
||||
export function routeServerNotification(notification: ServerNotification, scope: ActiveRouteScope): ServerNotificationRoute {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/execution-state";
|
||||
import type { ExecutionState } from "../../../domain/thread-stream/items";
|
||||
import { type ExecutionState, RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/items";
|
||||
|
||||
export { RUNNING_EXECUTION_STATE };
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
import { jsonPreview } from "../../../../../domain/display/json-preview";
|
||||
import type { HistoricalTurn } from "../../../../../domain/threads/history";
|
||||
import type { TurnTranscriptSummary } from "../../../../../domain/threads/transcript";
|
||||
import { contextAttachmentsFromManifest } from "../../../domain/thread-stream/format/context-attachments";
|
||||
import { contextAttachmentsFromHistoryContexts } from "../../../domain/thread-stream/format/context-attachments";
|
||||
import { threadStreamFileReferences } from "../../../domain/thread-stream/format/file-references";
|
||||
import { normalizeProposedPlanMarkdown } from "../../../domain/thread-stream/format/proposed-plan";
|
||||
import { userMessageDisplayText } from "../../../domain/thread-stream/format/user-message-text";
|
||||
|
|
@ -134,7 +134,7 @@ function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStr
|
|||
const text = projection.text;
|
||||
const referencedThread = projection.referencedThread;
|
||||
const referencedFiles = threadStreamFileReferences(projection.fileReferences);
|
||||
const contextAttachments = contextAttachmentsFromManifest(projection.manifest, text);
|
||||
const contextAttachments = contextAttachmentsFromHistoryContexts(projection.contexts, text);
|
||||
if (referencedThread) {
|
||||
return {
|
||||
...turnItemSourceFields(item, turnId),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { AppServerRequestClient } from "../../../app-server/services/request-client";
|
||||
import { readReferencedThreadTurnTranscriptSummaries } from "../../../app-server/services/threads";
|
||||
import { type CodexInput, codexTextInputWithAttachments } from "../../../domain/chat/input";
|
||||
import { threadReferenceMarkdown } from "../../../domain/threads/deep-link";
|
||||
import { shortThreadId } from "../../../domain/threads/id";
|
||||
import type { Thread } from "../../../domain/threads/model";
|
||||
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadContextBundle } from "../../../domain/threads/reference";
|
||||
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadContext } from "../../../domain/threads/reference";
|
||||
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
|
||||
import type { ThreadReferenceInput } from "../application/turns/slash-command-execution";
|
||||
|
||||
|
|
@ -40,28 +41,20 @@ async function referencedThreadInput(
|
|||
return null;
|
||||
}
|
||||
const messageInput = host.prepareInput(message, snapshot);
|
||||
const reference = referencedThreadContextBundle(thread, turns);
|
||||
const reference = referencedThreadContext(thread, turns);
|
||||
const visibleText = `${threadReferenceMarkdown(thread)}\n\n${messageInput.text}`;
|
||||
host.setStatus(`Referencing ${shortThreadId(thread.id)} (${String(turns.length)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`);
|
||||
return {
|
||||
text: messageInput.text,
|
||||
input: codexTextInputWithAttachments(messageInput.text, [
|
||||
text: visibleText,
|
||||
input: codexTextInputWithAttachments(visibleText, [
|
||||
{
|
||||
type: "additionalContext",
|
||||
key: "codex_panel_referenced_thread",
|
||||
kind: "untrusted",
|
||||
value: reference.value,
|
||||
attachment: {
|
||||
kind: "referencedThread",
|
||||
threadId: reference.referencedThread.threadId,
|
||||
includedTurns: reference.referencedThread.includedTurns,
|
||||
turnLimit: reference.referencedThread.turnLimit,
|
||||
omittedTurns: reference.referencedThread.omittedTurns ?? 0,
|
||||
truncated: reference.referencedThread.truncated ?? false,
|
||||
},
|
||||
value: reference,
|
||||
},
|
||||
...messageInput.input,
|
||||
]),
|
||||
referencedThread: reference.referencedThread,
|
||||
};
|
||||
} catch (error) {
|
||||
if (host.currentClient() !== client) return null;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
readThreadGoal,
|
||||
recordThreadGoalUserMessage,
|
||||
resumeThread,
|
||||
rollbackThread,
|
||||
setThreadGoal,
|
||||
startThread,
|
||||
threadActivationSnapshotFromAppServerResponse,
|
||||
|
|
@ -30,7 +29,7 @@ import type {
|
|||
ThreadResumeSnapshot,
|
||||
ThreadResumeTransport,
|
||||
} from "../../application/threads/thread-loading-transport";
|
||||
import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport";
|
||||
import type { ThreadMutationTransport } from "../../application/threads/thread-mutation-transport";
|
||||
import type { ThreadStartTransport } from "../../application/threads/thread-start-transport";
|
||||
import type { ThreadSubscriptionTransport } from "../../application/threads/thread-subscription-transport";
|
||||
import type { ChatTurnTransport } from "../../application/turns/turn-transport";
|
||||
|
|
@ -166,16 +165,8 @@ function createChatThreadMutationTransport(host: ChatAppServerTransportHost): Th
|
|||
runCurrentChatAppServerEffect(host, async (client) => {
|
||||
await compactThread(client, threadId);
|
||||
}),
|
||||
forkThread: (threadId, lastTurnId = null) =>
|
||||
runCurrentChatAppServerEffect(host, (client) => forkThread(client, threadId, host.vaultPath, lastTurnId)),
|
||||
rollbackThread: (threadId) =>
|
||||
runCurrentChatAppServerEffect(host, async (client): Promise<ThreadRollbackSnapshot> => {
|
||||
const snapshot = await rollbackThread(client, threadId);
|
||||
return {
|
||||
thread: snapshot.thread,
|
||||
items: threadStreamItemsFromTurns(snapshot.turns),
|
||||
};
|
||||
}),
|
||||
forkThread: (threadId, options) =>
|
||||
runCurrentChatAppServerEffect(host, (client) => forkThread(client, threadId, host.vaultPath, options)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
export interface DailyNoteReferenceCandidate {
|
||||
keyword: "today" | "tomorrow" | "yesterday";
|
||||
display: string;
|
||||
name: string;
|
||||
path: string;
|
||||
linktext: string;
|
||||
}
|
||||
|
|
@ -1,6 +1,28 @@
|
|||
import type { VaultFileReference } from "../../../../domain/chat/input";
|
||||
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
|
||||
import type { NoteCandidate } from "./suggestions";
|
||||
|
||||
export interface DailyNoteReferenceCandidate {
|
||||
keyword: "today" | "tomorrow" | "yesterday";
|
||||
display: string;
|
||||
name: string;
|
||||
path: string;
|
||||
linktext: string;
|
||||
}
|
||||
|
||||
export interface NoteHeadingCandidate {
|
||||
heading: string;
|
||||
linkHeading: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface NoteCandidate {
|
||||
basename: string;
|
||||
displayName: string;
|
||||
path: string;
|
||||
mtime: number;
|
||||
linktext: string;
|
||||
headings: NoteHeadingCandidate[];
|
||||
recentIndex: number | null;
|
||||
}
|
||||
|
||||
export interface NoteCandidateProvider {
|
||||
candidates(sourcePath: string): readonly NoteCandidate[];
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
type SelectionContextReference,
|
||||
selectionContextReferenceMarker,
|
||||
} from "./context-references";
|
||||
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
|
||||
import type { DailyNoteReferenceCandidate, NoteCandidate, NoteHeadingCandidate } from "./note-context";
|
||||
import { isSlashCommandName, SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands";
|
||||
import {
|
||||
partialThreadTitleQuery,
|
||||
|
|
@ -51,22 +51,6 @@ export interface ComposerSuggestionOptions {
|
|||
tagCandidates?: readonly string[] | (() => readonly string[]);
|
||||
}
|
||||
|
||||
export interface NoteCandidate {
|
||||
basename: string;
|
||||
displayName: string;
|
||||
path: string;
|
||||
mtime: number;
|
||||
linktext: string;
|
||||
headings: NoteHeadingCandidate[];
|
||||
recentIndex: number | null;
|
||||
}
|
||||
|
||||
interface NoteHeadingCandidate {
|
||||
heading: string;
|
||||
linkHeading: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface NoteCandidateMatch {
|
||||
file: NoteCandidate;
|
||||
match: SearchResult;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type SkillReference,
|
||||
type VaultFileReference,
|
||||
} from "../../../../domain/chat/input";
|
||||
import { type MarkdownCodeRange, markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../domain/markdown/code-ranges";
|
||||
import {
|
||||
type ActiveNoteContextReference,
|
||||
type ComposerContextReferences,
|
||||
|
|
@ -43,10 +44,11 @@ interface PreparedComposerInputOptions {
|
|||
referenceActiveNoteOnSend: boolean;
|
||||
}
|
||||
|
||||
function parsedWikiLinks(text: string): ParsedWikiLink[] {
|
||||
function parsedWikiLinks(text: string, codeRanges: readonly MarkdownCodeRange[]): ParsedWikiLink[] {
|
||||
const links: ParsedWikiLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of text.matchAll(WIKILINK_PATTERN)) {
|
||||
if (markdownCodeRangeContainsOffset(codeRanges, match.index)) continue;
|
||||
const rawValue = match[1];
|
||||
if (rawValue === undefined) continue;
|
||||
const raw = rawValue.trim();
|
||||
|
|
@ -67,14 +69,15 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
|
|||
contextReferences: ComposerContextReferences,
|
||||
options: PreparedComposerInputOptions,
|
||||
): PreparedComposerInput {
|
||||
const contextReplacement = textWithContextReferences(text, contextReferences);
|
||||
const codeRanges = markdownReferenceCodeRanges(text);
|
||||
const contextReplacement = textWithContextReferences(text, contextReferences, codeRanges);
|
||||
const resolvedText = contextReplacement.text;
|
||||
const fileReferences: VaultFileReference[] = [];
|
||||
const wikilinkReferences: ObsidianReference[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? [];
|
||||
|
||||
for (const link of parsedWikiLinks(resolvedText)) {
|
||||
for (const link of parsedWikiLinks(resolvedText, codeRanges)) {
|
||||
const fileReference = activeNoteFileReferenceForLink(link, activeNoteSnapshots) ?? resolveFileReference(link.target);
|
||||
if (!fileReference || seenPaths.has(fileReference.path)) continue;
|
||||
seenPaths.add(fileReference.path);
|
||||
|
|
@ -94,7 +97,7 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
|
|||
const skillByName = firstEnabledSkillByName(skills);
|
||||
const resolvedSkills: SkillReference[] = [];
|
||||
const seenSkillPaths = new Set<string>();
|
||||
for (const reference of parsedSkillReferences(resolvedText)) {
|
||||
for (const reference of parsedSkillReferences(resolvedText, codeRanges)) {
|
||||
const skill = skillByName.get(reference.toLowerCase());
|
||||
if (!skill || seenSkillPaths.has(skill.path)) continue;
|
||||
seenSkillPaths.add(skill.path);
|
||||
|
|
@ -120,19 +123,24 @@ function activeNoteFileReferenceForLink(link: ParsedWikiLink, snapshots: readonl
|
|||
function textWithContextReferences(
|
||||
text: string,
|
||||
contextReferences: ComposerContextReferences,
|
||||
codeRanges: readonly MarkdownCodeRange[],
|
||||
): { text: string; selections: SelectionContextReference[] } {
|
||||
return {
|
||||
text,
|
||||
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? []),
|
||||
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? [], codeRanges),
|
||||
};
|
||||
}
|
||||
|
||||
function selectionsReferencedByText(text: string, snapshots: readonly SelectionContextReference[]): SelectionContextReference[] {
|
||||
function selectionsReferencedByText(
|
||||
text: string,
|
||||
snapshots: readonly SelectionContextReference[],
|
||||
codeRanges: readonly MarkdownCodeRange[],
|
||||
): SelectionContextReference[] {
|
||||
const selections: SelectionContextReference[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const snapshot of snapshots) {
|
||||
const marker = selectionContextReferenceMarker(snapshot);
|
||||
if (!text.includes(marker) || seen.has(marker)) continue;
|
||||
if (!includesOutsideMarkdownCode(text, marker, codeRanges) || seen.has(marker)) continue;
|
||||
seen.add(marker);
|
||||
selections.push(snapshot);
|
||||
}
|
||||
|
|
@ -171,13 +179,11 @@ function referenceKey(marker: string, path: string): string {
|
|||
|
||||
function obsidianContextAdditionalContext(references: readonly ObsidianReference[]): RequestAdditionalContext[] {
|
||||
if (references.length === 0) return [];
|
||||
const inlineExcerpts = references.filter((reference) => reference.excerpt !== undefined).length;
|
||||
return [
|
||||
{
|
||||
key: OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY,
|
||||
kind: "untrusted",
|
||||
value: obsidianContextValue(references),
|
||||
...(inlineExcerpts > 0 ? { attachment: { kind: "obsidian" as const, inlineExcerpts } } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -202,10 +208,12 @@ function excerptLines(references: readonly (ObsidianReference & { excerpt: strin
|
|||
});
|
||||
}
|
||||
|
||||
function parsedSkillReferences(text: string): string[] {
|
||||
function parsedSkillReferences(text: string, codeRanges: readonly MarkdownCodeRange[]): string[] {
|
||||
const references: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) {
|
||||
const prefix = match[1] ?? "";
|
||||
if (markdownCodeRangeContainsOffset(codeRanges, match.index + prefix.length)) continue;
|
||||
const name = match[2];
|
||||
if (name === undefined) continue;
|
||||
const key = name.toLowerCase();
|
||||
|
|
@ -216,6 +224,19 @@ function parsedSkillReferences(text: string): string[] {
|
|||
return references;
|
||||
}
|
||||
|
||||
function markdownReferenceCodeRanges(text: string): MarkdownCodeRange[] {
|
||||
return text.includes("[[") || text.includes("$") ? markdownCodeRanges(text) : [];
|
||||
}
|
||||
|
||||
function includesOutsideMarkdownCode(text: string, value: string, codeRanges: readonly MarkdownCodeRange[]): boolean {
|
||||
let index = text.indexOf(value);
|
||||
while (index >= 0) {
|
||||
if (!markdownCodeRangeContainsOffset(codeRanges, index)) return true;
|
||||
index = text.indexOf(value, index + value.length);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseWikiLink(raw: string): ParsedWikiLink | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export interface ChatConnectionActionsHost {
|
|||
addSystemMessage: (text: string) => void;
|
||||
configuredCommand: () => string;
|
||||
isStaleConnectionError: (error: unknown) => boolean;
|
||||
isStaleResourceContextError: (error: unknown) => boolean;
|
||||
isStaleRuntimeError: (error: unknown) => boolean;
|
||||
notifyConnectionFailed: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +53,7 @@ function handleChatConnectionExit(host: ChatConnectionExitHost): void {
|
|||
}
|
||||
|
||||
export interface ChatConnectionActions {
|
||||
ensureInitialized(): Promise<void>;
|
||||
ensureConnected(): Promise<void>;
|
||||
invalidate(): void;
|
||||
handleExit(): void;
|
||||
|
|
@ -63,29 +64,40 @@ export interface ChatConnectionActions {
|
|||
|
||||
export function createChatConnectionActions(host: ChatConnectionActionsHost): ChatConnectionActions {
|
||||
let generation = 0;
|
||||
let activeConnection: { generation: number; promise: Promise<void> } | null = null;
|
||||
let activeConnection: { generation: number; initialization: Promise<void>; hydration: Promise<void> } | null = null;
|
||||
const invalidate = (): void => {
|
||||
generation += 1;
|
||||
activeConnection = null;
|
||||
};
|
||||
const isStale = (candidateGeneration: number): boolean => candidateGeneration !== generation;
|
||||
const startConnection = (): NonNullable<typeof activeConnection> => {
|
||||
const connectionGeneration = generation;
|
||||
const connectionIsStale = (): boolean => isStale(connectionGeneration);
|
||||
const initialization = initializeConnection(host, connectionIsStale);
|
||||
const hydration = initialization.then(async () => {
|
||||
if (connectionIsStale() || !host.connection.isConnected()) return;
|
||||
await hydrateConnectedResources(host, connectionIsStale);
|
||||
});
|
||||
const active = { generation: connectionGeneration, initialization, hydration };
|
||||
activeConnection = active;
|
||||
const clear = (): void => {
|
||||
if (activeConnection === active) activeConnection = null;
|
||||
};
|
||||
void hydration.then(clear, clear);
|
||||
return active;
|
||||
};
|
||||
const actions: ChatConnectionActions = {
|
||||
ensureInitialized: async () => {
|
||||
if (!host.canConnect()) return;
|
||||
if (activeConnection) return activeConnection.initialization;
|
||||
if (host.connection.isConnected()) return;
|
||||
await startConnection().initialization;
|
||||
},
|
||||
ensureConnected: async () => {
|
||||
if (!host.canConnect()) return;
|
||||
if (activeConnection) return activeConnection.promise;
|
||||
if (activeConnection) return activeConnection.hydration;
|
||||
if (host.connection.isConnected()) return;
|
||||
|
||||
const connectionGeneration = generation;
|
||||
const promise = initializeConnection(host, () => isStale(connectionGeneration));
|
||||
const active = { generation: connectionGeneration, promise };
|
||||
activeConnection = active;
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
if (activeConnection === active) {
|
||||
activeConnection = null;
|
||||
}
|
||||
}
|
||||
await startConnection().hydration;
|
||||
},
|
||||
invalidate,
|
||||
handleExit: () => {
|
||||
|
|
@ -105,7 +117,7 @@ async function refreshActiveThreads(host: ChatConnectionActionsHost): Promise<vo
|
|||
await host.refreshSharedThreads();
|
||||
host.refreshTabHeader();
|
||||
} catch (error) {
|
||||
if (host.isStaleResourceContextError(error)) return;
|
||||
if (host.isStaleRuntimeError(error)) return;
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
|
@ -143,22 +155,20 @@ async function initializeConnection(host: ChatConnectionActionsHost, isStale: ()
|
|||
} catch (error) {
|
||||
if (isStale()) return;
|
||||
if (host.isStaleConnectionError(error)) return;
|
||||
if (host.isStaleResourceContextError(error)) return;
|
||||
if (host.isStaleRuntimeError(error)) return;
|
||||
const message = connectionErrorMessage(error, host.configuredCommand());
|
||||
host.setStatus(STATUS_CONNECTION_FAILED, { kind: "failed", message });
|
||||
host.addSystemMessage(message);
|
||||
host.notifyConnectionFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
await hydrateConnectedResources(host, isStale);
|
||||
}
|
||||
|
||||
async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStale: () => boolean): Promise<void> {
|
||||
try {
|
||||
await host.metadata.refreshAppServerMetadata();
|
||||
} catch (error) {
|
||||
if (isStale() || host.isStaleResourceContextError(error)) return;
|
||||
if (isStale() || host.isStaleRuntimeError(error)) return;
|
||||
host.addSystemMessage(`Could not refresh Codex metadata: ${errorMessage(error)}`);
|
||||
}
|
||||
if (isStale()) return;
|
||||
|
|
@ -166,7 +176,7 @@ async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStal
|
|||
try {
|
||||
await host.refreshSharedThreads();
|
||||
} catch (error) {
|
||||
if (isStale() || host.isStaleResourceContextError(error)) return;
|
||||
if (isStale() || host.isStaleRuntimeError(error)) return;
|
||||
host.addSystemMessage(`Could not refresh Codex threads: ${errorMessage(error)}`);
|
||||
}
|
||||
if (isStale()) return;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export interface ServerMetadataActionsHost {
|
|||
refreshAppServerMetadata: () => Promise<void>;
|
||||
refreshSkills: () => Promise<void>;
|
||||
refreshRateLimits: () => Promise<void>;
|
||||
isStaleResourceContextError: (error: unknown) => boolean;
|
||||
isStaleRuntimeError: (error: unknown) => boolean;
|
||||
}
|
||||
|
||||
export interface ServerMetadataActions {
|
||||
|
|
@ -106,7 +106,7 @@ async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promis
|
|||
try {
|
||||
await host.refreshAppServerMetadata();
|
||||
} catch (error) {
|
||||
if (host.isStaleResourceContextError(error)) return;
|
||||
if (host.isStaleRuntimeError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ async function refreshMetadataResource(host: ServerMetadataActionsHost, refresh:
|
|||
try {
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
if (!host.isStaleResourceContextError(error)) throw error;
|
||||
if (!host.isStaleRuntimeError(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type ActivePanelThreadFacts =
|
|||
| {
|
||||
readonly phase: "active";
|
||||
readonly lifetime: "persistent" | "ephemeral";
|
||||
readonly canAcceptDirectInput: boolean | null;
|
||||
readonly provenance: "interactive" | "subagent" | null;
|
||||
};
|
||||
|
||||
|
|
@ -49,6 +50,13 @@ function activePanelOperationDecisionForFacts(
|
|||
}
|
||||
if (facts.phase === "empty") return ALLOWED;
|
||||
|
||||
if (operation === "submit") {
|
||||
if (facts.canAcceptDirectInput !== null) return facts.canAcceptDirectInput ? ALLOWED : directInputBlocked();
|
||||
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
|
||||
if (facts.lifetime === "ephemeral") return sideChatDecision(operation);
|
||||
return ALLOWED;
|
||||
}
|
||||
|
||||
// Keep the stricter interpretation if a malformed or future state contains
|
||||
// both mode facts. This makes mode restrictions monotonically safe.
|
||||
if (facts.provenance === "subagent" && facts.lifetime === "ephemeral") {
|
||||
|
|
@ -68,10 +76,15 @@ function activePanelThreadFacts(state: ChatState): ActivePanelThreadFacts {
|
|||
return {
|
||||
phase: "active",
|
||||
lifetime: panelThread.thread.lifetime?.kind ?? "persistent",
|
||||
canAcceptDirectInput: panelThread.thread.canAcceptDirectInput,
|
||||
provenance: panelThread.thread.provenance?.kind ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function directInputBlocked(): ActivePanelOperationDecision {
|
||||
return { kind: "blocked", message: "This thread cannot accept messages." };
|
||||
}
|
||||
|
||||
function agentThreadBlocked(operation: ActivePanelOperation): ActivePanelOperationDecision {
|
||||
switch (operation) {
|
||||
case "submit":
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ export interface ChatActiveThreadState {
|
|||
readonly goal: ThreadGoal | null;
|
||||
readonly tokenUsage: ThreadTokenUsage | null;
|
||||
readonly lifetime: ActiveThreadLifetime | null;
|
||||
readonly canAcceptDirectInput: boolean | null;
|
||||
readonly provenance: Thread["provenance"] | null;
|
||||
}
|
||||
|
||||
|
|
@ -463,6 +464,7 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
|
|||
goal: null,
|
||||
tokenUsage: null,
|
||||
lifetime: action.lifetime ?? { kind: "persistent" },
|
||||
canAcceptDirectInput: action.thread.canAcceptDirectInput ?? null,
|
||||
provenance: action.thread.provenance,
|
||||
},
|
||||
},
|
||||
|
|
@ -900,6 +902,7 @@ function createActiveThreadState(id: string): ChatActiveThreadState {
|
|||
goal: null,
|
||||
tokenUsage: null,
|
||||
lifetime: null,
|
||||
canAcceptDirectInput: null,
|
||||
provenance: null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,21 +5,28 @@ import {
|
|||
type ThreadRenameLifecycleEvent,
|
||||
type ThreadRenameLifecycleState,
|
||||
threadRenameGenerationStillActive,
|
||||
threadRenameSaveStillActive,
|
||||
transitionThreadRenameLifecycleState,
|
||||
} from "../../../../domain/threads/rename-lifecycle";
|
||||
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
|
||||
import type { DisclosureSetAction } from "./actions";
|
||||
import { patchObject } from "./patch";
|
||||
|
||||
export type ChatRenameUiState = { readonly kind: "idle" } | (ThreadRenameActiveState & { readonly threadId: string });
|
||||
|
||||
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
|
||||
export type ChatRenameSavingUiState = Extract<ChatRenameUiState, { kind: "saving" }>;
|
||||
type ChatRenameUiAction = Extract<
|
||||
UiAction,
|
||||
{
|
||||
type:
|
||||
| "ui/rename-started"
|
||||
| "ui/rename-draft-updated"
|
||||
| "ui/rename-auto-name-context-resolved"
|
||||
| "ui/rename-cancelled"
|
||||
| "ui/rename-save-started"
|
||||
| "ui/rename-save-failed"
|
||||
| "ui/rename-save-succeeded"
|
||||
| "ui/rename-generation-started"
|
||||
| "ui/rename-generation-succeeded"
|
||||
| "ui/rename-generation-finished"
|
||||
|
|
@ -71,7 +78,11 @@ export type UiAction =
|
|||
| { type: "ui/archive-confirm-set"; threadId: string | null }
|
||||
| { type: "ui/rename-started"; threadId: string; draft: string }
|
||||
| { type: "ui/rename-draft-updated"; threadId: string; draft: string }
|
||||
| { type: "ui/rename-auto-name-context-resolved"; threadId: string; context: ThreadTitleContext | null }
|
||||
| { type: "ui/rename-cancelled"; threadId: string }
|
||||
| { type: "ui/rename-save-started"; threadId: string; saveToken: number }
|
||||
| { type: "ui/rename-save-failed"; threadId: string; saveToken: number }
|
||||
| { type: "ui/rename-save-succeeded"; threadId: string; saveToken: number }
|
||||
| { type: "ui/rename-generation-started"; threadId: string; generationToken: number }
|
||||
| { type: "ui/rename-generation-succeeded"; threadId: string; generationToken: number; draft: string }
|
||||
| { type: "ui/rename-generation-finished"; threadId: string; generationToken: number }
|
||||
|
|
@ -99,7 +110,11 @@ export function isUiAction(action: { type: string }): action is UiAction {
|
|||
case "ui/archive-confirm-set":
|
||||
case "ui/rename-started":
|
||||
case "ui/rename-draft-updated":
|
||||
case "ui/rename-auto-name-context-resolved":
|
||||
case "ui/rename-cancelled":
|
||||
case "ui/rename-save-started":
|
||||
case "ui/rename-save-failed":
|
||||
case "ui/rename-save-succeeded":
|
||||
case "ui/rename-generation-started":
|
||||
case "ui/rename-generation-succeeded":
|
||||
case "ui/rename-generation-finished":
|
||||
|
|
@ -123,7 +138,11 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState
|
|||
return patchObject(state, { archiveConfirmThreadId: action.threadId });
|
||||
case "ui/rename-started":
|
||||
case "ui/rename-draft-updated":
|
||||
case "ui/rename-auto-name-context-resolved":
|
||||
case "ui/rename-cancelled":
|
||||
case "ui/rename-save-started":
|
||||
case "ui/rename-save-failed":
|
||||
case "ui/rename-save-succeeded":
|
||||
case "ui/rename-generation-started":
|
||||
case "ui/rename-generation-succeeded":
|
||||
case "ui/rename-generation-finished":
|
||||
|
|
@ -172,6 +191,10 @@ export function renameGenerationStillActive(
|
|||
return state.kind === "generating" && state.threadId === threadId && threadRenameGenerationStillActive(state, generationToken);
|
||||
}
|
||||
|
||||
export function renameSaveStillActive(state: ChatRenameUiState, threadId: string, saveToken: number): state is ChatRenameSavingUiState {
|
||||
return state.kind === "saving" && state.threadId === threadId && threadRenameSaveStillActive(state, saveToken);
|
||||
}
|
||||
|
||||
export function clearAllRequestDisclosures(state: ChatUiState): ChatUiState {
|
||||
if (state.disclosures.approvalDetails.size === 0) return state;
|
||||
return patchObject(state, {
|
||||
|
|
@ -253,11 +276,23 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string)
|
|||
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
|
||||
switch (action.type) {
|
||||
case "ui/rename-started":
|
||||
return { kind: "editing", threadId: action.threadId, draft: action.draft };
|
||||
if (state.kind === "saving") return state;
|
||||
return { kind: "editing", threadId: action.threadId, draft: action.draft, autoName: { kind: "checking" } };
|
||||
case "ui/rename-draft-updated":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "draft-updated", draft: action.draft });
|
||||
case "ui/rename-auto-name-context-resolved":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, {
|
||||
type: "auto-name-context-resolved",
|
||||
context: action.context,
|
||||
});
|
||||
case "ui/rename-cancelled":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "cancelled" });
|
||||
case "ui/rename-save-started":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-started", saveToken: action.saveToken });
|
||||
case "ui/rename-save-failed":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-failed", saveToken: action.saveToken });
|
||||
case "ui/rename-save-succeeded":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-succeeded", saveToken: action.saveToken });
|
||||
case "ui/rename-generation-started":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, {
|
||||
type: "generation-started",
|
||||
|
|
@ -304,12 +339,14 @@ function chatRenameLifecycleStateWithoutThreadId(state: ChatRenameUiState): Thre
|
|||
function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState, { kind: "idle" }>): ThreadRenameActiveState {
|
||||
switch (state.kind) {
|
||||
case "editing":
|
||||
return { kind: "editing", draft: state.draft };
|
||||
return { kind: "editing", draft: state.draft, autoName: state.autoName };
|
||||
case "saving":
|
||||
return { kind: "saving", draft: state.draft, autoName: state.autoName, saveToken: state.saveToken };
|
||||
case "generating":
|
||||
return {
|
||||
kind: "generating",
|
||||
draft: state.draft,
|
||||
originalDraft: state.originalDraft,
|
||||
autoName: state.autoName,
|
||||
generationToken: state.generationToken,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { ThreadTitleContext } from "../../../../domain/threads/title-genera
|
|||
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { threadStreamItems } from "../state/thread-stream";
|
||||
import { type ChatRenameUiState, renameGenerationStillActive } from "../state/ui-state";
|
||||
import { type ChatRenameUiState, renameGenerationStillActive, renameSaveStillActive } from "../state/ui-state";
|
||||
import { firstThreadTitleContextFromThreadStreamItems } from "./title-context";
|
||||
|
||||
interface RenameEditState {
|
||||
|
|
@ -16,7 +16,8 @@ export interface ThreadRenameEditorActionsHost {
|
|||
ensureConnected: () => Promise<void>;
|
||||
addSystemMessage: (text: string) => void;
|
||||
renameThread(threadId: string, value: string): Promise<boolean>;
|
||||
generateThreadTitle(threadId: string): Promise<string>;
|
||||
resolveThreadTitleContext(threadId: string): Promise<ThreadTitleContext | null>;
|
||||
generateThreadTitle(context: ThreadTitleContext, signal?: AbortSignal): Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface ThreadRenameEditorActions {
|
||||
|
|
@ -26,15 +27,22 @@ export interface ThreadRenameEditorActions {
|
|||
start(threadId: string): void;
|
||||
updateDraft(threadId: string, value: string): void;
|
||||
cancel(threadId: string): void;
|
||||
cancelAutoName(threadId: string): void;
|
||||
save(threadId: string, value: string): Promise<void>;
|
||||
autoNameDraft(threadId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions {
|
||||
let nextRenameGenerationToken = 1;
|
||||
let nextRenameSaveToken = 1;
|
||||
let activeGeneration: { threadId: string; generationToken: number; controller: AbortController } | null = null;
|
||||
let activeContextPreparation: { threadId: string } | null = null;
|
||||
|
||||
const action = {
|
||||
invalidate(): void {
|
||||
activeGeneration?.controller.abort();
|
||||
activeGeneration = null;
|
||||
activeContextPreparation = null;
|
||||
dispatch(host, { type: "ui/rename-cleared" });
|
||||
},
|
||||
|
||||
|
|
@ -52,9 +60,13 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
},
|
||||
|
||||
start(threadId: string): void {
|
||||
const current = renameState(host);
|
||||
if (current.kind === "saving") return;
|
||||
const thread = host.stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
|
||||
if (!thread) return;
|
||||
abortActiveGeneration();
|
||||
dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) });
|
||||
void prepareAutoName(threadId);
|
||||
},
|
||||
|
||||
updateDraft(threadId: string, value: string): void {
|
||||
|
|
@ -62,29 +74,38 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
},
|
||||
|
||||
cancel(threadId: string): void {
|
||||
const current = renameState(host);
|
||||
if (current.kind === "saving" && current.threadId === threadId) return;
|
||||
abortGeneration(threadId);
|
||||
if (activeContextPreparation?.threadId === threadId) activeContextPreparation = null;
|
||||
dispatch(host, { type: "ui/rename-cancelled", threadId });
|
||||
},
|
||||
|
||||
cancelAutoName(threadId: string): void {
|
||||
const current = renameState(host);
|
||||
if (current.kind !== "generating" || current.threadId !== threadId) return;
|
||||
abortGeneration(threadId);
|
||||
finishAutoNameDraftGeneration(host, threadId, current.generationToken);
|
||||
},
|
||||
|
||||
async save(threadId: string, value: string): Promise<void> {
|
||||
const current = renameState(host);
|
||||
if (current.kind === "idle" || current.threadId !== threadId || current.kind === "generating") return;
|
||||
const editingState = current;
|
||||
if (current.kind !== "editing" || current.threadId !== threadId) return;
|
||||
const saveToken = nextRenameSaveToken;
|
||||
dispatch(host, { type: "ui/rename-save-started", threadId, saveToken });
|
||||
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
|
||||
nextRenameSaveToken += 1;
|
||||
|
||||
try {
|
||||
await host.ensureConnected();
|
||||
if (renameState(host) !== editingState) return;
|
||||
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
|
||||
|
||||
const result = await host.renameThread(threadId, value);
|
||||
if (!result) {
|
||||
if (renameState(host) === editingState) action.cancel(threadId);
|
||||
return;
|
||||
}
|
||||
if (renameState(host) === editingState) {
|
||||
dispatch(host, { type: "ui/rename-cleared" });
|
||||
}
|
||||
await host.renameThread(threadId, value);
|
||||
dispatch(host, { type: "ui/rename-save-succeeded", threadId, saveToken });
|
||||
} catch (error) {
|
||||
if (renameState(host) !== editingState) return;
|
||||
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
dispatch(host, { type: "ui/rename-save-failed", threadId, saveToken });
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -92,34 +113,67 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
const current = renameState(host);
|
||||
if (current.kind !== "editing" || current.threadId !== threadId) return;
|
||||
|
||||
const editingState = current;
|
||||
|
||||
await host.ensureConnected();
|
||||
if (renameState(host) !== editingState) return;
|
||||
|
||||
dispatch(host, {
|
||||
type: "ui/rename-generation-started",
|
||||
threadId,
|
||||
generationToken: nextRenameGenerationToken,
|
||||
});
|
||||
const generationToken = nextRenameGenerationToken;
|
||||
if (!renameGenerationStillActive(renameState(host), threadId, generationToken)) return;
|
||||
const generating = renameState(host);
|
||||
if (!renameGenerationStillActive(generating, threadId, generationToken)) return;
|
||||
nextRenameGenerationToken += 1;
|
||||
const controller = new AbortController();
|
||||
activeGeneration = { threadId, generationToken, controller };
|
||||
|
||||
try {
|
||||
const title = await host.generateThreadTitle(threadId);
|
||||
const title = await host.generateThreadTitle(generating.autoName.context, controller.signal);
|
||||
if (!title) throw new Error("Codex did not return a usable thread title.");
|
||||
dispatch(host, { type: "ui/rename-generation-succeeded", threadId, generationToken, draft: title });
|
||||
} catch (error) {
|
||||
if (renameGenerationStillActive(renameState(host), threadId, generationToken)) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
clearGeneration(generationToken);
|
||||
finishAutoNameDraftGeneration(host, threadId, generationToken);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return action;
|
||||
|
||||
function abortGeneration(threadId: string): void {
|
||||
if (activeGeneration?.threadId !== threadId) return;
|
||||
abortActiveGeneration();
|
||||
}
|
||||
|
||||
function abortActiveGeneration(): void {
|
||||
if (!activeGeneration) return;
|
||||
activeGeneration.controller.abort();
|
||||
activeGeneration = null;
|
||||
}
|
||||
|
||||
function clearGeneration(generationToken: number): void {
|
||||
if (activeGeneration?.generationToken === generationToken) activeGeneration = null;
|
||||
}
|
||||
|
||||
async function prepareAutoName(threadId: string): Promise<void> {
|
||||
const preparation = { threadId };
|
||||
activeContextPreparation = preparation;
|
||||
let context: ThreadTitleContext | null = null;
|
||||
try {
|
||||
await host.ensureConnected();
|
||||
if (activeContextPreparation !== preparation) return;
|
||||
context = await host.resolveThreadTitleContext(threadId);
|
||||
} catch {
|
||||
// Auto-name availability is reflected by the disabled action.
|
||||
}
|
||||
if (activeContextPreparation !== preparation) return;
|
||||
activeContextPreparation = null;
|
||||
const current = renameState(host);
|
||||
if ((current.kind !== "editing" && current.kind !== "saving") || current.threadId !== threadId) return;
|
||||
dispatch(host, { type: "ui/rename-auto-name-context-resolved", threadId, context });
|
||||
}
|
||||
}
|
||||
|
||||
export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import { inheritedForkThreadName, type Thread } from "../../../../domain/threads
|
|||
import { activeThreadRuntimeState } from "../../domain/runtime/state";
|
||||
import { effectCompleted, effectCompletedInCurrentContext } from "../effect-outcome";
|
||||
import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy";
|
||||
import { resumedThreadActionFromActiveRuntime } from "../state/actions";
|
||||
import { capturePanelTargetLease, type PanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target";
|
||||
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import { activeThreadId, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream";
|
||||
import { chatTurnBusy } from "../turns/turn-state";
|
||||
|
|
@ -23,12 +22,23 @@ export interface ThreadManagementActionsHost {
|
|||
setStatus: (status: string) => void;
|
||||
setComposerText: (text: string) => void;
|
||||
openThreadInNewView: (threadId: string) => Promise<void>;
|
||||
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
|
||||
notifyActiveThreadIdentityChanged: () => void;
|
||||
recordThread: (thread: Thread) => void;
|
||||
openThreadInCurrentPanel: (threadId: string, onAdopted: () => void) => Promise<CurrentPanelAdoption>;
|
||||
beginThreadForkPublication: (sourceThreadId: string) => ThreadForkPublication;
|
||||
threadHasPendingOrRunningPanel: (threadId: string) => boolean;
|
||||
}
|
||||
|
||||
type CurrentPanelAdoption =
|
||||
| { readonly adopted: false }
|
||||
| {
|
||||
readonly adopted: true;
|
||||
readonly activityPublication: { publish(commit: () => void): void };
|
||||
};
|
||||
|
||||
interface ThreadForkPublication {
|
||||
record(thread: Thread): void;
|
||||
finish(options?: { sourceArchived?: boolean }): void;
|
||||
}
|
||||
|
||||
interface ThreadManagementOperations {
|
||||
renameThread(threadId: string, value: string): Promise<boolean>;
|
||||
archiveThread(threadId: string, options?: { saveMarkdown?: boolean }): Promise<boolean>;
|
||||
|
|
@ -133,16 +143,22 @@ async function forkThreadFromTurn(
|
|||
host.addSystemMessage("Could not find the selected turn to fork.");
|
||||
return;
|
||||
}
|
||||
let publication: ThreadForkPublication | null = null;
|
||||
let publicationFinished = false;
|
||||
let activityPublication: { publish(commit: () => void): void } | null = null;
|
||||
|
||||
try {
|
||||
const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads);
|
||||
if (!(await host.threadTransport.ensureConnected())) return;
|
||||
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
|
||||
const effect = turnId ? await host.threadTransport.forkThread(threadId, turnId) : await host.threadTransport.forkThread(threadId);
|
||||
publication = host.beginThreadForkPublication(threadId);
|
||||
const effect = turnId
|
||||
? await host.threadTransport.forkThread(threadId, { position: { kind: "through-turn", turnId } })
|
||||
: await host.threadTransport.forkThread(threadId);
|
||||
if (!effectCompleted(effect)) return;
|
||||
const forkedThread = effect.value;
|
||||
const forkedThreadId = forkedThread.id;
|
||||
host.recordThread(forkedThread);
|
||||
publication.record(forkedThread);
|
||||
if (!effectCompletedInCurrentContext(effect)) return;
|
||||
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
|
||||
if (sourceName) {
|
||||
|
|
@ -156,17 +172,32 @@ async function forkThreadFromTurn(
|
|||
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
|
||||
}
|
||||
if (archiveSource) {
|
||||
if (!(await archiveThreadFromPanel(host, threadId))) return;
|
||||
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
|
||||
let adoption: CurrentPanelAdoption;
|
||||
try {
|
||||
await host.openThreadInCurrentPanel(forkedThreadId);
|
||||
adoption = await host.openThreadInCurrentPanel(forkedThreadId, () => undefined);
|
||||
} catch (error) {
|
||||
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`);
|
||||
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in the current panel: ${message}`);
|
||||
return;
|
||||
}
|
||||
if (!adoption.adopted) {
|
||||
if (threadManagementScopeStillTargetsOriginalPanel(host, scope)) {
|
||||
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in the current panel.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
activityPublication = adoption.activityPublication;
|
||||
const sourceArchived = await archiveReplacedSource(host, threadId, forkedThreadId, {
|
||||
failureMessage: "Forked the thread, but could not archive the previous version",
|
||||
});
|
||||
activityPublication.publish(() => publication?.finish({ sourceArchived }));
|
||||
activityPublication = null;
|
||||
publicationFinished = true;
|
||||
return;
|
||||
}
|
||||
publication.finish();
|
||||
publicationFinished = true;
|
||||
try {
|
||||
await host.openThreadInNewView(forkedThreadId);
|
||||
} catch (error) {
|
||||
|
|
@ -177,6 +208,11 @@ async function forkThreadFromTurn(
|
|||
} catch (error) {
|
||||
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (!publicationFinished) {
|
||||
if (activityPublication) activityPublication.publish(() => publication?.finish());
|
||||
else publication?.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -204,43 +240,99 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
|
|||
host.addSystemMessage("No completed turn to roll back.");
|
||||
return;
|
||||
}
|
||||
const runtime = activeThreadRuntimeState(threadManagementState(host).runtime);
|
||||
const runtimeOverrides = {
|
||||
...(runtime.model ? { model: runtime.model } : {}),
|
||||
reasoningEffort: runtime.reasoningEffort,
|
||||
...(runtime.serviceTierKnown ? { serviceTier: runtime.serviceTier } : {}),
|
||||
...(runtime.approvalPolicyKnown && runtime.approvalPolicy ? { approvalPolicy: runtime.approvalPolicy } : {}),
|
||||
...(runtime.approvalsReviewer ? { approvalsReviewer: runtime.approvalsReviewer } : {}),
|
||||
...(runtime.permissionProfileKnown && runtime.activePermissionProfile
|
||||
? { permissions: runtime.activePermissionProfile.id }
|
||||
: runtime.sandboxPolicyKnown && runtime.sandboxPolicy
|
||||
? { sandboxPolicy: runtime.sandboxPolicy }
|
||||
: {}),
|
||||
};
|
||||
let publication: ThreadForkPublication | null = null;
|
||||
let publicationFinished = false;
|
||||
let activityPublication: { publish(commit: () => void): void } | null = null;
|
||||
|
||||
try {
|
||||
host.setStatus(STATUS_ROLLBACK_STARTING);
|
||||
if (!(await host.threadTransport.ensureConnected())) return;
|
||||
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
|
||||
const effect = await host.threadTransport.rollbackThread(threadId);
|
||||
if (!effectCompleted(effect)) return;
|
||||
if (!effectCompletedInCurrentContext(effect)) return;
|
||||
const snapshot = effect.value;
|
||||
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
|
||||
threadManagementDispatch(
|
||||
host,
|
||||
resumedThreadActionFromActiveRuntime({
|
||||
thread: snapshot.thread,
|
||||
runtime: activeThreadRuntimeState(threadManagementState(host).runtime),
|
||||
listedThreads: threadManagementState(host).threadList.listedThreads,
|
||||
expectedPanelTargetRevision: scope.panelTarget.revision,
|
||||
}),
|
||||
);
|
||||
threadManagementDispatch(host, {
|
||||
type: "thread-stream/items-replaced",
|
||||
items: snapshot.items,
|
||||
historyCursor: null,
|
||||
loadingHistory: false,
|
||||
publication = host.beginThreadForkPublication(threadId);
|
||||
const effect = await host.threadTransport.forkThread(threadId, {
|
||||
position: { kind: "before-turn", turnId: candidate.turnId },
|
||||
deferGoalContinuation: true,
|
||||
runtime: runtimeOverrides,
|
||||
});
|
||||
host.setComposerText(candidate.text);
|
||||
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
|
||||
host.setStatus(STATUS_ROLLBACK_COMPLETE);
|
||||
host.notifyActiveThreadIdentityChanged();
|
||||
host.recordThread(snapshot.thread);
|
||||
if (!effectCompleted(effect)) return;
|
||||
const forkedThread = effect.value;
|
||||
if (effectCompletedInCurrentContext(effect)) publication.record(forkedThread);
|
||||
else return;
|
||||
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
|
||||
const adoption = await host.openThreadInCurrentPanel(forkedThread.id, () => {
|
||||
host.setComposerText(candidate.text);
|
||||
});
|
||||
if (!adoption.adopted) {
|
||||
if (threadManagementScopeStillTargetsPanel(host, scope)) {
|
||||
host.addSystemMessage("The rolled-back version was created but could not be opened in this panel. Open it from thread history.");
|
||||
host.setStatus(STATUS_ROLLBACK_FAILED);
|
||||
}
|
||||
return;
|
||||
}
|
||||
activityPublication = adoption.activityPublication;
|
||||
if (activeThreadId(threadManagementState(host)) === forkedThread.id) {
|
||||
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
|
||||
host.setStatus(STATUS_ROLLBACK_COMPLETE);
|
||||
}
|
||||
const sourceArchived = await archiveReplacedSource(host, threadId, forkedThread.id, {
|
||||
saveMarkdown: false,
|
||||
failureMessage: "Rolled back the latest turn, but could not archive the previous version",
|
||||
});
|
||||
activityPublication.publish(() => publication?.finish({ sourceArchived }));
|
||||
activityPublication = null;
|
||||
publicationFinished = true;
|
||||
} catch (error) {
|
||||
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
host.setStatus(STATUS_ROLLBACK_FAILED);
|
||||
} finally {
|
||||
if (!publicationFinished) {
|
||||
if (activityPublication) activityPublication.publish(() => publication?.finish());
|
||||
else publication?.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveReplacedSource(
|
||||
host: ThreadManagementActionsHost,
|
||||
sourceThreadId: string,
|
||||
replacementThreadId: string,
|
||||
options: { readonly saveMarkdown?: boolean; readonly failureMessage: string },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const archiveOptions = options.saveMarkdown === undefined ? {} : { saveMarkdown: options.saveMarkdown };
|
||||
if (await host.operations.archiveThread(sourceThreadId, archiveOptions)) return true;
|
||||
reportReplacementArchiveFailure(host, replacementThreadId, options.failureMessage, "archive was not completed");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
reportReplacementArchiveFailure(host, replacementThreadId, options.failureMessage, message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function reportReplacementArchiveFailure(
|
||||
host: ThreadManagementActionsHost,
|
||||
replacementThreadId: string,
|
||||
failureMessage: string,
|
||||
detail: string,
|
||||
): void {
|
||||
if (activeThreadId(threadManagementState(host)) !== replacementThreadId) return;
|
||||
host.addSystemMessage(`${failureMessage}: ${detail}`);
|
||||
}
|
||||
|
||||
function activePanelOperationBlocked(host: ThreadManagementActionsHost, threadId: string, operation: ActivePanelOperation): boolean {
|
||||
const state = threadManagementState(host);
|
||||
if (activeThreadId(state) !== threadId) return false;
|
||||
|
|
@ -254,10 +346,6 @@ function threadManagementState(host: ThreadManagementActionsHost): ChatState {
|
|||
return host.stateStore.getState();
|
||||
}
|
||||
|
||||
function threadManagementDispatch(host: ThreadManagementActionsHost, action: ChatAction): void {
|
||||
host.stateStore.dispatch(action);
|
||||
}
|
||||
|
||||
function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, targetThreadId: string): ThreadManagementPanelScope {
|
||||
return {
|
||||
targetThreadId,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,31 @@
|
|||
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
|
||||
import type { RuntimeApprovalPolicy, RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions";
|
||||
import type { ApprovalsReviewer, ServiceTier } from "../../../../domain/runtime/policy";
|
||||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
|
||||
import type { EffectOutcome } from "../effect-outcome";
|
||||
|
||||
export interface ThreadRollbackSnapshot {
|
||||
thread: Thread;
|
||||
items: ThreadStreamItem[];
|
||||
type ThreadForkPosition =
|
||||
| { readonly kind: "through-turn"; readonly turnId: string }
|
||||
| { readonly kind: "before-turn"; readonly turnId: string };
|
||||
|
||||
interface ThreadForkOptions {
|
||||
readonly position?: ThreadForkPosition;
|
||||
readonly deferGoalContinuation?: boolean;
|
||||
readonly runtime?: ThreadForkRuntimeOverrides;
|
||||
}
|
||||
|
||||
interface ThreadForkRuntimeOverrides {
|
||||
readonly model?: string;
|
||||
readonly reasoningEffort?: ReasoningEffort | null;
|
||||
readonly serviceTier?: ServiceTier | null;
|
||||
readonly approvalPolicy?: RuntimeApprovalPolicy;
|
||||
readonly approvalsReviewer?: ApprovalsReviewer;
|
||||
readonly permissions?: string;
|
||||
readonly sandboxPolicy?: RuntimeSandboxPolicy;
|
||||
}
|
||||
|
||||
export interface ThreadMutationTransport {
|
||||
ensureConnected(): Promise<boolean>;
|
||||
compactThread(threadId: string): Promise<EffectOutcome<void>>;
|
||||
forkThread(threadId: string, lastTurnId?: string | null): Promise<EffectOutcome<Thread>>;
|
||||
rollbackThread(threadId: string): Promise<EffectOutcome<ThreadRollbackSnapshot>>;
|
||||
forkThread(threadId: string, options?: ThreadForkOptions): Promise<EffectOutcome<Thread>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,12 +77,12 @@ export async function submitComposer(host: ComposerSubmitActionsHost): Promise<v
|
|||
const state = submissionStateSnapshot(chatState);
|
||||
if (host.stateStore.getState().pendingSubmission) return;
|
||||
const operationDecision = activePanelOperationDecision(chatState, "submit");
|
||||
if (operationDecision.kind === "blocked") {
|
||||
host.status.addSystemMessage(operationDecision.message);
|
||||
if (state.busy && state.activeThreadId && state.activeTurnId && (draft.length === 0 || operationDecision.kind === "blocked")) {
|
||||
await interruptTurn(host, panelTarget);
|
||||
return;
|
||||
}
|
||||
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
|
||||
await interruptTurn(host, panelTarget);
|
||||
if (operationDecision.kind === "blocked") {
|
||||
host.status.addSystemMessage(operationDecision.message);
|
||||
return;
|
||||
}
|
||||
await sendMessage(host, draft, originalDraft, panelTarget);
|
||||
|
|
@ -133,7 +133,6 @@ async function sendMessage(
|
|||
text: result.sendText,
|
||||
inputSnapshot,
|
||||
...(result.sendInput !== undefined ? { codexInputOverride: result.sendInput } : {}),
|
||||
...(result.referencedThread !== undefined ? { referencedThread: result.referencedThread } : {}),
|
||||
...(result.sendInput !== undefined ? { preserveComposerContextOnFailure: true } : {}),
|
||||
...(pendingWeb ? { pendingSubmissionId: pendingWeb.id, failureDraft: originalDraft } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ interface LocalUserDialogueParams {
|
|||
text: string;
|
||||
copyText?: string;
|
||||
turnId?: string;
|
||||
referencedThread?: ThreadStreamDialogueItem["referencedThread"];
|
||||
referencedFiles?: readonly ThreadStreamFileReference[];
|
||||
contextAttachments?: ThreadStreamDialogueItem["contextAttachments"];
|
||||
}
|
||||
|
|
@ -64,7 +63,6 @@ function localUserDialogueItem(params: LocalUserDialogueParams): ThreadStreamDia
|
|||
provenance: localUserDialogueProvenance(params.clientId ?? params.id, params.interaction),
|
||||
...(params.clientId ? { clientId: params.clientId } : {}),
|
||||
...(params.turnId ? { turnId: params.turnId } : {}),
|
||||
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
|
||||
...(referencedFiles.length > 0 ? { referencedFiles: [...referencedFiles] } : {}),
|
||||
...(contextAttachments.length > 0 ? { contextAttachments: [...contextAttachments] } : {}),
|
||||
};
|
||||
|
|
@ -87,7 +85,6 @@ export function localUserDialogueItemFromInput(params: LocalUserDialogueFromInpu
|
|||
text: userMessageDisplayText(params.text, params.codexInput),
|
||||
copyText: params.text,
|
||||
...(params.turnId ? { turnId: params.turnId } : {}),
|
||||
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
|
||||
referencedFiles: fileReferencesFromInput([...params.codexInput]),
|
||||
contextAttachments: contextAttachmentsFromInput(params.codexInput),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { CodexInput } from "../../../../domain/chat/input";
|
|||
import type { ThreadGoal } from "../../../../domain/threads/goal";
|
||||
import { shortThreadId } from "../../../../domain/threads/id";
|
||||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
|
||||
import { resolveThreadSearchQuery } from "../../../../domain/threads/search";
|
||||
import { threadDisplayTitle } from "../../../../domain/threads/title";
|
||||
import { modelOverrideMessage, permissionProfileOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
|
||||
|
|
@ -79,14 +78,12 @@ export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts
|
|||
export interface SlashCommandExecutionResult {
|
||||
sendText?: string;
|
||||
sendInput?: CodexInput;
|
||||
referencedThread?: ReferencedThreadMetadata;
|
||||
composerDraft?: string;
|
||||
}
|
||||
|
||||
export interface ThreadReferenceInput {
|
||||
text: string;
|
||||
input: CodexInput;
|
||||
referencedThread: ReferencedThreadMetadata;
|
||||
}
|
||||
|
||||
export interface WebUrlInput {
|
||||
|
|
@ -150,7 +147,7 @@ export async function executeSlashCommand(
|
|||
}
|
||||
const reference = await context.referThread(thread.thread, parsed.message, context.inputSnapshot);
|
||||
if (!reference) return;
|
||||
return { sendText: reference.text, sendInput: reference.input, referencedThread: reference.referencedThread };
|
||||
return { sendText: reference.text, sendInput: reference.input };
|
||||
}
|
||||
case "web": {
|
||||
const parsed = parseWebCommandArgs(args);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { type CodexInput, codexTextInput } from "../../../../domain/chat/input";
|
||||
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
|
||||
import type { ComposerInputSnapshot } from "../composer/input-snapshot";
|
||||
import type { LocalIdSource } from "../local-id-source";
|
||||
import { activePanelOperationDecision } from "../panel-operation-policy";
|
||||
|
|
@ -59,7 +58,6 @@ export interface TurnSubmissionRequest {
|
|||
text: string;
|
||||
inputSnapshot?: ComposerInputSnapshot;
|
||||
codexInputOverride?: CodexInput;
|
||||
referencedThread?: ReferencedThreadMetadata;
|
||||
preserveComposerContextOnFailure?: boolean;
|
||||
pendingSubmissionId?: string;
|
||||
failureDraft?: string;
|
||||
|
|
@ -85,7 +83,7 @@ async function sendTurnText(
|
|||
localItemIds: LocalIdSource,
|
||||
request: TurnSubmissionRequest,
|
||||
): Promise<boolean> {
|
||||
const { text, inputSnapshot, codexInputOverride, referencedThread } = request;
|
||||
const { text, inputSnapshot, codexInputOverride } = request;
|
||||
let panelTarget = capturePanelTargetLease(host.stateStore.getState());
|
||||
const prepared = codexInputOverride
|
||||
? { text, input: codexInputOverride }
|
||||
|
|
@ -115,7 +113,7 @@ async function sendTurnText(
|
|||
if (pendingRequestIsCurrent(host, request)) host.addSystemMessage(plan.message);
|
||||
return false;
|
||||
case "steer":
|
||||
return await steerCurrentTurn(host, localItemIds, plan, text, prepared, request, referencedThread);
|
||||
return await steerCurrentTurn(host, localItemIds, plan, text, prepared, request);
|
||||
case "start-thread-then-turn":
|
||||
if (!commitPendingRequest(host, request)) return false;
|
||||
{
|
||||
|
|
@ -163,7 +161,6 @@ async function sendTurnText(
|
|||
...(request.pendingSubmissionId ? { clientId: clientUserMessageId } : {}),
|
||||
text: prepared.text,
|
||||
codexInput: prepared.input,
|
||||
referencedThread,
|
||||
});
|
||||
host.stateStore.dispatch({
|
||||
type: "turn/optimistic-started",
|
||||
|
|
@ -267,7 +264,6 @@ async function steerCurrentTurn(
|
|||
text: string,
|
||||
prepared: { text: string; input: CodexInput },
|
||||
request: TurnSubmissionRequest,
|
||||
referencedThread?: ReferencedThreadMetadata,
|
||||
): Promise<boolean> {
|
||||
if (!pendingRequestIsCurrent(host, request)) return false;
|
||||
if (!commitPendingRequest(host, request)) return false;
|
||||
|
|
@ -297,7 +293,6 @@ async function steerCurrentTurn(
|
|||
...(request.pendingSubmissionId ? { clientId: localSteerId, interaction: "steer" as const } : {}),
|
||||
text: prepared.text,
|
||||
turnId: plan.turnId,
|
||||
referencedThread,
|
||||
codexInput: prepared.input,
|
||||
});
|
||||
host.stateStore.dispatch(
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
import type { ExecutionState } from "./items";
|
||||
|
||||
export type ThreadStreamExecutionState = Exclude<ExecutionState, null>;
|
||||
export const RUNNING_EXECUTION_STATE: ThreadStreamExecutionState = "running";
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { RUNNING_EXECUTION_STATE } from "../execution-state";
|
||||
import type { ThreadStreamItem, ThreadStreamItemKind } from "../items";
|
||||
import { RUNNING_EXECUTION_STATE, type ThreadStreamItem, type ThreadStreamItemKind } from "../items";
|
||||
|
||||
export const STREAMED_COMMAND_RUNNING_TEXT = "Command running";
|
||||
export const STREAMED_MCP_PROGRESS_LABEL = "mcp progress";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { TurnContextManifest } from "../../../../../domain/chat/context-manifest";
|
||||
import type { CodexInputItem } from "../../../../../domain/chat/input";
|
||||
import type { ThreadStreamContextAttachment } from "../items";
|
||||
|
||||
|
|
@ -6,20 +5,23 @@ export const WEB_CONTEXT_KEY = "codex_panel_web_context";
|
|||
|
||||
export function contextAttachmentsFromInput(input: readonly CodexInputItem[]): ThreadStreamContextAttachment[] {
|
||||
return input.flatMap((item) => {
|
||||
if (item.type !== "additionalContext" || (item.attachment?.kind !== "web" && item.key !== WEB_CONTEXT_KEY)) return [];
|
||||
if (item.type !== "additionalContext" || item.key !== WEB_CONTEXT_KEY) return [];
|
||||
const source = webContextSource(item.value);
|
||||
return [{ label: "Web page", ...(source ? { detail: source } : {}) }];
|
||||
});
|
||||
}
|
||||
|
||||
export function contextAttachmentsFromManifest(manifest: TurnContextManifest | null, visibleText: string): ThreadStreamContextAttachment[] {
|
||||
export function contextAttachmentsFromHistoryContexts(
|
||||
contexts: readonly { kind: "web" | "obsidian"; truncated: boolean; inlineExcerpts?: number }[],
|
||||
visibleText: string,
|
||||
): ThreadStreamContextAttachment[] {
|
||||
const attachments: ThreadStreamContextAttachment[] = [];
|
||||
const web = manifest?.contexts.find((context) => context.kind === "web");
|
||||
const web = contexts.find((context) => context.kind === "web");
|
||||
if (web) {
|
||||
const source = visibleWebSource(visibleText);
|
||||
attachments.push({ label: web.truncated ? "Web page (truncated)" : "Web page", ...(source ? { detail: source } : {}) });
|
||||
}
|
||||
const obsidian = manifest?.contexts.find((context) => context.kind === "obsidian" && context.truncated);
|
||||
const obsidian = contexts.find((context) => context.kind === "obsidian" && context.truncated);
|
||||
if (obsidian) {
|
||||
attachments.push({ label: obsidian.inlineExcerpts ? "Obsidian excerpt (truncated)" : "Obsidian context (truncated)" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
type TextRange = [number, number];
|
||||
import { markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../../domain/markdown/code-ranges";
|
||||
|
||||
interface UserMessageDisplayInputItem {
|
||||
type: string;
|
||||
name?: string;
|
||||
|
|
@ -12,7 +13,7 @@ export function userMessageDisplayText(text: string, input: readonly UserMessage
|
|||
const codeRanges = markdownCodeRanges(text);
|
||||
return text.replace(pattern, (match: string, prefix: string, name: string, offset: number) => {
|
||||
const dollarIndex = offset + prefix.length;
|
||||
return isIndexInRanges(dollarIndex, codeRanges) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`;
|
||||
return markdownCodeRangeContainsOffset(codeRanges, dollarIndex) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -36,64 +37,6 @@ function markdownCodeSpan(text: string): string {
|
|||
return `${delimiter} ${text} ${delimiter}`;
|
||||
}
|
||||
|
||||
function markdownCodeRanges(text: string): TextRange[] {
|
||||
return [...markdownFenceRanges(text), ...markdownInlineCodeRanges(text)].sort((a, b) => a[0] - b[0]);
|
||||
}
|
||||
|
||||
function markdownFenceRanges(text: string): TextRange[] {
|
||||
const ranges: TextRange[] = [];
|
||||
let active: { marker: string; start: number } | null = null;
|
||||
let offset = 0;
|
||||
for (const line of text.matchAll(/[^\n]*(?:\n|$)/g)) {
|
||||
const value = line[0];
|
||||
if (value.length === 0) break;
|
||||
const fence = /^(?: {0,3})(`{3,}|~{3,})/.exec(value);
|
||||
if (fence) {
|
||||
const marker = fence[1];
|
||||
if (!marker) continue;
|
||||
if (!active) {
|
||||
active = { marker, start: offset };
|
||||
} else if (marker.startsWith(active.marker.charAt(0)) && marker.length >= active.marker.length) {
|
||||
ranges.push([active.start, offset + value.length]);
|
||||
active = null;
|
||||
}
|
||||
}
|
||||
offset += value.length;
|
||||
}
|
||||
if (active) ranges.push([active.start, text.length]);
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function markdownInlineCodeRanges(text: string): TextRange[] {
|
||||
const ranges: TextRange[] = [];
|
||||
const fenceRanges = markdownFenceRanges(text);
|
||||
let index = 0;
|
||||
while (index < text.length) {
|
||||
if (isIndexInRanges(index, fenceRanges) || text[index] !== "`") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const match = /`+/.exec(text.slice(index));
|
||||
if (!match) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const delimiter = match[0];
|
||||
const end = text.indexOf(delimiter, index + delimiter.length);
|
||||
if (end < 0) {
|
||||
index += delimiter.length;
|
||||
continue;
|
||||
}
|
||||
ranges.push([index, end + delimiter.length]);
|
||||
index = end + delimiter.length;
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isIndexInRanges(index: number, ranges: readonly TextRange[]): boolean {
|
||||
return ranges.some(([start, end]) => index >= start && index < end);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export type ThreadStreamItemKind =
|
|||
| "reviewResult";
|
||||
type ThreadStreamRole = "user" | "assistant" | "system" | "tool";
|
||||
export type ExecutionState = "running" | "completed" | "failed" | null;
|
||||
export type ThreadStreamExecutionState = Exclude<ExecutionState, null>;
|
||||
export const RUNNING_EXECUTION_STATE: ThreadStreamExecutionState = "running";
|
||||
type DialogueState = "streaming" | "completed";
|
||||
|
||||
interface ThreadStreamBase {
|
||||
|
|
|
|||
|
|
@ -31,14 +31,14 @@ export function createChatComposerController(
|
|||
contextReferenceProvider: new VaultComposerContextReferenceProvider(environment.obsidian.app),
|
||||
attachmentHandler: createVaultComposerAttachmentHandler({
|
||||
app: environment.obsidian.app,
|
||||
attachmentFolder: () => environment.plugin.settingsRef.settings.attachmentFolder(),
|
||||
attachmentFolder: () => environment.plugin.settings.attachmentFolder(),
|
||||
}),
|
||||
sourcePath: () => environment.obsidian.app.workspace.getActiveFile()?.path ?? "",
|
||||
stateStore,
|
||||
viewId: environment.obsidian.viewId,
|
||||
referenceActiveNoteOnSend: () => environment.plugin.settingsRef.settings.referenceActiveNoteOnSend(),
|
||||
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(),
|
||||
scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges(),
|
||||
referenceActiveNoteOnSend: () => environment.plugin.settings.referenceActiveNoteOnSend(),
|
||||
sendShortcut: () => environment.plugin.settings.sendShortcut(),
|
||||
scrollThreadFromComposerEdges: () => environment.plugin.settings.scrollThreadFromComposerEdges(),
|
||||
canInterrupt: (model) => {
|
||||
return model.turnBusy && Boolean(model.activeThreadId && model.activeTurnId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { Notice } from "obsidian";
|
|||
|
||||
import type { AppServerClient, AppServerServerRequestResponder } from "../../../../app-server/connection/client";
|
||||
import { type ConnectionManager, StaleConnectionError } from "../../../../app-server/connection/connection-manager";
|
||||
import { isStaleAppServerResourceContextError } from "../../../../app-server/query/resource-store";
|
||||
import type { SharedServerMetadataResource } from "../../../../domain/server/metadata";
|
||||
import { isStaleExecutionRuntimeError } from "../../../../shared/runtime/execution-runtime-lifetime";
|
||||
import { type ChatInboundHandler, createChatInboundHandler } from "../../app-server/inbound/handler";
|
||||
import { type ChatConnectionActions, createChatConnectionActions } from "../../application/connection/connection-actions";
|
||||
import type { ServerDiagnosticsTransport } from "../../application/connection/metadata-transport";
|
||||
|
|
@ -127,7 +127,7 @@ export function createConnectionBundle(
|
|||
refreshAppServerMetadata: () => environment.plugin.appServerQueries.refreshAppServerMetadata(),
|
||||
refreshSkills: () => environment.plugin.appServerQueries.refreshSkills(),
|
||||
refreshRateLimits: () => environment.plugin.appServerQueries.refreshRateLimits(),
|
||||
isStaleResourceContextError: isStaleAppServerResourceContextError,
|
||||
isStaleRuntimeError: isStaleExecutionRuntimeError,
|
||||
});
|
||||
const serverDiagnostics = createServerDiagnosticsActions({
|
||||
stateStore,
|
||||
|
|
@ -135,7 +135,7 @@ export function createConnectionBundle(
|
|||
appServerMetadataSnapshot: () => environment.plugin.appServerQueries.appServerMetadataSnapshot(),
|
||||
});
|
||||
const refreshSharedThreads = async (): Promise<void> => {
|
||||
await environment.plugin.threadCatalog.refreshActive();
|
||||
await environment.plugin.threadCatalog.refreshActiveThreads();
|
||||
};
|
||||
const inboundHandler = createChatInboundHandler(
|
||||
stateStore,
|
||||
|
|
@ -153,8 +153,8 @@ export function createConnectionBundle(
|
|||
maybeNameThread: (threadId, turnId, completedTurnTranscriptSummary) => {
|
||||
autoTitleCoordinator.maybeAutoTitleThread(threadId, turnId, completedTurnTranscriptSummary);
|
||||
},
|
||||
applyThreadCatalogEvent: (event) => {
|
||||
environment.plugin.threadCatalog.apply(event);
|
||||
applyThreadOperationEvent: (event) => {
|
||||
environment.plugin.threadOperationCoordinator.apply(event);
|
||||
},
|
||||
respondToServerRequest: (requestId, result) => serverRequestResponders.respond(requestId, result),
|
||||
rejectServerRequest: (requestId, code, message) => serverRequestResponders.reject(requestId, code, message),
|
||||
|
|
@ -222,9 +222,9 @@ export function createConnectionBundle(
|
|||
},
|
||||
setStatus: status.set,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath(),
|
||||
configuredCommand: () => environment.plugin.appServerContext.codexPath,
|
||||
isStaleConnectionError: (error) => error instanceof StaleConnectionError,
|
||||
isStaleResourceContextError: isStaleAppServerResourceContextError,
|
||||
isStaleRuntimeError: isStaleExecutionRuntimeError,
|
||||
notifyConnectionFailed: () => {
|
||||
new Notice("Codex app-server connection failed.");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export function createRuntimeBundle(
|
|||
projection: createChatPanelRuntimeProjection({
|
||||
state: () => host.stateStore.getState(),
|
||||
connected: () => input.connection.isConnected(),
|
||||
configuredCommand: () => host.environment.plugin.settingsRef.settings.codexPath(),
|
||||
vaultPath: () => host.environment.plugin.settingsRef.vaultPath,
|
||||
configuredCommand: () => host.environment.plugin.appServerContext.codexPath,
|
||||
vaultPath: () => host.environment.plugin.appServerContext.vaultPath,
|
||||
nowMs: () => Date.now(),
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
|
|||
toolbarPanel: toolbarPanelActions,
|
||||
rename,
|
||||
navigation,
|
||||
loadMoreThreads: () => environment.plugin.threadCatalog.loadMoreActive(),
|
||||
loadMoreThreads: () => environment.plugin.threadCatalog.loadMoreActiveThreads(),
|
||||
openSideChat: () => {
|
||||
const state = stateStore.getState();
|
||||
if (activePanelOperationDecision(state, "start-side-chat").kind !== "allowed") return;
|
||||
|
|
@ -89,13 +89,13 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
|
|||
nowMs: () => Date.now(),
|
||||
},
|
||||
settings: {
|
||||
vaultPath: () => environment.plugin.settingsRef.vaultPath,
|
||||
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath(),
|
||||
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(),
|
||||
vaultPath: () => environment.plugin.appServerContext.vaultPath,
|
||||
configuredCommand: () => environment.plugin.appServerContext.codexPath,
|
||||
archiveExportEnabled: () => environment.plugin.settings.archiveExportEnabled(),
|
||||
},
|
||||
};
|
||||
const goalSurface: ChatPanelGoalSurface = {
|
||||
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(),
|
||||
sendShortcut: () => environment.plugin.settings.sendShortcut(),
|
||||
actions: goals,
|
||||
};
|
||||
const threadStreamContext = createChatThreadStreamSurfaceContext({
|
||||
|
|
@ -103,12 +103,13 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
|
|||
app: environment.obsidian.app,
|
||||
owner: environment.obsidian.owner,
|
||||
stateStore,
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
vaultPath: environment.plugin.appServerContext.vaultPath,
|
||||
loadOlderTurns: () => void history.loadOlder(),
|
||||
actions: {
|
||||
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
|
||||
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
|
||||
implementPlan: (itemId) => void turn.turnActions.planImplementation.implement(itemId),
|
||||
openThreadInAvailableView: (threadId) => void environment.plugin.workspace.openThreadInAvailableView(threadId),
|
||||
openThreadInNewView: (threadId) => void environment.plugin.workspace.openThreadInNewView(threadId),
|
||||
openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export type ChatPanelThreadActions = ReturnType<typeof createThreadManagementAct
|
|||
export type ChatPanelThreadNavigationActions = ReturnType<typeof createThreadNavigationActions>;
|
||||
|
||||
export interface ChatPanelThreadLifecycle {
|
||||
history: HistoryController;
|
||||
restoration: RestorationController;
|
||||
resume: ResumeActions;
|
||||
identity: ActiveThreadIdentitySync;
|
||||
|
|
@ -58,6 +57,7 @@ interface ChatPanelThreadHost {
|
|||
showLatest(): void;
|
||||
};
|
||||
getClosing: () => boolean;
|
||||
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
|
||||
}
|
||||
|
||||
interface ChatPanelThreadFoundationInput {
|
||||
|
|
@ -80,19 +80,16 @@ interface ChatPanelThreadLifecycleInput {
|
|||
appServer: ChatAppServerGateway;
|
||||
localItemIds: LocalIdSource;
|
||||
ensureConnected: () => Promise<void>;
|
||||
ensureInitialized: () => Promise<void>;
|
||||
status: ChatPanelThreadStatus;
|
||||
threadStart: ThreadStartActions;
|
||||
foundation: ChatPanelThreadFoundation;
|
||||
notifyActiveThreadIdentityChanged: () => void;
|
||||
}
|
||||
|
||||
interface ChatPanelThreadLifecycleBundle {
|
||||
interface ChatPanelThreadLifecycleBundle extends ChatPanelThreadLifecycle {
|
||||
goals: ChatPanelGoalActions;
|
||||
rename: ThreadRenameEditorActions;
|
||||
lifecycle: ChatPanelThreadLifecycle;
|
||||
identity: ActiveThreadIdentitySync;
|
||||
restoration: RestorationController;
|
||||
resume: ResumeActions;
|
||||
}
|
||||
|
||||
interface ChatPanelThreadActionInput {
|
||||
|
|
@ -125,13 +122,13 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan
|
|||
transport: threadOperationsTransport,
|
||||
nameMutations: environment.plugin.threadNameMutations,
|
||||
archiveExport: {
|
||||
settings: () => environment.plugin.settingsRef.settings.archiveExportSettings(),
|
||||
enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(),
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
settings: () => environment.plugin.settings.archiveExportSettings(),
|
||||
enabled: () => environment.plugin.settings.archiveExportEnabled(),
|
||||
vaultPath: environment.plugin.appServerContext.vaultPath,
|
||||
vaultConfigDir: environment.obsidian.app.vault.configDir,
|
||||
},
|
||||
archiveDestination: environment.obsidian.archiveDestination,
|
||||
catalog: environment.plugin.threadCatalog,
|
||||
operationEvents: environment.plugin.threadOperationCoordinator,
|
||||
referenceThreads: () => stateStore.getState().threadList.listedThreads,
|
||||
notice: (text) => {
|
||||
new Notice(text);
|
||||
|
|
@ -196,9 +193,19 @@ export function createThreadLifecycleBundle(
|
|||
host: ChatPanelThreadHost,
|
||||
input: ChatPanelThreadLifecycleInput,
|
||||
): ChatPanelThreadLifecycleBundle {
|
||||
const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, notifyActiveThreadIdentityChanged } = input;
|
||||
const {
|
||||
appServer,
|
||||
localItemIds,
|
||||
ensureConnected,
|
||||
ensureInitialized,
|
||||
status,
|
||||
threadStart,
|
||||
foundation,
|
||||
notifyActiveThreadIdentityChanged,
|
||||
} = input;
|
||||
const lifecycle = createSessionThreadLifecycle(host, {
|
||||
appServer,
|
||||
ensureInitialized,
|
||||
status,
|
||||
goalSync: foundation.goalSync,
|
||||
autoTitleCoordinator: foundation.autoTitleCoordinator,
|
||||
|
|
@ -232,14 +239,14 @@ export function createThreadLifecycleBundle(
|
|||
ensureConnected,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
renameThread: (threadId, value) => foundation.threadOperations.renameThread(threadId, value),
|
||||
generateThreadTitle: (threadId) => foundation.titleService.generateTitle(threadId),
|
||||
resolveThreadTitleContext: (threadId) => foundation.titleService.resolveContext(threadId),
|
||||
generateThreadTitle: (context, signal) => foundation.titleService.generate(context, signal),
|
||||
});
|
||||
const { identity, restoration, resume } = lifecycle;
|
||||
|
||||
return {
|
||||
goals,
|
||||
rename,
|
||||
lifecycle,
|
||||
identity,
|
||||
restoration,
|
||||
resume,
|
||||
|
|
@ -247,7 +254,7 @@ export function createThreadLifecycleBundle(
|
|||
}
|
||||
|
||||
export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatPanelThreadActionInput): ChatPanelThreadActionBundle {
|
||||
const { appServer, status, composerController, foundation, lifecycle, notifyActiveThreadIdentityChanged } = input;
|
||||
const { appServer, status, composerController, foundation, lifecycle } = input;
|
||||
const { environment, stateStore } = host;
|
||||
const threadManagementHost: ThreadManagementActionsHost = {
|
||||
stateStore,
|
||||
|
|
@ -265,13 +272,25 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP
|
|||
composerController.setDraft(text, { focus: true });
|
||||
},
|
||||
openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId),
|
||||
openThreadInCurrentPanel: async (threadId) => {
|
||||
await lifecycle.resume.resumeThread(threadId);
|
||||
},
|
||||
notifyActiveThreadIdentityChanged,
|
||||
recordThread: (thread) => {
|
||||
environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread });
|
||||
openThreadInCurrentPanel: async (threadId, onAdopted) => {
|
||||
const activityPublication = host.beginPanelActivityPublication(threadId);
|
||||
const adoption = { completed: false };
|
||||
try {
|
||||
await lifecycle.resume.resumeThread(threadId, undefined, {
|
||||
onAdopted: () => {
|
||||
adoption.completed = true;
|
||||
onAdopted();
|
||||
},
|
||||
});
|
||||
if (adoption.completed) return { adopted: true, activityPublication };
|
||||
activityPublication.publish(() => undefined);
|
||||
return { adopted: false };
|
||||
} catch (error) {
|
||||
activityPublication.publish(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
beginThreadForkPublication: (sourceThreadId) => environment.plugin.threadOperationCoordinator.beginForkPublication(sourceThreadId),
|
||||
threadHasPendingOrRunningPanel: (threadId) => environment.plugin.workspace.threadHasPendingOrRunningPanel(threadId),
|
||||
};
|
||||
const actions = createThreadManagementActions(threadManagementHost);
|
||||
|
|
@ -303,6 +322,7 @@ function createSessionThreadLifecycle(
|
|||
host: ChatPanelThreadHost,
|
||||
input: {
|
||||
appServer: ChatAppServerGateway;
|
||||
ensureInitialized: () => Promise<void>;
|
||||
status: ChatPanelThreadStatus;
|
||||
goalSync: ChatPanelGoalSyncActions;
|
||||
autoTitleCoordinator: AutoTitleCoordinator;
|
||||
|
|
@ -311,7 +331,16 @@ function createSessionThreadLifecycle(
|
|||
notifyActiveThreadIdentityChanged: () => void;
|
||||
},
|
||||
): ChatPanelThreadLifecycle {
|
||||
const { appServer, status, goalSync, autoTitleCoordinator, history, invalidateThreadWork, notifyActiveThreadIdentityChanged } = input;
|
||||
const {
|
||||
appServer,
|
||||
ensureInitialized,
|
||||
status,
|
||||
goalSync,
|
||||
autoTitleCoordinator,
|
||||
history,
|
||||
invalidateThreadWork,
|
||||
notifyActiveThreadIdentityChanged,
|
||||
} = input;
|
||||
const restoration = new RestorationController({
|
||||
stateStore: host.stateStore,
|
||||
});
|
||||
|
|
@ -320,14 +349,20 @@ function createSessionThreadLifecycle(
|
|||
};
|
||||
const resume = createResumeActions({
|
||||
stateStore: host.stateStore,
|
||||
resumeTransport: appServer.threadResume,
|
||||
resumeTransport: {
|
||||
ensureConnected: async () => {
|
||||
await ensureInitialized();
|
||||
return appServer.connectionAvailable();
|
||||
},
|
||||
resumeThread: (threadId) => appServer.threadResume.resumeThread(threadId),
|
||||
},
|
||||
resumeWork: host.resumeWork,
|
||||
history,
|
||||
closing: host.getClosing,
|
||||
resetThreadTurnPresence,
|
||||
notifyActiveThreadIdentityChanged,
|
||||
recordResumedThread: (thread) => {
|
||||
host.environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread });
|
||||
host.environment.plugin.threadOperationCoordinator.apply({ type: "thread-upserted", thread });
|
||||
},
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
syncThreadGoal: (threadId) => goalSync.syncThreadGoal(threadId),
|
||||
|
|
@ -342,7 +377,6 @@ function createSessionThreadLifecycle(
|
|||
});
|
||||
|
||||
return {
|
||||
history,
|
||||
restoration,
|
||||
resume,
|
||||
identity,
|
||||
|
|
|
|||
|
|
@ -1,53 +1,46 @@
|
|||
import type { App, Component, EventRef } from "obsidian";
|
||||
|
||||
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
|
||||
import type { AppServerQueryContext } from "../../../app-server/query/keys";
|
||||
import type { AppServerExecutionContext } from "../../../app-server/connection/execution-context";
|
||||
import type { ObservedResultListener } from "../../../app-server/query/observed-result";
|
||||
import type { ModelMetadata, ReasoningEffort } from "../../../domain/catalog/metadata";
|
||||
import type { ModelMetadata } from "../../../domain/catalog/metadata";
|
||||
import type { SendShortcut } from "../../../domain/input/send-shortcut";
|
||||
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../../domain/server/metadata";
|
||||
import type { ArchiveExportSettings } from "../../../domain/threads/archive-markdown";
|
||||
import type { KeyedOperationQueue } from "../../../shared/runtime/keyed-operation-queue";
|
||||
import type { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../../threads/catalog/thread-catalog";
|
||||
import type { ArchiveExportDestination } from "../../threads/workflows/archive-export";
|
||||
import type { ThreadCatalogPaginatedActiveReader } from "../../threads/catalog/thread-catalog";
|
||||
import type { ArchiveExportDestination, ArchiveExportSettings } from "../../threads/workflows/archive-export";
|
||||
import type { ThreadTitleTransport } from "../../threads/workflows/ports";
|
||||
import type { ThreadNameMutationCoordinator } from "../../threads/workflows/thread-name-mutation-coordinator";
|
||||
import type { ThreadOperationCoordinator } from "../../threads/workflows/thread-operation-coordinator";
|
||||
import type { TurnDiffViewState } from "../../turn-diff/model";
|
||||
import type { ThreadGoalOperationCoordinator } from "../application/threads/goal-actions";
|
||||
|
||||
export interface CodexChatHost {
|
||||
readonly appServerClientAccess: AppServerClientAccess;
|
||||
readonly appServerContext: Readonly<AppServerQueryContext>;
|
||||
readonly settingsRef: ChatPanelSettingsRef;
|
||||
readonly appServerContext: Readonly<AppServerExecutionContext>;
|
||||
readonly settings: ChatPanelSettingsAccess;
|
||||
readonly workspace: WorkspacePanels;
|
||||
readonly appServerQueries: ChatAppServerQueries;
|
||||
readonly threadCatalog: ChatThreadCatalog;
|
||||
readonly threadNameMutations: ThreadNameMutationCoordinator;
|
||||
readonly threadOperationCoordinator: ThreadOperationCoordinator;
|
||||
readonly threadNameMutations: KeyedOperationQueue<string>;
|
||||
readonly threadTitleTransport: ThreadTitleTransport;
|
||||
readonly threadGoalOperations: ThreadGoalOperationCoordinator;
|
||||
readonly runtimeSettingsCommitQueue: KeyedOperationQueue<string>;
|
||||
}
|
||||
|
||||
interface ChatPanelSettingsRef {
|
||||
readonly settings: ChatPanelSettingsAccess;
|
||||
readonly vaultPath: string;
|
||||
}
|
||||
|
||||
export interface ChatPanelSettingsAccess {
|
||||
referenceActiveNoteOnSend(): boolean;
|
||||
attachmentFolder(): string;
|
||||
archiveExportEnabled(): boolean;
|
||||
archiveExportSettings(): ArchiveExportSettings;
|
||||
codexPath(): string;
|
||||
scrollThreadFromComposerEdges(): boolean;
|
||||
sendShortcut(): SendShortcut;
|
||||
showToolbar(): boolean;
|
||||
threadNamingEffort(): ReasoningEffort | null;
|
||||
threadNamingModel(): string | null;
|
||||
}
|
||||
|
||||
export interface WorkspacePanels {
|
||||
openThreadInNewView(threadId: string): Promise<void>;
|
||||
openThreadInAvailableView(threadId: string): Promise<void>;
|
||||
openThreadFromPanel(threadId: string, originViewId: string, originSwitchable: boolean): Promise<void>;
|
||||
focusThreadInOpenView(threadId: string): Promise<boolean>;
|
||||
threadHasPendingOrRunningPanel(threadId: string): boolean;
|
||||
|
|
@ -56,7 +49,7 @@ export interface WorkspacePanels {
|
|||
openSideChat(sourceThreadId: string, sourceThreadTitle: string | null): Promise<void>;
|
||||
}
|
||||
|
||||
type ChatThreadCatalog = ThreadCatalogPaginatedActiveReader & ThreadCatalogEventSink;
|
||||
type ChatThreadCatalog = ThreadCatalogPaginatedActiveReader;
|
||||
|
||||
interface ChatAppServerQueries {
|
||||
appServerMetadataSnapshot(): SharedServerMetadata | null;
|
||||
|
|
@ -123,6 +116,11 @@ export interface ChatWorkspacePanelSnapshot {
|
|||
threadId: string | null;
|
||||
turnBusy: boolean;
|
||||
pending: boolean;
|
||||
publishedActivity: {
|
||||
threadId: string | null;
|
||||
turnBusy: boolean;
|
||||
pending: boolean;
|
||||
};
|
||||
hasComposerDraft: boolean;
|
||||
connected: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { type App, moment, normalizePath, TFile } from "obsidian";
|
||||
import { appHasDailyNotesPluginLoaded, getDailyNoteSettings, type IPeriodicNoteSettings } from "obsidian-daily-notes-interface";
|
||||
|
||||
import type { DailyNoteReferenceCandidate } from "../../application/composer/daily-note-references";
|
||||
import type { DailyNoteReferenceCandidate } from "../../application/composer/note-context";
|
||||
import { displayNameForFile, linktextForFile } from "./vault-note-links.obsidian";
|
||||
|
||||
const RELATIVE_DAILY_NOTES = [
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import type { App, EventRef } from "obsidian";
|
|||
import { stripHeadingForLink, TFile } from "obsidian";
|
||||
|
||||
import type { VaultFileReference } from "../../../../domain/chat/input";
|
||||
import type { NoteCandidateProvider } from "../../application/composer/note-context";
|
||||
import type { NoteCandidate } from "../../application/composer/suggestions";
|
||||
import type { NoteCandidate, NoteCandidateProvider } from "../../application/composer/note-context";
|
||||
import { configuredDailyNoteReferences } from "./vault-daily-note-references.obsidian";
|
||||
import { displayNameForFile, linktextForFile } from "./vault-note-links.obsidian";
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ async function readUrlToInput(
|
|||
return {
|
||||
text,
|
||||
input: codexTextInputWithAttachments(text, [
|
||||
{ type: "additionalContext", key: WEB_CONTEXT_KEY, kind: "untrusted", value: context, attachment: { kind: "web" } },
|
||||
{ type: "additionalContext", key: WEB_CONTEXT_KEY, kind: "untrusted", value: context },
|
||||
...messageInput.input,
|
||||
]),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { codexPanelAppServerInitializeParams } from "../../../app-server/connection/client-profile";
|
||||
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import { isStaleAppServerResourceContextError } from "../../../app-server/query/resource-store";
|
||||
import { isStaleExecutionRuntimeError } from "../../../shared/runtime/execution-runtime-lifetime";
|
||||
import { createChatAppServerGateway, createChatCurrentAppServerGateway } from "../app-server/session-gateway";
|
||||
import { createReconnectPanelAction } from "../application/connection/reconnect-actions";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../application/local-id-source";
|
||||
|
|
@ -72,7 +72,7 @@ interface ChatPanelSessionRuntimeHost {
|
|||
resumeWork: ChatResumeWorkTracker;
|
||||
threadStreamScrollBinding: ChatThreadStreamScrollBinding;
|
||||
getClosing: () => boolean;
|
||||
reconnect?: () => Promise<void>;
|
||||
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
|
||||
}
|
||||
|
||||
export class ChatPanelSessionRuntime {
|
||||
|
|
@ -178,7 +178,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
threadStartTransport: appServer.threadStart,
|
||||
runtimeSnapshotForState: runtimeSnapshotForChatState,
|
||||
recordStartedThread: (thread) => {
|
||||
environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread });
|
||||
environment.plugin.threadOperationCoordinator.apply({ type: "thread-upserted", thread });
|
||||
},
|
||||
syncThreadGoal: (threadId) => {
|
||||
void threadFoundation.goalSync.syncThreadGoal(threadId);
|
||||
|
|
@ -188,6 +188,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
appServer,
|
||||
localItemIds,
|
||||
ensureConnected,
|
||||
ensureInitialized: () => connectionActions.ensureInitialized(),
|
||||
status,
|
||||
threadStart,
|
||||
foundation: threadFoundation,
|
||||
|
|
@ -245,13 +246,12 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
const reconnect = async () => {
|
||||
await reconnectPanel();
|
||||
};
|
||||
const reconnectForUser = host.reconnect ?? reconnect;
|
||||
const turn = createTurnBundle(host, {
|
||||
localItemIds,
|
||||
appServer,
|
||||
status,
|
||||
inboundHandler,
|
||||
threadLifecycle: threadLifecycle.lifecycle,
|
||||
threadLifecycle,
|
||||
threadActions: threadActions.actions,
|
||||
navigation: threadActions.navigation,
|
||||
composerController,
|
||||
|
|
@ -259,7 +259,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
threadStart,
|
||||
goals: threadLifecycle.goals,
|
||||
autoTitleCoordinator: threadFoundation.autoTitleCoordinator,
|
||||
reconnect: reconnectForUser,
|
||||
reconnect,
|
||||
runtimeProjection: runtime.projection,
|
||||
refreshDiagnostics: () => connectionActions.refreshDiagnostics(),
|
||||
notifyActiveThreadIdentityChanged,
|
||||
|
|
@ -272,7 +272,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
threadActions: threadActions.actions,
|
||||
toolbarPanelActions: threadActions.toolbarPanelActions,
|
||||
navigation: threadActions.navigation,
|
||||
reconnect: reconnectForUser,
|
||||
reconnect,
|
||||
history: threadFoundation.history,
|
||||
pendingRequests: turn.pendingRequests,
|
||||
turn,
|
||||
|
|
@ -282,7 +282,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
try {
|
||||
await connectionBundle.refreshSharedThreads();
|
||||
} catch (error) {
|
||||
if (isStaleAppServerResourceContextError(error)) return;
|
||||
if (isStaleExecutionRuntimeError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
private opened = false;
|
||||
private closing = false;
|
||||
private observedPanelActivity: PanelActivity | null = null;
|
||||
private readonly panelActivityPublicationSlots: PanelActivityPublicationSlot[] = [];
|
||||
private activePanelActivityHold: PanelActivityHold | null = null;
|
||||
private unsubscribePanelActivity: (() => void) | null = null;
|
||||
private pendingRuntimeRestore: ChatPanelRuntimeSnapshot | null;
|
||||
|
||||
|
|
@ -85,10 +87,6 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
this.mountOrRepairShell();
|
||||
}
|
||||
|
||||
private async reconnect(): Promise<void> {
|
||||
await this.runtime.actions.reconnect();
|
||||
}
|
||||
|
||||
runtimeSnapshot(): ChatPanelRuntimeSnapshot {
|
||||
const lifetime = activeThreadState(this.state)?.lifetime;
|
||||
return {
|
||||
|
|
@ -104,10 +102,15 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
|
||||
openPanelSnapshot(): ChatWorkspacePanelSnapshot {
|
||||
const activity = panelActivity(this.state);
|
||||
const publishedActivity = this.activePanelActivityHold?.activity ?? activity;
|
||||
return {
|
||||
viewId: this.environment.obsidian.viewId,
|
||||
...activity,
|
||||
threadId: this.closing ? null : activity.threadId,
|
||||
publishedActivity: {
|
||||
...publishedActivity,
|
||||
threadId: this.closing ? null : publishedActivity.threadId,
|
||||
},
|
||||
hasComposerDraft: this.state.composer.draft.trim().length > 0,
|
||||
connected: this.runtime.connection.manager.isConnected(),
|
||||
};
|
||||
|
|
@ -214,7 +217,7 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
if (!root) return;
|
||||
renderChatPanelShell(root, {
|
||||
stateStore: this.stateStore,
|
||||
showToolbar: this.environment.plugin.settingsRef.settings.showToolbar(),
|
||||
showToolbar: this.environment.plugin.settings.showToolbar(),
|
||||
parts: this.runtime.shell.parts,
|
||||
});
|
||||
}
|
||||
|
|
@ -264,6 +267,9 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
const next = panelActivity(this.state);
|
||||
if (panelActivityEquals(this.observedPanelActivity, next)) return;
|
||||
this.observedPanelActivity = next;
|
||||
const hold = this.activePanelActivityHold;
|
||||
if (hold && (next.threadId === hold.activity.threadId || hold.replacementThreadIds.has(next.threadId))) return;
|
||||
if (hold) this.activePanelActivityHold = null;
|
||||
this.notifyPanelActivityChanged();
|
||||
});
|
||||
this.notifyPanelActivityChanged();
|
||||
|
|
@ -273,6 +279,47 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
this.environment.plugin.workspace.notifyPanelActivityChanged();
|
||||
}
|
||||
|
||||
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void } {
|
||||
const hold: PanelActivityHold = this.activePanelActivityHold ?? {
|
||||
activity: panelActivity(this.state),
|
||||
replacementThreadIds: new Set<string | null>(),
|
||||
pendingPublications: 0,
|
||||
};
|
||||
hold.replacementThreadIds.add(replacementThreadId);
|
||||
hold.pendingPublications += 1;
|
||||
this.activePanelActivityHold = hold;
|
||||
const slot: PanelActivityPublicationSlot = { hold, commit: null };
|
||||
this.panelActivityPublicationSlots.push(slot);
|
||||
return {
|
||||
publish: (commit) => {
|
||||
if (slot.commit) return;
|
||||
slot.commit = commit;
|
||||
hold.pendingPublications -= 1;
|
||||
let failure: unknown = null;
|
||||
let commitFailed = false;
|
||||
let first = this.panelActivityPublicationSlots[0];
|
||||
while (first?.commit && first.hold.pendingPublications === 0) {
|
||||
this.panelActivityPublicationSlots.shift();
|
||||
const slotHold = first.hold;
|
||||
const publishActivity =
|
||||
this.activePanelActivityHold === slotHold && !this.panelActivityPublicationSlots.some((pending) => pending.hold === slotHold);
|
||||
if (publishActivity) this.activePanelActivityHold = null;
|
||||
try {
|
||||
first.commit();
|
||||
} catch (error) {
|
||||
if (!commitFailed) failure = error;
|
||||
commitFailed = true;
|
||||
}
|
||||
if (publishActivity && !panelActivityEquals(slotHold.activity, panelActivity(this.state))) {
|
||||
this.notifyPanelActivityChanged();
|
||||
}
|
||||
first = this.panelActivityPublicationSlots[0];
|
||||
}
|
||||
if (commitFailed) throw failure;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private activeThreadTitle(): string | null {
|
||||
const activeThread = activeThreadState(this.state);
|
||||
if (!activeThread) return null;
|
||||
|
|
@ -310,7 +357,7 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
resumeWork: this.resumeWork,
|
||||
threadStreamScrollBinding: this.threadStreamScrollBinding,
|
||||
getClosing: () => this.closing,
|
||||
reconnect: () => this.reconnect(),
|
||||
beginPanelActivityPublication: (replacementThreadId) => this.beginPanelActivityPublication(replacementThreadId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -321,6 +368,17 @@ interface PanelActivity {
|
|||
readonly pending: boolean;
|
||||
}
|
||||
|
||||
interface PanelActivityHold {
|
||||
readonly activity: PanelActivity;
|
||||
readonly replacementThreadIds: Set<string | null>;
|
||||
pendingPublications: number;
|
||||
}
|
||||
|
||||
interface PanelActivityPublicationSlot {
|
||||
readonly hold: PanelActivityHold;
|
||||
commit: (() => void) | null;
|
||||
}
|
||||
|
||||
function panelActivity(state: ChatState): PanelActivity {
|
||||
return {
|
||||
threadId: panelThreadId(state),
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import type { ChatStateStore } from "../../application/state/store";
|
|||
type ThreadObserver = (result: ObservedPaginatedResult<readonly Thread[]>) => void;
|
||||
|
||||
interface SharedStateThreadCatalog {
|
||||
activeSnapshot(): readonly Thread[] | null;
|
||||
hasMoreActive(): boolean;
|
||||
observeActive(observer: ThreadObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
activeThreadsSnapshot(): readonly Thread[] | null;
|
||||
hasMoreActiveThreads(): boolean;
|
||||
observeActiveThreadsResult(observer: ThreadObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
}
|
||||
|
||||
interface SharedStateAppServerQueries {
|
||||
|
|
@ -56,12 +56,12 @@ export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateB
|
|||
}
|
||||
};
|
||||
const applyCached = (): void => {
|
||||
const threads = threadCatalog.activeSnapshot();
|
||||
const threads = threadCatalog.activeThreadsSnapshot();
|
||||
if (threads) {
|
||||
stateStore.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads,
|
||||
hasMore: threadCatalog.hasMoreActive(),
|
||||
hasMore: threadCatalog.hasMoreActiveThreads(),
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
error: null,
|
||||
|
|
@ -75,7 +75,7 @@ export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateB
|
|||
unsubscribe();
|
||||
applyCached();
|
||||
unsubscribers.push(
|
||||
threadCatalog.observeActive(receiveThreadResult, { emitCurrent: false }),
|
||||
threadCatalog.observeActiveThreadsResult(receiveThreadResult, { emitCurrent: false }),
|
||||
appServerQueries.observeAppServerMetadataResources(applyAppServerMetadataResource),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Component, ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
|
||||
|
||||
import { VIEW_TYPE_CODEX_PANEL } from "../../../constants";
|
||||
import { createObsidianArchiveExportDestination } from "../../threads/obsidian/archive-export-destination.obsidian";
|
||||
import { createObsidianVaultMarkdownDestination } from "../../../shared/obsidian/vault-write-destination.obsidian";
|
||||
import { createLocalIdSource } from "../application/local-id-source";
|
||||
import type { ChatPanelHandle, ChatPanelRuntimeSnapshot, ChatViewRuntimeOwner, CodexChatHost } from "./contracts";
|
||||
import { ChatPanelSession } from "./session";
|
||||
|
|
@ -46,7 +46,7 @@ export class CodexChatView extends ItemView {
|
|||
registerPointerDown: (handler) => {
|
||||
owner.registerDomEvent(this.containerEl.doc, "pointerdown", handler);
|
||||
},
|
||||
archiveDestination: () => createObsidianArchiveExportDestination(this.app.vault),
|
||||
archiveDestination: () => createObsidianVaultMarkdownDestination(this.app.vault),
|
||||
requestWorkspaceLayoutSave: () => {
|
||||
void this.app.workspace.requestSaveLayout();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,14 +14,13 @@ import {
|
|||
selectionContextReferenceMarker,
|
||||
} from "../application/composer/context-references";
|
||||
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
|
||||
import type { NoteCandidateProvider } from "../application/composer/note-context";
|
||||
import type { NoteCandidate, NoteCandidateProvider } from "../application/composer/note-context";
|
||||
import { activePanelOperationForSlashCommandSuggestion } from "../application/composer/slash-commands";
|
||||
import {
|
||||
activeComposerSuggestions,
|
||||
applyComposerSuggestionInsertion,
|
||||
type ComposerSuggestion,
|
||||
composerSuggestionNavigationDirection,
|
||||
type NoteCandidate,
|
||||
nextComposerSuggestionIndex,
|
||||
} from "../application/composer/suggestions";
|
||||
import { type ThreadCommandTarget, threadCommandTargetForDraft } from "../application/composer/thread-title-argument";
|
||||
|
|
@ -112,7 +111,9 @@ export class ChatComposerController {
|
|||
draft: model.draft,
|
||||
busy: model.turnBusy,
|
||||
canInterrupt: this.options.canInterrupt(model),
|
||||
submissionDisabled: model.submissionBlockedByPanelPolicy || model.webSubmissionPending,
|
||||
submissionDisabled: model.webSubmissionPending,
|
||||
directInputDisabled: model.submissionBlockedByPanelPolicy,
|
||||
runtimeControlsDisabled: model.runtimeSettingsDisabled,
|
||||
sendDisabled: model.attachmentSavePending,
|
||||
webSubmissionCancellable: model.webSubmissionCancellable,
|
||||
normalPlaceholder: projection.placeholder,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,9 @@ export interface ChatPanelComposerModel {
|
|||
readonly activeThreadId: string | null;
|
||||
readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"];
|
||||
readonly activeThreadSubagent: boolean;
|
||||
readonly canAcceptDirectInput: ChatActiveThreadState["canAcceptDirectInput"];
|
||||
readonly submissionBlockedByPanelPolicy: boolean;
|
||||
readonly runtimeSettingsDisabled: boolean;
|
||||
readonly webSubmissionPending: boolean;
|
||||
readonly webSubmissionCancellable: boolean;
|
||||
readonly turnBusy: boolean;
|
||||
|
|
@ -91,7 +93,7 @@ export function selectChatPanelToolbar(state: ChatState): ChatPanelToolbarModel
|
|||
activeThreadId: activeThread?.id ?? null,
|
||||
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
|
||||
sideChatStartDisabled: activePanelOperationDecision(state, "start-side-chat").kind !== "allowed",
|
||||
compactDisabled: activePanelOperationDecision(state, "compact").kind !== "allowed",
|
||||
compactDisabled: !activeThread || activePanelOperationDecision(state, "compact").kind !== "allowed",
|
||||
goalMutationDisabled: activePanelOperationDecision(state, "goal-mutation").kind === "blocked",
|
||||
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
|
||||
turnBusy: chatTurnBusy(state),
|
||||
|
|
@ -154,7 +156,9 @@ export function selectChatPanelComposer(state: ChatState): ChatPanelComposerMode
|
|||
activeThreadId,
|
||||
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
|
||||
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
|
||||
canAcceptDirectInput: activeThread?.canAcceptDirectInput ?? null,
|
||||
submissionBlockedByPanelPolicy: activePanelOperationDecision(state, "submit").kind === "blocked",
|
||||
runtimeSettingsDisabled: activePanelOperationDecision(state, "thread-settings").kind !== "allowed",
|
||||
webSubmissionPending: state.pendingSubmission !== null,
|
||||
webSubmissionCancellable: state.pendingSubmission?.phase === "cancellable",
|
||||
turnBusy: chatTurnBusy(state),
|
||||
|
|
|
|||
|
|
@ -130,10 +130,7 @@ function ChatPanelToolbarRegion({
|
|||
actions: ToolbarActions;
|
||||
}): UiNode {
|
||||
const model = useChatSelector(stateStore, selectChatPanelToolbar);
|
||||
return useMemo(
|
||||
() => <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />,
|
||||
[model, stateStore, surface, actions],
|
||||
);
|
||||
return <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />;
|
||||
}
|
||||
|
||||
function ChatPanelGoalRegion({ stateStore, surface }: { stateStore: ChatStateStore; surface: ChatPanelGoalSurface }): UiNode {
|
||||
|
|
|
|||
|
|
@ -71,9 +71,12 @@ export function chatPanelComposerProjection(
|
|||
availableModels: model.availableModels,
|
||||
});
|
||||
return {
|
||||
placeholder: model.activeThreadSubagent
|
||||
? "Agent thread is read-only."
|
||||
: composerPlaceholder(activeComposerThreadName(model), model.sideChatActive, model.sideChatSourceTitle),
|
||||
placeholder:
|
||||
model.canAcceptDirectInput === false
|
||||
? "This thread cannot accept messages."
|
||||
: model.activeThreadSubagent && model.canAcceptDirectInput === null
|
||||
? "Agent thread is read-only."
|
||||
: composerPlaceholder(activeComposerThreadName(model), model.sideChatActive, model.sideChatSourceTitle),
|
||||
meta: {
|
||||
...composerMetaViewModel(model, snapshot),
|
||||
...runtimeComposerChoices({
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface ChatThreadStreamActions {
|
|||
rollbackThread: (threadId: string) => void;
|
||||
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
|
||||
implementPlan: (itemId: string) => void;
|
||||
openThreadInAvailableView: (threadId: string) => void;
|
||||
openThreadInNewView: (threadId: string) => void;
|
||||
openTurnDiff: (state: TurnDiffViewState) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function createChatThreadStreamSurfaceContext(options: ChatThreadStreamSu
|
|||
app: options.app,
|
||||
owner: options.owner,
|
||||
vaultPath: options.vaultPath,
|
||||
openThread: options.actions.openThreadInAvailableView,
|
||||
});
|
||||
const dispatch = (action: ChatAction): void => {
|
||||
options.stateStore.dispatch(action);
|
||||
|
|
|
|||
|
|
@ -206,11 +206,18 @@ function toolbarThreadRows(input: {
|
|||
title: core.title,
|
||||
threadId: core.threadId,
|
||||
selected: core.selected,
|
||||
disabled: input.turnBusy && threadId !== input.activeThreadId,
|
||||
openDisabled: false,
|
||||
renameDisabled: input.renameState.kind === "saving",
|
||||
archiveDisabled: input.turnBusy,
|
||||
canArchive: true,
|
||||
archiveConfirm: core.archiveConfirm,
|
||||
rename: core.rename.active ? { draft: core.rename.draft, generating: core.rename.generating } : null,
|
||||
rename: core.rename.active
|
||||
? {
|
||||
draft: core.rename.draft,
|
||||
generating: core.rename.generating,
|
||||
saving: core.rename.saving,
|
||||
autoNameDisabled: core.rename.autoNameDisabled,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,9 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb
|
|||
cancel: (threadId) => {
|
||||
deps.rename.cancel(threadId);
|
||||
},
|
||||
cancelAutoName: (threadId) => {
|
||||
deps.rename.cancelAutoName(threadId);
|
||||
},
|
||||
autoName: (threadId) => {
|
||||
void deps.rename.autoNameDraft(threadId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ export interface ComposerShellProps {
|
|||
busy: boolean;
|
||||
canInterrupt: boolean;
|
||||
submissionDisabled: boolean;
|
||||
directInputDisabled: boolean;
|
||||
runtimeControlsDisabled: boolean;
|
||||
sendDisabled: boolean;
|
||||
webSubmissionCancellable: boolean;
|
||||
normalPlaceholder: string;
|
||||
|
|
@ -106,6 +108,8 @@ export function ComposerShell({
|
|||
busy,
|
||||
canInterrupt,
|
||||
submissionDisabled,
|
||||
directInputDisabled,
|
||||
runtimeControlsDisabled,
|
||||
sendDisabled,
|
||||
webSubmissionCancellable,
|
||||
normalPlaceholder,
|
||||
|
|
@ -152,8 +156,16 @@ export function ComposerShell({
|
|||
if (pendingSelection.value === draft) restoreComposerSelection(composerRef.current, pendingSelection);
|
||||
onPendingSelectionApplied?.();
|
||||
}, [draft, pendingSelection, onPendingSelectionApplied]);
|
||||
const sendMode = composerSendMode(busy, canInterrupt, draft, submissionDisabled, sendDisabled, webSubmissionCancellable);
|
||||
const composerLocked = submissionDisabled;
|
||||
const sendMode = composerSendMode(
|
||||
busy,
|
||||
canInterrupt,
|
||||
draft,
|
||||
submissionDisabled,
|
||||
directInputDisabled,
|
||||
sendDisabled,
|
||||
webSubmissionCancellable,
|
||||
);
|
||||
const composerLocked = submissionDisabled || directInputDisabled;
|
||||
const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1);
|
||||
const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined;
|
||||
|
||||
|
|
@ -163,7 +175,7 @@ export function ComposerShell({
|
|||
<textarea
|
||||
ref={composerRef}
|
||||
className="codex-panel-ui__text-input codex-panel__composer-input"
|
||||
placeholder={sendMode.canInterrupt ? "Steer the current turn..." : normalPlaceholder}
|
||||
placeholder={sendMode.canInterrupt && !composerLocked ? "Steer the current turn..." : normalPlaceholder}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={suggestions.length > 0 ? "true" : "false"}
|
||||
|
|
@ -191,7 +203,7 @@ export function ComposerShell({
|
|||
callbacks.onDragOver?.(event);
|
||||
}}
|
||||
/>
|
||||
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} disabled={composerLocked} />
|
||||
<ComposerMeta meta={meta} sendMode={sendMode} callbacks={callbacks} disabled={composerLocked || runtimeControlsDisabled} />
|
||||
</div>
|
||||
<ComposerSuggestions
|
||||
containerRef={suggestionsRef}
|
||||
|
|
@ -480,6 +492,7 @@ function composerSendMode(
|
|||
canInterrupt: boolean,
|
||||
draft: string,
|
||||
submissionDisabled: boolean,
|
||||
directInputDisabled: boolean,
|
||||
sendDisabled: boolean,
|
||||
webSubmissionCancellable: boolean,
|
||||
): ComposerSendMode {
|
||||
|
|
@ -493,13 +506,13 @@ function composerSendMode(
|
|||
};
|
||||
}
|
||||
const hasDraft = Boolean(draft.trim());
|
||||
const canSteer = canInterrupt && hasDraft;
|
||||
const interruptMode = canInterrupt && !hasDraft;
|
||||
const interruptMode = canInterrupt && (!hasDraft || directInputDisabled);
|
||||
const canSteer = canInterrupt && hasDraft && !directInputDisabled;
|
||||
return {
|
||||
icon: interruptMode ? "square" : canSteer ? "corner-down-right" : "send",
|
||||
label: interruptMode ? "Interrupt" : canSteer ? "Steer" : "Send",
|
||||
className: interruptMode ? "is-interrupt" : canSteer ? "is-steer" : "",
|
||||
disabled: submissionDisabled || (sendDisabled && !interruptMode) || (busy && !canInterrupt),
|
||||
disabled: submissionDisabled || (directInputDisabled && !interruptMode) || (sendDisabled && !interruptMode) || (busy && !canInterrupt),
|
||||
canInterrupt,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { micromark } from "micromark";
|
||||
import { type App, type Component, MarkdownRenderer, Notice } from "obsidian";
|
||||
|
||||
import { codexThreadIdFromHref } from "../../../../domain/threads/deep-link";
|
||||
import { isAbsoluteFileHref, vaultRelativeFileLinkTarget } from "../../../../domain/vault/file-hrefs";
|
||||
import { vaultFileLinkTarget } from "../../../../shared/obsidian/vault-file-links.obsidian";
|
||||
import { notifyThreadStreamContentRendered } from "./content-rendered-event.dom";
|
||||
|
|
@ -9,6 +10,7 @@ interface ThreadStreamMarkdownRendererOptions {
|
|||
app: App;
|
||||
owner: Component;
|
||||
vaultPath: string;
|
||||
openThread: (threadId: string) => void;
|
||||
}
|
||||
|
||||
interface StreamMarkdownRenderContext {
|
||||
|
|
@ -47,6 +49,7 @@ export class ThreadStreamMarkdownRenderer {
|
|||
parent.replaceChildren(...Array.from(staging.childNodes));
|
||||
bindRenderedWikiLinks(parent, sourcePath, this.options);
|
||||
bindRenderedMarkdownFileLinks(parent, sourcePath, this.options);
|
||||
bindCodexThreadLinks(parent, this.options);
|
||||
bindRenderedTags(parent, this.options);
|
||||
notifyThreadStreamContentRendered(parent);
|
||||
})
|
||||
|
|
@ -72,6 +75,10 @@ interface RenderedMarkdownLinkContext {
|
|||
vaultPath: string;
|
||||
}
|
||||
|
||||
interface CodexThreadLinkContext {
|
||||
openThread: (threadId: string) => void;
|
||||
}
|
||||
|
||||
function bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string, context: RenderedMarkdownLinkContext): void {
|
||||
parent.querySelectorAll<HTMLAnchorElement>("a.internal-link").forEach((link) => {
|
||||
link.addClass("codex-panel__wikilink");
|
||||
|
|
@ -115,6 +122,18 @@ function bindRenderedTags(parent: HTMLElement, context: RenderedMarkdownLinkCont
|
|||
});
|
||||
}
|
||||
|
||||
function bindCodexThreadLinks(parent: HTMLElement, context: CodexThreadLinkContext): void {
|
||||
parent.querySelectorAll<HTMLAnchorElement>('a[href^="codex://threads/"]').forEach((link) => {
|
||||
const threadId = codexThreadIdFromHref(link.getAttribute("href") ?? "");
|
||||
if (!threadId) return;
|
||||
link.addClass("codex-panel__thread-link");
|
||||
link.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
context.openThread(threadId);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderedTagName(link: HTMLAnchorElement): string | null {
|
||||
const text = link.textContent.trim();
|
||||
const value = text || link.getAttribute("data-tag") || link.getAttribute("href");
|
||||
|
|
|
|||
|
|
@ -12,13 +12,15 @@ export interface ToolbarThreadRow {
|
|||
title: string;
|
||||
threadId: string;
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
openDisabled?: boolean;
|
||||
renameDisabled: boolean;
|
||||
archiveDisabled: boolean;
|
||||
canArchive: boolean;
|
||||
archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean };
|
||||
rename: {
|
||||
draft: string;
|
||||
generating: boolean;
|
||||
saving: boolean;
|
||||
autoNameDisabled: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +89,7 @@ interface ToolbarThreadActions {
|
|||
updateDraft: (threadId: string, value: string) => void;
|
||||
save: (threadId: string, value: string) => void;
|
||||
cancel: (threadId: string) => void;
|
||||
cancelAutoName: (threadId: string) => void;
|
||||
autoName: (threadId: string) => void;
|
||||
};
|
||||
}
|
||||
|
|
@ -365,7 +368,11 @@ function ThreadList({
|
|||
{threads.map((thread) => (
|
||||
<ThreadListRow key={thread.threadId} thread={thread} actions={actions} />
|
||||
))}
|
||||
{error ? <ToolbarPanelItem label={error} className="codex-panel__thread codex-panel__thread--error" interactive={false} /> : null}
|
||||
{error ? (
|
||||
<div className="codex-panel__thread-list-status codex-panel__thread-list-status--error" role="status">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{hasMore ? (
|
||||
<ToolbarPanelItem
|
||||
label={loadingMore ? "Loading more threads…" : "Load more threads"}
|
||||
|
|
@ -404,7 +411,6 @@ function ThreadListRow({ thread, actions }: { thread: ToolbarThreadRow; actions:
|
|||
label={thread.title}
|
||||
selected={thread.selected}
|
||||
selectionStyle="row"
|
||||
disabled={thread.openDisabled ?? thread.disabled}
|
||||
className="codex-panel__thread"
|
||||
onClick={() => {
|
||||
actions.resume(thread.threadId);
|
||||
|
|
@ -415,7 +421,7 @@ function ThreadListRow({ thread, actions }: { thread: ToolbarThreadRow; actions:
|
|||
icon="pencil"
|
||||
label="Rename thread"
|
||||
className="codex-panel__thread-action"
|
||||
disabled={thread.disabled}
|
||||
disabled={thread.renameDisabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.rename.start(thread.threadId);
|
||||
|
|
@ -437,7 +443,7 @@ function ArchiveControls({ thread, actions }: { thread: ToolbarThreadRow; action
|
|||
icon="archive"
|
||||
label="Archive thread"
|
||||
className="codex-panel__thread-action"
|
||||
disabled={thread.disabled}
|
||||
disabled={thread.archiveDisabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.archive.start(thread.threadId);
|
||||
|
|
@ -471,7 +477,7 @@ function ArchiveModeButton({
|
|||
icon={saveMarkdown ? "save" : "trash"}
|
||||
label={label}
|
||||
className={`codex-panel__thread-action ${primary ? "codex-panel__archive-default" : "codex-panel__archive-alternate"}`}
|
||||
disabled={thread.disabled}
|
||||
disabled={thread.archiveDisabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.archive.confirm(thread.threadId, saveMarkdown);
|
||||
|
|
@ -483,10 +489,12 @@ function ArchiveModeButton({
|
|||
function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; actions: ToolbarThreadActions }): UiNode {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const generating = thread.rename?.generating ?? false;
|
||||
const saving = thread.rename?.saving ?? false;
|
||||
const renameBusy = generating || saving;
|
||||
const draft = thread.rename?.draft ?? thread.title;
|
||||
useLayoutEffect(() => {
|
||||
focusToolbarRenameInput(inputRef.current);
|
||||
}, [draft]);
|
||||
if (!renameBusy) focusToolbarRenameInput(inputRef.current);
|
||||
}, [draft, renameBusy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -501,39 +509,41 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action
|
|||
className="codex-panel-ui__nav-inline-input codex-panel__thread-rename-input"
|
||||
type="text"
|
||||
value={draft}
|
||||
disabled={renameBusy}
|
||||
onInput={(event) => {
|
||||
actions.rename.updateDraft(thread.threadId, event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (!event.isComposing && !generating) actions.rename.save(thread.threadId, event.currentTarget.value);
|
||||
if (!event.isComposing && !renameBusy) actions.rename.save(thread.threadId, event.currentTarget.value);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
actions.rename.cancel(thread.threadId);
|
||||
if (!renameBusy) actions.rename.cancel(thread.threadId);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!generating) actions.rename.save(thread.threadId, event.currentTarget.value);
|
||||
if (!renameBusy) actions.rename.save(thread.threadId, event.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<ToolbarRowActionButton
|
||||
icon={generating ? "loader" : "sparkles"}
|
||||
label="Auto-name thread"
|
||||
icon={generating ? "x" : "sparkles"}
|
||||
label={generating ? "Cancel auto-name" : "Auto-name thread"}
|
||||
className="codex-panel__thread-action"
|
||||
disabled={generating}
|
||||
disabled={saving || (!generating && (thread.rename?.autoNameDisabled ?? true))}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.rename.autoName(thread.threadId);
|
||||
if (generating) actions.rename.cancelAutoName(thread.threadId);
|
||||
else actions.rename.autoName(thread.threadId);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ import {
|
|||
runEphemeralStructuredTurnForLastAgentText,
|
||||
type StructuredTurnOutputSchema,
|
||||
} from "../../app-server/services/ephemeral-structured-turn";
|
||||
import { SelectionRewriteOutputError, selectionRewriteOutputParseResultFromText } from "./output";
|
||||
import { SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS, SELECTION_REWRITE_SERVICE_NAME } from "./prompt";
|
||||
import type { SelectionRewriteTransport, SelectionRewriteTransportRequest } from "./transport";
|
||||
import {
|
||||
type SelectionRewriteOutput,
|
||||
SelectionRewriteOutputError,
|
||||
type SelectionRewriteTransport,
|
||||
type SelectionRewriteTransportRequest,
|
||||
} from "./transport";
|
||||
|
||||
const SELECTION_REWRITE_TIMEOUT_MS = 120_000;
|
||||
|
||||
|
|
@ -60,7 +64,19 @@ async function runAppServerSelectionRewrite(options: AppServerSelectionRewriteTr
|
|||
},
|
||||
options.runner,
|
||||
);
|
||||
const { output, rawText } = selectionRewriteOutputParseResultFromText(lastAgentText);
|
||||
if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", rawText);
|
||||
const output = selectionRewriteOutputFromText(lastAgentText);
|
||||
if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", lastAgentText);
|
||||
return output;
|
||||
}
|
||||
|
||||
function selectionRewriteOutputFromText(text: string | null): SelectionRewriteOutput | null {
|
||||
if (!text) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text.trim()) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const replacementText = (parsed as { replacementText?: unknown }).replacementText;
|
||||
return typeof replacementText === "string" ? { replacementText } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,22 +27,17 @@ export function registerSelectionRewriteCommand(
|
|||
plugin.addCommand({
|
||||
id: "rewrite-selection",
|
||||
name: "Rewrite selection",
|
||||
editorCallback: (editor, view) => {
|
||||
if (!(view instanceof MarkdownView) || !view.file) {
|
||||
new Notice("Select text in an active markdown note first.");
|
||||
return;
|
||||
}
|
||||
|
||||
editorCheckCallback: (checking, editor, view) => {
|
||||
if (!(view instanceof MarkdownView) || !view.file) return false;
|
||||
const originalText = editor.getSelection();
|
||||
if (!originalText.trim()) {
|
||||
new Notice("Select text to rewrite first.");
|
||||
return;
|
||||
}
|
||||
if (!originalText.trim()) return false;
|
||||
if (checking) return true;
|
||||
|
||||
const viewDocument = view.containerEl.doc;
|
||||
const viewWindow = viewDocument.defaultView;
|
||||
if (!viewWindow) {
|
||||
new Notice("Could not open rewrite popover for this note.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const rewriteState: SelectionRewriteState = {
|
||||
|
|
@ -75,6 +70,7 @@ export function registerSelectionRewriteCommand(
|
|||
});
|
||||
popover.open();
|
||||
activePopovers.add(popover);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
export function buildSelectionUnifiedDiff(filePath: string, originalText: string, replacementText: string): string {
|
||||
import { diffArrays } from "diff";
|
||||
|
||||
export function buildSelectionDiffLines(originalText: string, replacementText: string): string[] {
|
||||
const originalLines = textLines(originalText);
|
||||
const replacementLines = textLines(replacementText);
|
||||
const changes = lineChanges(originalLines, replacementLines);
|
||||
const oldCount = Math.max(originalLines.length, 1);
|
||||
const newCount = Math.max(replacementLines.length, 1);
|
||||
|
||||
return [
|
||||
`diff --git a/${filePath} b/${filePath}`,
|
||||
`--- a/${filePath}`,
|
||||
`+++ b/${filePath}`,
|
||||
`@@ -1,${String(oldCount)} +1,${String(newCount)} @@`,
|
||||
...changes.map((change) => `${change.prefix}${change.text}`),
|
||||
].join("\n");
|
||||
return changes.map((change) => `${change.prefix}${change.text}`);
|
||||
}
|
||||
|
||||
const MAX_SELECTION_REWRITE_LCS_CELLS = 40_000;
|
||||
const MAX_SELECTION_REWRITE_EDIT_LENGTH = 400;
|
||||
|
||||
interface DiffLine {
|
||||
prefix: " " | "+" | "-";
|
||||
|
|
@ -29,31 +22,17 @@ function textLines(text: string): string[] {
|
|||
}
|
||||
|
||||
function lineChanges(originalLines: string[], replacementLines: string[]): DiffLine[] {
|
||||
if (originalLines.length * replacementLines.length > MAX_SELECTION_REWRITE_LCS_CELLS) {
|
||||
return linearLineChanges(originalLines, replacementLines);
|
||||
}
|
||||
const arrayChanges = diffArrays(originalLines, replacementLines, {
|
||||
maxEditLength: MAX_SELECTION_REWRITE_EDIT_LENGTH,
|
||||
});
|
||||
if (!arrayChanges) return linearLineChanges(originalLines, replacementLines);
|
||||
|
||||
const lengths = lcsLengths(originalLines, replacementLines);
|
||||
const changes: DiffLine[] = [];
|
||||
let oldIndex = 0;
|
||||
let newIndex = 0;
|
||||
|
||||
while (oldIndex < originalLines.length || newIndex < replacementLines.length) {
|
||||
if (oldIndex < originalLines.length && newIndex < replacementLines.length && originalLines[oldIndex] === replacementLines[newIndex]) {
|
||||
changes.push({ prefix: " ", text: originalLines[oldIndex] ?? "" });
|
||||
oldIndex += 1;
|
||||
newIndex += 1;
|
||||
} else if (
|
||||
newIndex < replacementLines.length &&
|
||||
(oldIndex === originalLines.length || (lengths[oldIndex]?.[newIndex + 1] ?? 0) > (lengths[oldIndex + 1]?.[newIndex] ?? 0))
|
||||
) {
|
||||
changes.push({ prefix: "+", text: replacementLines[newIndex] ?? "" });
|
||||
newIndex += 1;
|
||||
} else if (oldIndex < originalLines.length) {
|
||||
changes.push({ prefix: "-", text: originalLines[oldIndex] ?? "" });
|
||||
oldIndex += 1;
|
||||
}
|
||||
}
|
||||
const changes = arrayChanges.flatMap<DiffLine>((change) =>
|
||||
change.value.map((text) => ({
|
||||
prefix: change.added ? "+" : change.removed ? "-" : " ",
|
||||
text,
|
||||
})),
|
||||
);
|
||||
|
||||
return changes.length > 0 ? changes : [{ prefix: " ", text: "" }];
|
||||
}
|
||||
|
|
@ -96,18 +75,3 @@ function pushLines(changes: DiffLine[], prefix: DiffLine["prefix"], lines: strin
|
|||
changes.push({ prefix, text: lines[index] ?? "" });
|
||||
}
|
||||
}
|
||||
|
||||
function lcsLengths(left: string[], right: string[]): number[][] {
|
||||
const rows = Array.from({ length: left.length + 1 }, () => Array.from({ length: right.length + 1 }, () => 0));
|
||||
for (let leftIndex = left.length - 1; leftIndex >= 0; leftIndex -= 1) {
|
||||
for (let rightIndex = right.length - 1; rightIndex >= 0; rightIndex -= 1) {
|
||||
const row = rows[leftIndex];
|
||||
if (row === undefined) continue;
|
||||
row[rightIndex] =
|
||||
left[leftIndex] === right[rightIndex]
|
||||
? (rows[leftIndex + 1]?.[rightIndex + 1] ?? 0) + 1
|
||||
: Math.max(rows[leftIndex + 1]?.[rightIndex] ?? 0, rows[leftIndex]?.[rightIndex + 1] ?? 0);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -6,8 +6,8 @@ import { renderUiRoot, unmountUiRoot } from "../../shared/dom/preact-root.dom";
|
|||
import { syncTextareaHeight } from "../../shared/dom/textarea-autogrow.measure";
|
||||
import { textareaCursorAtVisualBoundary } from "../../shared/dom/textarea-caret.measure";
|
||||
import { IconButton } from "../../shared/obsidian/components.obsidian";
|
||||
import { DiffLineList, unifiedDiffDisplayLines } from "../../shared/ui/diff-view";
|
||||
import { buildSelectionUnifiedDiff } from "./diff";
|
||||
import { DiffLineList } from "../../shared/ui/diff-view";
|
||||
import { buildSelectionDiffLines } from "./diff";
|
||||
import {
|
||||
canApplySelectionRewrite,
|
||||
type SelectionRewriteInstructionHistoryDirection,
|
||||
|
|
@ -201,7 +201,7 @@ export class SelectionRewritePopover {
|
|||
elements.applyButton = element;
|
||||
}}
|
||||
debugText={state.debugText}
|
||||
diff={replacement === null ? null : buildSelectionUnifiedDiff(state.filePath, state.originalText, replacement)}
|
||||
diffLines={replacement === null ? null : buildSelectionDiffLines(state.originalText, replacement)}
|
||||
generating={state.status === "generating"}
|
||||
hasInstruction={this.session.hasInstruction}
|
||||
hasReplacement={replacement !== null}
|
||||
|
|
@ -306,7 +306,7 @@ function selectionRewriteInstructionCursorOnLogicalBoundary(
|
|||
interface SelectionRewritePopoverViewProps {
|
||||
applyButtonRef: (element: HTMLButtonElement | null) => void;
|
||||
debugText: string | null;
|
||||
diff: string | null;
|
||||
diffLines: readonly string[] | null;
|
||||
generating: boolean;
|
||||
hasInstruction: boolean;
|
||||
hasReplacement: boolean;
|
||||
|
|
@ -324,7 +324,7 @@ interface SelectionRewritePopoverViewProps {
|
|||
function SelectionRewritePopoverView({
|
||||
applyButtonRef,
|
||||
debugText,
|
||||
diff,
|
||||
diffLines,
|
||||
generating,
|
||||
hasInstruction,
|
||||
hasReplacement,
|
||||
|
|
@ -365,7 +365,7 @@ function SelectionRewritePopoverView({
|
|||
<SelectionRewriteStatus status={status} />
|
||||
<pre className={`codex-panel-selection-rewrite__stream-preview${streamPreview ? "" : " is-hidden"}`}>{streamPreview}</pre>
|
||||
<div className={`codex-panel-selection-rewrite__result${hasReplacement ? "" : " is-hidden"}`}>
|
||||
<div className="codex-panel-selection-rewrite__diff">{diff ? <SelectionRewriteDiff diff={diff} /> : null}</div>
|
||||
<div className="codex-panel-selection-rewrite__diff">{diffLines ? <SelectionRewriteDiff lines={diffLines} /> : null}</div>
|
||||
<div className="codex-panel-selection-rewrite__result-actions">
|
||||
<IconButton
|
||||
buttonRef={applyButtonRef}
|
||||
|
|
@ -407,11 +407,6 @@ function SelectionRewriteStatus({ status }: { status: SelectionRewriteSessionSta
|
|||
);
|
||||
}
|
||||
|
||||
function SelectionRewriteDiff({ diff }: { diff: string }): UiNode {
|
||||
return (
|
||||
<DiffLineList
|
||||
lines={unifiedDiffDisplayLines(diff).filter((line) => line.kind !== "file" && !line.text.startsWith("@@"))}
|
||||
className="codex-panel-selection-rewrite__diff-body"
|
||||
/>
|
||||
);
|
||||
function SelectionRewriteDiff({ lines }: { lines: readonly string[] }): UiNode {
|
||||
return <DiffLineList lines={lines.map((text) => ({ text }))} className="codex-panel-selection-rewrite__diff-body" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { SelectionRewriteInstructionHistoryDirection, SelectionRewriteRuntimeSettings, SelectionRewriteState } from "./model";
|
||||
import { SelectionRewriteOutputError } from "./output";
|
||||
import { buildSelectionRewritePrompt } from "./prompt";
|
||||
import type { SelectionRewriteActivity, SelectionRewriteTransport } from "./transport";
|
||||
import { type SelectionRewriteActivity, SelectionRewriteOutputError, type SelectionRewriteTransport } from "./transport";
|
||||
|
||||
const MAX_SELECTION_REWRITE_INSTRUCTION_HISTORY = 20;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
import type { SelectionRewriteRuntimeSettings } from "./model";
|
||||
import type { SelectionRewriteOutput } from "./output";
|
||||
|
||||
export type SelectionRewriteActivity = "reasoning" | "writing";
|
||||
|
||||
export interface SelectionRewriteOutput {
|
||||
replacementText: string;
|
||||
}
|
||||
|
||||
export class SelectionRewriteOutputError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly rawText: string | null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SelectionRewriteOutputError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SelectionRewriteTransportRequest {
|
||||
prompt: string;
|
||||
runtimeSettings: SelectionRewriteRuntimeSettings;
|
||||
|
|
|
|||
|
|
@ -34,11 +34,11 @@ export function openThreadPicker(host: ThreadPickerHost, onClosed: () => void):
|
|||
};
|
||||
const loadAndOpen = async (): Promise<void> => {
|
||||
try {
|
||||
const recentSnapshot = host.threadCatalog.recentActiveSnapshot();
|
||||
const loadedThreads = recentSnapshot ?? (await host.threadCatalog.loadActive());
|
||||
const recentSnapshot = host.threadCatalog.recentActiveThreadsSnapshot();
|
||||
const loadedThreads = recentSnapshot ?? (await host.threadCatalog.fetchActiveThreads());
|
||||
if (state.closed) return;
|
||||
const recentThreads = host.threadCatalog.recentActiveSnapshot() ?? loadedThreads;
|
||||
if (recentThreads.length === 0 && !host.threadCatalog.hasMoreActive()) {
|
||||
const recentThreads = host.threadCatalog.recentActiveThreadsSnapshot() ?? loadedThreads;
|
||||
if (recentThreads.length === 0 && !host.threadCatalog.hasMoreActiveThreads()) {
|
||||
new Notice("No Codex threads found.");
|
||||
finish();
|
||||
return;
|
||||
|
|
@ -152,7 +152,7 @@ class ThreadPickerModal extends SuggestModal<ThreadSuggestion> {
|
|||
|
||||
private async loadCompleteThreadList(): Promise<readonly Thread[]> {
|
||||
if (this.completeThreads) return this.completeThreads;
|
||||
const pending = this.completeThreadsPromise ?? this.host.threadCatalog.searchActive();
|
||||
const pending = this.completeThreadsPromise ?? this.host.threadCatalog.fetchActiveThreadSearchInventory();
|
||||
this.completeThreadsPromise = pending;
|
||||
try {
|
||||
this.completeThreads = Object.freeze([...(await pending)]);
|
||||
|
|
|
|||
|
|
@ -2,17 +2,16 @@ import { Notice } from "obsidian";
|
|||
|
||||
import type { ObservedPaginatedResult } from "../../app-server/query/observed-result";
|
||||
import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result";
|
||||
import { isStaleAppServerResourceContextError } from "../../app-server/query/resource-store";
|
||||
import type { ReasoningEffort } from "../../domain/catalog/metadata";
|
||||
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import type { ThreadRenameLifecycleEvent } from "../../domain/threads/rename-lifecycle";
|
||||
import { DeferredTask } from "../../shared/runtime/deferred-task";
|
||||
import { isStaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
|
||||
import type { KeyedOperationQueue } from "../../shared/runtime/keyed-operation-queue";
|
||||
import { OwnerLifetime } from "../../shared/runtime/owner-lifetime";
|
||||
import type { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
|
||||
import type { ArchiveExportDestination } from "../threads/workflows/archive-export";
|
||||
import type { ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
|
||||
import type { ArchiveExportDestination, ArchiveExportSettings } from "../threads/workflows/archive-export";
|
||||
import type { ThreadOperationsTransport, ThreadTitleTransport } from "../threads/workflows/ports";
|
||||
import type { ThreadNameMutationCoordinator } from "../threads/workflows/thread-name-mutation-coordinator";
|
||||
import type { ThreadOperationEventSink } from "../threads/workflows/thread-operation-event";
|
||||
import { createThreadOperations, type ThreadOperations } from "../threads/workflows/thread-operations";
|
||||
import { createThreadTitleService, type ThreadTitleService } from "../threads/workflows/thread-title-service";
|
||||
import { isThreadsArchiveConfirmPointer, renderThreadsViewShell, unmountThreadsViewShell } from "./shell.dom";
|
||||
|
|
@ -21,7 +20,8 @@ export interface ThreadsViewHost {
|
|||
readonly settings: ThreadsViewSettingsAccess;
|
||||
readonly vaultPath: string;
|
||||
readonly threadCatalog: ThreadsViewThreadCatalog;
|
||||
readonly threadNameMutations: ThreadNameMutationCoordinator;
|
||||
readonly threadEvents: ThreadOperationEventSink;
|
||||
readonly threadNameMutations: KeyedOperationQueue<string>;
|
||||
readonly threadOperationsTransport: ThreadOperationsTransport;
|
||||
readonly threadTitleTransport: ThreadTitleTransport;
|
||||
openNewPanel(): Promise<unknown>;
|
||||
|
|
@ -29,13 +29,10 @@ export interface ThreadsViewHost {
|
|||
openPanelActivities(): readonly ThreadsViewPanelActivity[];
|
||||
}
|
||||
|
||||
type ThreadsViewThreadCatalog = ThreadCatalogPaginatedActiveReader & ThreadCatalogEventSink;
|
||||
type ThreadsViewThreadCatalog = ThreadCatalogPaginatedActiveReader;
|
||||
|
||||
export interface ThreadsViewSettingsAccess {
|
||||
archiveExportEnabled(): boolean;
|
||||
codexPath(): string;
|
||||
threadNamingModel(): string | null;
|
||||
threadNamingEffort(): ReasoningEffort | null;
|
||||
archiveExportSettings(): ArchiveExportSettings;
|
||||
}
|
||||
|
||||
|
|
@ -48,17 +45,16 @@ export interface ThreadsViewSessionEnvironment {
|
|||
viewWindow(): Window | null;
|
||||
}
|
||||
|
||||
type ThreadsViewStatus =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading"; message: string }
|
||||
| { kind: "empty"; message: string }
|
||||
| { kind: "log"; message: string }
|
||||
| { kind: "error"; message: string };
|
||||
type ThreadsViewStatus = { kind: "idle" } | { kind: "loading"; message: string } | { kind: "error"; message: string };
|
||||
|
||||
interface ThreadsViewOperationLease {
|
||||
readonly lifetime: AbortSignal;
|
||||
}
|
||||
|
||||
interface ThreadTitleContextPreparation {
|
||||
readonly threadId: string;
|
||||
}
|
||||
|
||||
export class ThreadsViewSession {
|
||||
private readonly lifetime = new OwnerLifetime();
|
||||
private readonly operations: ThreadOperations;
|
||||
|
|
@ -70,7 +66,10 @@ export class ThreadsViewSession {
|
|||
private threads: readonly Thread[] = [];
|
||||
private threadsLoaded = false;
|
||||
private readonly renameStates = new Map<string, ThreadsRenameState>();
|
||||
private readonly renameContextPreparations = new Map<string, ThreadTitleContextPreparation>();
|
||||
private readonly renameGenerationControllers = new Map<string, { generationToken: number; controller: AbortController }>();
|
||||
private nextRenameGenerationToken = 1;
|
||||
private nextRenameSaveToken = 1;
|
||||
private unsubscribeThreads: (() => void) | null = null;
|
||||
private archiveConfirmThreadId: string | null = null;
|
||||
|
||||
|
|
@ -86,7 +85,7 @@ export class ThreadsViewSession {
|
|||
vaultConfigDir: this.environment.vaultConfigDir(),
|
||||
},
|
||||
archiveDestination: () => this.environment.archiveDestination(),
|
||||
catalog: this.host.threadCatalog,
|
||||
operationEvents: this.host.threadEvents,
|
||||
referenceThreads: () => this.threads,
|
||||
notice: (message) => {
|
||||
new Notice(message);
|
||||
|
|
@ -102,12 +101,12 @@ export class ThreadsViewSession {
|
|||
this.environment.registerPointerDown((event) => {
|
||||
this.cancelArchiveConfirmOnOutsidePointer(event);
|
||||
});
|
||||
const activeThreadsSnapshot = this.host.threadCatalog.activeSnapshot();
|
||||
const activeThreadsSnapshot = this.host.threadCatalog.activeThreadsSnapshot();
|
||||
if (activeThreadsSnapshot) {
|
||||
this.threads = activeThreadsSnapshot;
|
||||
this.threadsLoaded = true;
|
||||
}
|
||||
this.unsubscribeThreads = this.host.threadCatalog.observeActive((result) => {
|
||||
this.unsubscribeThreads = this.host.threadCatalog.observeActiveThreadsResult((result) => {
|
||||
this.receiveObservedThreadsResult(result);
|
||||
});
|
||||
this.render();
|
||||
|
|
@ -116,6 +115,9 @@ export class ThreadsViewSession {
|
|||
|
||||
close(): void {
|
||||
this.lifetime.dispose();
|
||||
for (const operation of this.renameGenerationControllers.values()) operation.controller.abort();
|
||||
this.renameGenerationControllers.clear();
|
||||
this.renameContextPreparations.clear();
|
||||
this.titleService.invalidate();
|
||||
this.observedFetching = false;
|
||||
this.observedFetchingNextPage = false;
|
||||
|
|
@ -126,11 +128,11 @@ export class ThreadsViewSession {
|
|||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
await this.requestThreads(() => this.host.threadCatalog.refreshActive());
|
||||
await this.requestThreads(() => this.host.threadCatalog.refreshActiveThreads());
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
await this.requestThreads(() => this.host.threadCatalog.loadActive());
|
||||
await this.requestThreads(() => this.host.threadCatalog.fetchActiveThreads());
|
||||
}
|
||||
|
||||
private async requestThreads(request: () => Promise<readonly Thread[]>): Promise<void> {
|
||||
|
|
@ -139,23 +141,24 @@ export class ThreadsViewSession {
|
|||
try {
|
||||
await request();
|
||||
} catch (error) {
|
||||
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
|
||||
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(error)) return;
|
||||
if (!this.currentThreadsSnapshot()) {
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
this.render();
|
||||
} else {
|
||||
this.noticeError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadMore(): Promise<void> {
|
||||
const lifetime = this.lifetime.signal();
|
||||
if (!this.lifetime.isCurrent(lifetime) || !this.host.threadCatalog.hasMoreActive() || this.observedFetching) return;
|
||||
if (!this.lifetime.isCurrent(lifetime) || !this.host.threadCatalog.hasMoreActiveThreads() || this.observedFetching) return;
|
||||
try {
|
||||
await this.host.threadCatalog.loadMoreActive();
|
||||
await this.host.threadCatalog.loadMoreActiveThreads();
|
||||
} catch (error) {
|
||||
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
this.render();
|
||||
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(error)) return;
|
||||
this.noticeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,13 +175,10 @@ export class ThreadsViewSession {
|
|||
this.observedFetchingNextPage = result.isFetchingNextPage;
|
||||
const observedThreads = result.value;
|
||||
if (observedThreads) {
|
||||
const hadThreadsSnapshot = this.currentThreadsSnapshot() !== null;
|
||||
this.threads = observedThreads;
|
||||
this.threadsLoaded = true;
|
||||
this.status = result.error
|
||||
? { kind: "error", message: result.error.message }
|
||||
: observedThreads.length === 0
|
||||
? { kind: "empty", message: "No threads" }
|
||||
: { kind: "idle" };
|
||||
this.status = result.error && !hadThreadsSnapshot ? { kind: "error", message: result.error.message } : { kind: "idle" };
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
|
@ -208,10 +208,10 @@ export class ThreadsViewSession {
|
|||
renderThreadsViewShell(
|
||||
this.environment.root,
|
||||
{
|
||||
status: this.status.kind === "idle" ? null : this.status.message,
|
||||
status: this.status.kind === "idle" ? null : this.status,
|
||||
loading: this.observedFetchingNextPage,
|
||||
fetching: this.observedFetching,
|
||||
hasMore: this.host.threadCatalog.hasMoreActive(),
|
||||
hasMore: this.host.threadCatalog.hasMoreActiveThreads(),
|
||||
rows: threadRows(
|
||||
this.threads,
|
||||
this.host.openPanelActivities(),
|
||||
|
|
@ -235,6 +235,9 @@ export class ThreadsViewSession {
|
|||
cancelRename: (threadId) => {
|
||||
this.cancelRename(threadId);
|
||||
},
|
||||
cancelAutoName: (threadId) => {
|
||||
this.cancelAutoName(threadId);
|
||||
},
|
||||
autoNameThread: (threadId) => void this.autoNameThread(threadId),
|
||||
startArchive: (threadId) => {
|
||||
this.startArchive(threadId);
|
||||
|
|
@ -260,9 +263,12 @@ export class ThreadsViewSession {
|
|||
}
|
||||
|
||||
private startRename(threadId: string, value: string): void {
|
||||
const current = this.renameStates.get(threadId);
|
||||
if (current?.kind === "saving") return;
|
||||
this.archiveConfirmThreadId = null;
|
||||
this.transitionRenameState(threadId, { type: "started", draft: value });
|
||||
this.render();
|
||||
void this.prepareAutoName(threadId);
|
||||
}
|
||||
|
||||
private updateRename(threadId: string, value: string): void {
|
||||
|
|
@ -271,30 +277,45 @@ export class ThreadsViewSession {
|
|||
}
|
||||
|
||||
private cancelRename(threadId: string): void {
|
||||
if (this.renameStates.get(threadId)?.kind === "saving") return;
|
||||
this.abortAutoName(threadId);
|
||||
this.renameContextPreparations.delete(threadId);
|
||||
this.transitionRenameState(threadId, { type: "cancelled" });
|
||||
this.render();
|
||||
}
|
||||
|
||||
private cancelAutoName(threadId: string): void {
|
||||
const state = this.renameStates.get(threadId);
|
||||
if (state?.kind !== "generating") return;
|
||||
this.abortAutoName(threadId);
|
||||
this.finishAutoNameThread(threadId, state.generationToken);
|
||||
}
|
||||
|
||||
private async saveRename(threadId: string, value: string): Promise<void> {
|
||||
const lease = this.captureOperationLease();
|
||||
const editingState = this.renameStates.get(threadId);
|
||||
if (!editingState || editingState.kind === "generating") return;
|
||||
const previousState = this.renameStates.get(threadId);
|
||||
if (previousState?.kind !== "editing") return;
|
||||
const saveToken = this.nextRenameSaveToken;
|
||||
const savingState = this.transitionRenameState(threadId, { type: "save-started", saveToken });
|
||||
if (savingState === previousState || savingState?.kind !== "saving") return;
|
||||
this.nextRenameSaveToken += 1;
|
||||
this.render();
|
||||
try {
|
||||
if (this.renameStates.get(threadId) !== editingState) return;
|
||||
const result = await this.operations.renameThread(threadId, value, {
|
||||
shouldStart: () => this.operationContextIsCurrent(lease),
|
||||
shouldPublish: () => this.operationContextIsCurrent(lease),
|
||||
await this.operations.renameThread(threadId, value, {
|
||||
shouldStart: () => this.operationContextIsCurrent(lease) && this.renameSaveStillActive(threadId, saveToken),
|
||||
shouldPublish: () => this.operationContextIsCurrent(lease) && this.renameSaveStillActive(threadId, saveToken),
|
||||
});
|
||||
if (!this.operationViewIsCurrent(lease) || this.renameStates.get(threadId) !== editingState) return;
|
||||
if (!result) {
|
||||
this.cancelRename(threadId);
|
||||
return;
|
||||
if (!this.operationViewIsCurrent(lease)) return;
|
||||
const currentState = this.renameStates.get(threadId);
|
||||
const nextState = this.transitionRenameState(threadId, { type: "save-succeeded", saveToken });
|
||||
if (nextState !== currentState) {
|
||||
if (!nextState) this.renameContextPreparations.delete(threadId);
|
||||
this.render();
|
||||
}
|
||||
this.renameStates.delete(threadId);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
if (!this.operationViewIsCurrent(lease) || this.renameStates.get(threadId) !== editingState) return;
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
if (!this.operationViewIsCurrent(lease) || !this.renameSaveStillActive(threadId, saveToken)) return;
|
||||
this.noticeError(error);
|
||||
this.transitionRenameState(threadId, { type: "save-failed", saveToken });
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -309,19 +330,24 @@ export class ThreadsViewSession {
|
|||
});
|
||||
if (generatingState === previousState || generatingState?.kind !== "generating") return;
|
||||
this.nextRenameGenerationToken += 1;
|
||||
const controller = new AbortController();
|
||||
this.renameGenerationControllers.set(threadId, { generationToken, controller });
|
||||
this.render();
|
||||
|
||||
try {
|
||||
if (this.renameStates.get(threadId) !== generatingState) return;
|
||||
const title = await this.titleService.generateTitle(threadId);
|
||||
const context = generatingState.autoName.context;
|
||||
const title = await this.titleService.generate(context, controller.signal);
|
||||
if (!title) throw new Error("Codex did not return a usable thread title.");
|
||||
if (!this.operationViewIsCurrent(lease)) return;
|
||||
this.transitionRenameState(threadId, { type: "generation-succeeded", generationToken, draft: title });
|
||||
} catch (error) {
|
||||
if (!this.operationViewIsCurrent(lease)) return;
|
||||
if (this.renameStates.get(threadId) === generatingState) {
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
this.noticeError(error);
|
||||
}
|
||||
} finally {
|
||||
const operation = this.renameGenerationControllers.get(threadId);
|
||||
if (operation?.generationToken === generationToken) this.renameGenerationControllers.delete(threadId);
|
||||
if (this.operationViewIsCurrent(lease)) {
|
||||
this.finishAutoNameThread(threadId, generationToken);
|
||||
}
|
||||
|
|
@ -356,8 +382,7 @@ export class ThreadsViewSession {
|
|||
this.renameStates.delete(threadId);
|
||||
} catch (error) {
|
||||
if (!this.operationViewIsCurrent(lease)) return;
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
this.render();
|
||||
this.noticeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -373,12 +398,38 @@ export class ThreadsViewSession {
|
|||
return this.lifetime.isCurrent(lease.lifetime);
|
||||
}
|
||||
|
||||
private noticeError(error: unknown): void {
|
||||
new Notice(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
private finishAutoNameThread(threadId: string, generationToken: number): void {
|
||||
const previousState = this.renameStates.get(threadId);
|
||||
const nextState = this.transitionRenameState(threadId, { type: "generation-finished", generationToken });
|
||||
if (nextState !== previousState) this.render();
|
||||
}
|
||||
|
||||
private async prepareAutoName(threadId: string): Promise<void> {
|
||||
const preparation = { threadId };
|
||||
this.renameContextPreparations.set(threadId, preparation);
|
||||
let context = null;
|
||||
try {
|
||||
context = await this.titleService.resolveContext(threadId);
|
||||
} catch {
|
||||
// Auto-name availability is reflected by the disabled action.
|
||||
}
|
||||
if (!this.lifetime.isActive() || this.renameContextPreparations.get(threadId) !== preparation) return;
|
||||
this.renameContextPreparations.delete(threadId);
|
||||
const state = this.renameStates.get(threadId);
|
||||
if (state?.kind !== "editing" && state?.kind !== "saving") return;
|
||||
this.transitionRenameState(threadId, { type: "auto-name-context-resolved", context });
|
||||
this.render();
|
||||
}
|
||||
|
||||
private abortAutoName(threadId: string): void {
|
||||
this.renameGenerationControllers.get(threadId)?.controller.abort();
|
||||
this.renameGenerationControllers.delete(threadId);
|
||||
}
|
||||
|
||||
private transitionRenameState(threadId: string, event: ThreadRenameLifecycleEvent): ThreadsRenameState | undefined {
|
||||
const nextState = transitionThreadsRenameState(this.renameStates.get(threadId), event);
|
||||
if (nextState) {
|
||||
|
|
@ -389,6 +440,11 @@ export class ThreadsViewSession {
|
|||
return nextState;
|
||||
}
|
||||
|
||||
private renameSaveStillActive(threadId: string, saveToken: number): boolean {
|
||||
const current = this.renameStates.get(threadId);
|
||||
return current?.kind === "saving" && current.saveToken === saveToken;
|
||||
}
|
||||
|
||||
private viewWindow(): Window {
|
||||
return this.environment.viewWindow() ?? window;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ type ButtonProps = ButtonHTMLAttributes & {
|
|||
};
|
||||
|
||||
export interface ThreadsViewShellModel {
|
||||
status: string | null;
|
||||
status: { kind: "loading" | "error"; message: string } | null;
|
||||
loading: boolean;
|
||||
fetching?: boolean;
|
||||
hasMore?: boolean;
|
||||
|
|
@ -25,6 +25,7 @@ export interface ThreadsViewShellActions {
|
|||
updateRename: (threadId: string, value: string) => void;
|
||||
saveRename: (threadId: string, value: string) => void;
|
||||
cancelRename: (threadId: string) => void;
|
||||
cancelAutoName: (threadId: string) => void;
|
||||
autoNameThread: (threadId: string) => void;
|
||||
startArchive: (threadId: string) => void;
|
||||
archiveThread: (threadId: string, saveMarkdown: boolean) => void;
|
||||
|
|
@ -65,10 +66,22 @@ function ThreadsViewShell({ model, actions }: { model: ThreadsViewShellModel; ac
|
|||
</div>
|
||||
<div className="codex-panel-threads__list">
|
||||
{model.rows.length === 0 ? (
|
||||
<div className="codex-panel-threads__empty">{model.status ?? (model.loading ? "Loading threads..." : "No threads")}</div>
|
||||
model.status ? (
|
||||
<div
|
||||
className={
|
||||
model.status.kind === "error"
|
||||
? "codex-panel-threads__status codex-panel-threads__status--error"
|
||||
: "codex-panel-threads__status"
|
||||
}
|
||||
role="status"
|
||||
>
|
||||
{model.status.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="codex-panel-threads__empty">No threads</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{model.status ? <div className="codex-panel-threads__status">{model.status}</div> : null}
|
||||
{model.rows.map((row) => (
|
||||
<ThreadRow key={row.threadId} row={row} actions={actions} />
|
||||
))}
|
||||
|
|
@ -213,9 +226,10 @@ function threadArchiveDisabled(row: ThreadsRowModel): boolean {
|
|||
|
||||
function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions: ThreadsViewShellActions; className: string }): UiNode {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const renameBusy = row.rename.generating || row.rename.saving;
|
||||
useLayoutEffect(() => {
|
||||
focusThreadsRenameInput(inputRef.current);
|
||||
}, [row.rename.draft]);
|
||||
if (!renameBusy) focusThreadsRenameInput(inputRef.current);
|
||||
}, [row.rename.draft, renameBusy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -226,39 +240,41 @@ function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions:
|
|||
className="codex-panel-ui__nav-inline-input codex-panel-threads__rename-input"
|
||||
type="text"
|
||||
value={row.rename.draft}
|
||||
disabled={renameBusy}
|
||||
onInput={(event) => {
|
||||
actions.updateRename(row.threadId, event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (!event.isComposing && !row.rename.generating) actions.saveRename(row.threadId, event.currentTarget.value);
|
||||
if (!event.isComposing && !renameBusy) actions.saveRename(row.threadId, event.currentTarget.value);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
actions.cancelRename(row.threadId);
|
||||
if (!renameBusy) actions.cancelRename(row.threadId);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!row.rename.generating) actions.saveRename(row.threadId, event.currentTarget.value);
|
||||
if (!renameBusy) actions.saveRename(row.threadId, event.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="codex-panel-threads__actions codex-panel-threads__rename-actions">
|
||||
<ThreadsRowButton
|
||||
icon={row.rename.generating ? "loader" : "sparkles"}
|
||||
label="Auto-name thread"
|
||||
icon={row.rename.generating ? "x" : "sparkles"}
|
||||
label={row.rename.generating ? "Cancel auto-name" : "Auto-name thread"}
|
||||
className="codex-panel-threads__row-button"
|
||||
disabled={row.rename.generating}
|
||||
disabled={row.rename.saving || (!row.rename.generating && row.rename.autoNameDisabled)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.autoNameThread(row.threadId);
|
||||
if (row.rename.generating) actions.cancelAutoName(row.threadId);
|
||||
else actions.autoNameThread(row.threadId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Component, ItemView, type WorkspaceLeaf } from "obsidian";
|
||||
|
||||
import { VIEW_TYPE_CODEX_THREADS } from "../../constants";
|
||||
import { createObsidianArchiveExportDestination } from "../threads/obsidian/archive-export-destination.obsidian";
|
||||
import { createObsidianVaultMarkdownDestination } from "../../shared/obsidian/vault-write-destination.obsidian";
|
||||
import { type ThreadsViewHost, ThreadsViewSession } from "./session";
|
||||
|
||||
export interface ThreadsRuntimeView {
|
||||
|
|
@ -38,7 +38,7 @@ export class CodexThreadsView extends ItemView {
|
|||
registerPointerDown: (handler) => {
|
||||
owner.registerDomEvent(this.containerEl.doc, "pointerdown", handler);
|
||||
},
|
||||
archiveDestination: () => createObsidianArchiveExportDestination(this.app.vault),
|
||||
archiveDestination: () => createObsidianVaultMarkdownDestination(this.app.vault),
|
||||
vaultConfigDir: () => this.app.vault.configDir,
|
||||
viewWindow: () => this.containerEl.doc.defaultView,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,129 +5,32 @@ import type { Thread } from "../../../domain/threads/model";
|
|||
type ActiveThreadListObserver = ObservedPaginatedResultListener<readonly Thread[]>;
|
||||
type ArchivedThreadListObserver = ObservedResultListener<readonly Thread[]>;
|
||||
|
||||
interface ThreadCatalogStore {
|
||||
export interface ThreadCatalogPaginatedActiveReader {
|
||||
activeThreadsSnapshot(): readonly Thread[] | null;
|
||||
recentActiveThreadsSnapshot(): readonly Thread[] | null;
|
||||
archivedThreadsSnapshot(): readonly Thread[] | null;
|
||||
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]>;
|
||||
fetchActiveThreads(): Promise<readonly Thread[]>;
|
||||
refreshActiveThreads(): Promise<readonly Thread[]>;
|
||||
observeActiveThreadsResult(observer: ActiveThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
hasMoreActiveThreads(): boolean;
|
||||
loadMoreActiveThreads(): Promise<readonly Thread[]>;
|
||||
refreshActiveThreads(): Promise<readonly Thread[]>;
|
||||
refreshArchivedThreads(): Promise<readonly Thread[]>;
|
||||
applyThreadListMutations(mutations: readonly ThreadListMutation[]): void;
|
||||
observeActiveThreadsResult(observer: ActiveThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
observeArchivedThreadsResult(observer: ArchivedThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
}
|
||||
|
||||
type ThreadCatalogEventObserver = (event: ThreadCatalogEvent) => void;
|
||||
|
||||
export interface ThreadCatalogOptions {
|
||||
store: ThreadCatalogStore;
|
||||
onEventApplied?: ThreadCatalogEventObserver;
|
||||
}
|
||||
|
||||
export type ThreadCatalogEvent =
|
||||
| { type: "thread-upserted"; thread: Thread }
|
||||
| { type: "thread-renamed"; threadId: string; name: string | null }
|
||||
| { type: "thread-archived"; threadId: string }
|
||||
| { type: "thread-deleted"; threadId: string }
|
||||
| { type: "thread-restored"; thread: Thread }
|
||||
| { type: "thread-unarchived"; threadId: string };
|
||||
|
||||
export interface ThreadCatalogPaginatedActiveReader {
|
||||
activeSnapshot(): readonly Thread[] | null;
|
||||
recentActiveSnapshot(): readonly Thread[] | null;
|
||||
loadActive(): Promise<readonly Thread[]>;
|
||||
refreshActive(): Promise<readonly Thread[]>;
|
||||
observeActive(observer: ActiveThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
hasMoreActive(): boolean;
|
||||
loadMoreActive(): Promise<readonly Thread[]>;
|
||||
}
|
||||
|
||||
export interface ThreadCatalogSearchReader {
|
||||
searchActive(): Promise<readonly Thread[]>;
|
||||
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]>;
|
||||
}
|
||||
|
||||
export interface ThreadCatalogArchivedReader {
|
||||
archivedSnapshot(): readonly Thread[] | null;
|
||||
refreshArchived(): Promise<readonly Thread[]>;
|
||||
observeArchived(observer: ArchivedThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
archivedThreadsSnapshot(): readonly Thread[] | null;
|
||||
refreshArchivedThreads(): Promise<readonly Thread[]>;
|
||||
observeArchivedThreadsResult(observer: ArchivedThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
}
|
||||
|
||||
export interface ThreadCatalogEventSink {
|
||||
apply(event: ThreadCatalogEvent): void;
|
||||
interface ThreadCatalogWriter {
|
||||
applyThreadListMutations(changes: readonly ThreadListMutation[]): void;
|
||||
}
|
||||
|
||||
export interface ThreadCatalog
|
||||
extends ThreadCatalogPaginatedActiveReader,
|
||||
ThreadCatalogSearchReader,
|
||||
ThreadCatalogArchivedReader,
|
||||
ThreadCatalogEventSink {}
|
||||
|
||||
export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalog {
|
||||
const { store } = options;
|
||||
const apply = (event: ThreadCatalogEvent): void => {
|
||||
store.applyThreadListMutations(threadListMutationsForEvent(store, event));
|
||||
options.onEventApplied?.(event);
|
||||
};
|
||||
|
||||
return {
|
||||
apply,
|
||||
activeSnapshot: () => store.activeThreadsSnapshot(),
|
||||
recentActiveSnapshot: () => store.recentActiveThreadsSnapshot(),
|
||||
loadActive: () => store.fetchActiveThreads(),
|
||||
searchActive: () => store.fetchActiveThreadSearchInventory(),
|
||||
refreshActive: () => store.refreshActiveThreads(),
|
||||
hasMoreActive: () => store.hasMoreActiveThreads(),
|
||||
loadMoreActive: () => store.loadMoreActiveThreads(),
|
||||
observeActive: (observer, observeOptions) => store.observeActiveThreadsResult(observer, observeOptions),
|
||||
archivedSnapshot: () => store.archivedThreadsSnapshot(),
|
||||
refreshArchived: () => store.refreshArchivedThreads(),
|
||||
observeArchived: (observer, observeOptions) => store.observeArchivedThreadsResult(observer, observeOptions),
|
||||
};
|
||||
}
|
||||
|
||||
function threadListMutationsForEvent(store: ThreadCatalogStore, event: ThreadCatalogEvent): ThreadListMutation[] {
|
||||
switch (event.type) {
|
||||
case "thread-upserted":
|
||||
return [{ kind: "upsert", list: "active", thread: { ...event.thread, archived: false } }];
|
||||
case "thread-renamed":
|
||||
return [
|
||||
{ kind: "update", list: "active", threadId: event.threadId, changes: { name: event.name } },
|
||||
{ kind: "update", list: "archived", threadId: event.threadId, changes: { name: event.name } },
|
||||
];
|
||||
case "thread-archived": {
|
||||
const thread = threadById(store.activeThreadsSnapshot(), event.threadId);
|
||||
return [
|
||||
{ kind: "remove", list: "active", threadId: event.threadId },
|
||||
...(thread
|
||||
? [{ kind: "upsert", list: "archived", thread: { ...thread, archived: true } } satisfies ThreadListMutation]
|
||||
: [{ kind: "refresh", list: "archived" } satisfies ThreadListMutation]),
|
||||
];
|
||||
}
|
||||
case "thread-deleted":
|
||||
return [
|
||||
{ kind: "remove", list: "active", threadId: event.threadId },
|
||||
{ kind: "remove", list: "archived", threadId: event.threadId },
|
||||
];
|
||||
case "thread-restored":
|
||||
return [
|
||||
{ kind: "upsert", list: "active", thread: { ...event.thread, archived: false } },
|
||||
{ kind: "remove", list: "archived", threadId: event.thread.id },
|
||||
];
|
||||
case "thread-unarchived": {
|
||||
const thread = threadById(store.archivedThreadsSnapshot(), event.threadId);
|
||||
return [
|
||||
{ kind: "remove", list: "archived", threadId: event.threadId },
|
||||
...(thread
|
||||
? [{ kind: "upsert", list: "active", thread: { ...thread, archived: false } } satisfies ThreadListMutation]
|
||||
: [{ kind: "refresh", list: "active" } satisfies ThreadListMutation]),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function threadById(threads: readonly Thread[] | null, threadId: string): Thread | null {
|
||||
return threads?.find((thread) => thread.id === threadId) ?? null;
|
||||
}
|
||||
ThreadCatalogWriter {}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ interface ThreadRowCoreRenameProjection {
|
|||
readonly active: boolean;
|
||||
readonly draft: string;
|
||||
readonly generating: boolean;
|
||||
readonly saving: boolean;
|
||||
readonly autoNameDisabled: boolean;
|
||||
}
|
||||
|
||||
interface ThreadRowCoreArchiveConfirmProjection {
|
||||
|
|
@ -37,6 +39,8 @@ export function threadRowCoreProjection(input: {
|
|||
active: rename !== undefined,
|
||||
draft: rename?.draft ?? threadRenameDraftTitle(input.thread),
|
||||
generating: rename?.kind === "generating",
|
||||
saving: rename?.kind === "saving",
|
||||
autoNameDisabled: rename?.autoName.kind !== "ready",
|
||||
},
|
||||
archiveConfirm: {
|
||||
active: input.archiveConfirmActive ?? false,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { type ArchiveExportSettings, type ArchiveThreadInput, archivedThreadMarkdown } from "../../../domain/threads/archive-markdown";
|
||||
import { type ArchiveMarkdownOptions, type ArchiveThreadInput, archivedThreadMarkdown } from "../../../domain/threads/archive-markdown";
|
||||
import { shortThreadId } from "../../../domain/threads/id";
|
||||
import { threadArchiveTitle } from "../../../domain/threads/title";
|
||||
import {
|
||||
|
|
@ -19,6 +19,11 @@ export interface ArchiveExportResult {
|
|||
path: string;
|
||||
}
|
||||
|
||||
export interface ArchiveExportSettings extends ArchiveMarkdownOptions {
|
||||
archiveExportFolderTemplate: string;
|
||||
archiveExportFilenameTemplate: string;
|
||||
}
|
||||
|
||||
export type ArchiveExportDestination = VaultMarkdownDestination;
|
||||
|
||||
interface TemplateContext {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
184
src/features/threads/workflows/thread-operation-coordinator.ts
Normal file
184
src/features/threads/workflows/thread-operation-coordinator.ts
Normal 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
Loading…
Reference in a new issue