mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Compare commits
No commits in common. "main" and "5.1.1" have entirely different histories.
259 changed files with 2972 additions and 5835 deletions
6
.github/release-notes/5.1.2.md
vendored
6
.github/release-notes/5.1.2.md
vendored
|
|
@ -1,6 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Thread rename and auto-name controls now prevent conflicting edits while work is in progress, and cancelling auto-name returns to the existing draft.
|
||||
- Actions with known unmet prerequisites are now disabled before execution, including unavailable auto-name and selection rewrite actions.
|
||||
- Thread operation errors no longer appear as entries in navigation lists.
|
||||
- Resumed conversations now hide Codex Panel's internal context metadata while preserving their context attachments.
|
||||
4
.github/release-notes/5.2.0.md
vendored
4
.github/release-notes/5.2.0.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Thread references created with `/refer` now remain as readable, clickable links in conversation history and open the referenced thread in an available Codex Panel.
|
||||
- Opening an existing thread in a new panel is now faster because resume no longer waits for background metadata and thread-list refreshes.
|
||||
5
.github/release-notes/5.2.1.md
vendored
5
.github/release-notes/5.2.1.md
vendored
|
|
@ -1,5 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Updated compatibility for Codex CLI 0.145.0.
|
||||
- Subagent conversations that support replies can now be continued directly from their panel.
|
||||
- Fixed malformed links in exported thread archives.
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Codex Panel brings Codex into an Obsidian sidebar. It keeps Codex threads beside your notes, helps you add vault context to prompts, and lets you handle approvals and file changes without switching windows.
|
||||
|
||||
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup.
|
||||
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup. The plugin handles the Obsidian side of the workflow, while models, credentials, sandboxing, tools, hooks, and thread state stay with Codex.
|
||||
|
||||

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