Merge branch 'main' into pr2969-integration
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: communication-style
|
||||
description: Use it always.
|
||||
author: poteto (pstack)
|
||||
---
|
||||
|
||||
# Communication style
|
||||
|
||||
Edit text to remove AI patterns and add human voice.
|
||||
|
||||
## Process
|
||||
|
||||
1. Scan for the patterns below.
|
||||
2. Rewrite. Preserve meaning, match intended tone.
|
||||
3. Add soul (see next section).
|
||||
4. Self-audit: "What makes this obviously AI generated?" Fix remaining tells.
|
||||
|
||||
## Adding soul
|
||||
|
||||
Removing patterns is half the job. Sterile, voiceless writing is just as obvious.
|
||||
|
||||
- **Have opinions.** React to facts instead of neutrally listing pros and cons.
|
||||
- **Vary rhythm.** Short sentences. Then longer ones that take their time. Mix it up.
|
||||
- **Acknowledge complexity.** "Impressive but also kind of unsettling" beats "impressive."
|
||||
- **Use "I" when it fits.** First person isn't unprofessional.
|
||||
- **Let some mess in.** Perfect structure looks machine-made.
|
||||
- **Be specific.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am."
|
||||
|
||||
## Patterns to detect and fix
|
||||
|
||||
### Content
|
||||
|
||||
1. **Puffery.** "pivotal moment", "testament to", "evolving landscape", "setting the stage for", "indelible mark", "deeply rooted". Cut puffery, state what happened.
|
||||
2. **Name-dropping.** Listing media outlets without context. Pick one, say what was said.
|
||||
3. **Superficial -ing phrases.** "highlighting...", "ensuring...", "reflecting...", "showcasing...", "fostering...". Delete or expand with real sources.
|
||||
4. **Promotional language.** "nestled", "vibrant", "breathtaking", "groundbreaking", "renowned", "stunning", "must-visit". Use neutral descriptions.
|
||||
5. **Vague attributions.** "Experts believe", "Industry reports suggest", "Some critics argue". Name the source or delete.
|
||||
6. **Formulaic challenges.** "Despite challenges... continues to thrive." Replace with specific facts.
|
||||
|
||||
### Language
|
||||
|
||||
7. **AI vocabulary.** Additionally, crucial, delve, enduring, enhance, fostering, garner, interplay, intricate, landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore, vibrant. Replace with plain words.
|
||||
8. **Fancy ways to say "is".** "serves as", "stands as", "boasts", "features". Just say "is" or "has".
|
||||
9. **"Not just X, but Y."** State the point directly instead.
|
||||
10. **Rule of three.** Forcing ideas into groups of three. Use the natural number.
|
||||
11. **Synonym cycling.** Protagonist, main character, central figure, hero all in one paragraph. Pick one, repeat it.
|
||||
12. **False ranges.** "from X to Y" where X and Y aren't on a meaningful scale. List topics directly.
|
||||
|
||||
### Style
|
||||
|
||||
13. **Em dash overuse.** Avoid em dashes entirely. Use periods or commas only (no parentheses, no en dashes, no hyphen-as-dash substitutes). Em dashes are an AI tell, and reaching for parentheses instead just trades one tell for another. If a thought needs separation, end the sentence or use a comma.
|
||||
14. **Colon overuse.** Colons are fine before a list or example. Not as mid-sentence connectors. "If you're coming from traditional automation: instead of registering event handlers, you describe conditions" adds nothing with the colon. Rewrite to let the point stand on its own without comparison framing. "Describing when the scheduler should fire works best as plain English." Same meaning, no crutch punctuation.
|
||||
15. **Boldface overuse.** Don't bold every proper noun or acronym.
|
||||
16. **Inline-header lists.** The tell is a bold label and colon that restates the line: "**Performance:** Performance improved...". Convert to prose. A bold lead-in that ends in a period, names the item, and is followed by genuinely new detail ("**Schema in TypeScript.** Tables live in one file.") is fine.
|
||||
17. **Title case headings.** Use sentence case.
|
||||
18. **Decorative emojis.** Remove from headings and bullets.
|
||||
19. **Curly quotes.** Replace with straight quotes.
|
||||
|
||||
### Communication artifacts
|
||||
|
||||
20. **Chatbot phrases.** "I hope this helps!", "Let me know if...", "Of course!", "Certainly!", "Found the smoking gun!" Remove.
|
||||
21. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove.
|
||||
22. **Sycophantic tone.** "Great question! You're absolutely right!" Respond directly.
|
||||
|
||||
### Filler
|
||||
|
||||
23. **Filler phrases.** "In order to" becomes "To". "Due to the fact that" becomes "Because". "It is important to note that" gets deleted.
|
||||
24. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may".
|
||||
25. **Generic conclusions.** "The future looks bright." State specific plans or facts.
|
||||
|
||||
### Jargon
|
||||
|
||||
26. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
|
||||
|
||||
### Plain speech
|
||||
|
||||
27. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write it. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it.
|
||||
28. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence.
|
||||
29. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter.
|
||||
30. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong.
|
||||
31. **Prefer the plain word.** "utilize" becomes "use", "leverage" becomes "use", "facilitate" becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer.
|
||||
@@ -1,12 +1,7 @@
|
||||
name: pr-review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# PR conversation comments arrive as `issue_comment` events, so their PR number
|
||||
|
||||
@@ -24,13 +24,13 @@ jobs:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
days-before-stale: 60
|
||||
days-before-stale: 28
|
||||
days-before-close: 7
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
any activity in the last 60 days. It will be closed in 7 days if no
|
||||
any activity in the last 28 days. It will be closed in 7 days if no
|
||||
further activity occurs.
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed because it has been stale for
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
reopen the issue.
|
||||
stale-pr-message: >
|
||||
This pull request has been automatically marked as stale because it has
|
||||
not had any activity in the last 60 days. It will be closed in 7 days
|
||||
not had any activity in the last 28 days. It will be closed in 7 days
|
||||
if no further activity occurs.
|
||||
close-pr-message: >
|
||||
This pull request has been automatically closed because it has been
|
||||
|
||||
@@ -68,5 +68,8 @@ data/
|
||||
workspaces/
|
||||
*.pid
|
||||
.worktrees/
|
||||
|
||||
# Marks a disposable clone dedicated to unattended maintenance tasks.
|
||||
.maintenance-clone
|
||||
test-results/
|
||||
artifacts/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode-go/deepseek-v4-flash
|
||||
model: opencode-go/mimo-v2.5
|
||||
color: "#c0392b"
|
||||
permission:
|
||||
edit: allow
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode-go/deepseek-v4-flash
|
||||
model: opencode-go/mimo-v2.5
|
||||
color: "#4f8f8f"
|
||||
permission:
|
||||
edit: deny
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode-go/deepseek-v4-flash
|
||||
model: opencode-go/mimo-v2.5
|
||||
color: "#c4920a"
|
||||
permission:
|
||||
edit: deny
|
||||
|
||||
@@ -13,7 +13,20 @@ First, verify the worktree is safe to use:
|
||||
|
||||
`git status --porcelain`
|
||||
|
||||
If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Local work in progress must never end up in a maintenance PR.
|
||||
If the output is not empty, decide which of two situations you are in.
|
||||
|
||||
If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping:
|
||||
|
||||
```
|
||||
git checkout -- .
|
||||
git clean -fd
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run.
|
||||
|
||||
If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches.
|
||||
|
||||
Then run:
|
||||
|
||||
@@ -295,7 +308,18 @@ So, before you consider a selected file done:
|
||||
- A group of findings sharing one root cause counts as one reason, and that root cause is usually worth fixing. If eleven findings in a file all come from one untyped parser, fixing that parser is the point of the batch, not a reason to skip.
|
||||
- Leaving more than roughly a quarter of a file's findings behind means you have not finished. Either finish them or explain, per group, why the file was a bad selection in the first place.
|
||||
|
||||
Skip a finding only when the fix would require unclear behavior changes, or a change so large it would stop the pull request from being reviewable. Difficulty alone is not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`.
|
||||
Skip a finding when the fix would require unclear behavior changes, when the change would be so large that the pull request stops being reviewable, or when the only way you can see to close it is one of the forbidden patterns. That last case is not a loophole, it is the required outcome: an honest skip is always better than a laundered fix, and choosing the forbidden pattern to satisfy "finish the file" is the worse failure of the two. Ordinary difficulty, on its own, is still not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`.
|
||||
|
||||
### When the whole file is an external-data boundary
|
||||
|
||||
Some files exist to receive data from outside the program: provider APIs, quota endpoints, extension host messages, configuration on disk. In such a file, most or all findings can share one root cause, and the honest fix is a real parsed boundary with named contracts, which is a substantial piece of work rather than a lint cleanup.
|
||||
|
||||
Recognize this early, before editing. Read the file first and ask whether closing its findings means designing a data contract that does not exist yet. If it does, choose one of two outcomes, and never a third:
|
||||
|
||||
- Do the work properly for a coherent part of the file: define the contract for one provider, one endpoint, or one message, parse it at its boundary, and leave the rest with a clear explanation of the remaining root cause. A correct partial fix with a named boundary is a good pull request.
|
||||
- Conclude that the file is a poor batch selection, abort per "Aborting cleanly", and say in your report that the file needs a deliberate data-contract change rather than an unattended cleanup.
|
||||
|
||||
What you must not do is invent a generic JSON contract to make the findings disappear. Generic record types, primitive unions, and `unknown`-based aliases over external data are exactly the patterns these rules exist to reject, and reintroducing them under time pressure defeats the purpose of the whole task.
|
||||
|
||||
Hard prohibitions. Each of these makes the lint output greener while making the code worse, and each is grounds for rejecting the whole PR:
|
||||
- Do not disable, downgrade, or ignore anti-slop rules, in configuration or with inline comments.
|
||||
@@ -311,6 +335,21 @@ Hard prohibitions. Each of these makes the lint output greener while making the
|
||||
- Do not edit `CHANGELOG.md`, package versions, or release metadata. This is internal maintenance with no user-facing change.
|
||||
- Do not fix findings outside the selected files.
|
||||
|
||||
## Aborting cleanly
|
||||
|
||||
You may reach a point where the batch cannot be completed correctly: validation keeps failing, or the only remaining way to close the findings is a pattern this task forbids. Stopping there is the right decision. Stopping there and walking away from a modified working copy is not.
|
||||
|
||||
Whatever edits exist in the working copy at that moment are your own, made minutes ago in this session. They are not human work in progress, and nothing is lost by removing them. Leaving them behind jams every scheduled run that follows, because those runs correctly refuse to operate on a dirty worktree.
|
||||
|
||||
So when you abort, in this order:
|
||||
|
||||
1. Revert every file you modified: `git checkout -- <paths>`, plus `git clean -fd` for files you created. Verify with `git status --porcelain` that the result is empty.
|
||||
2. Release the claim so the files return to the pool: ``bun run deslop -- release --run <run-id>``.
|
||||
3. Return to `main`.
|
||||
4. Report what you attempted, precisely why you stopped, and confirm that both the worktree is clean and the claim is released.
|
||||
|
||||
Never leave a partially fixed working copy as a message to the next run. If a file resists a correct fix, that belongs in your report, not on disk.
|
||||
|
||||
After edits, run:
|
||||
|
||||
`bun run deslop -- check-batch --run <run-id>`
|
||||
|
||||
@@ -13,7 +13,20 @@ First, verify the worktree is safe to use:
|
||||
|
||||
`git status --porcelain`
|
||||
|
||||
If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, or switch branches.
|
||||
If the output is not empty, decide which of two situations you are in.
|
||||
|
||||
If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping:
|
||||
|
||||
```
|
||||
git checkout -- .
|
||||
git clean -fd
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run.
|
||||
|
||||
If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches.
|
||||
|
||||
List the active batches:
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ Verify the worktree is clean:
|
||||
|
||||
`git status --porcelain`
|
||||
|
||||
If the output is not empty, stop immediately and report it. Do not stash, reset, or discard anything.
|
||||
If the output is not empty and the repository root contains a `.maintenance-clone` marker file, this is a disposable maintenance clone and the changes are debris from an earlier failed task. Recover it with `git checkout -- .`, `git clean -fd`, `git checkout main`, `git pull`, report exactly which files you discarded, and continue.
|
||||
|
||||
If the marker file is absent, stop immediately and report it. Do not stash, reset, or discard anything.
|
||||
|
||||
Read `AGENTS.md`, and read `.opencode/commands/as-fixes.md` in full, including the sections "What a good fix looks like" and "Hard prohibitions". Those describe the standard the anti-slop PRs were supposed to meet. Your job includes verifying they actually met it.
|
||||
|
||||
|
||||
@@ -13,7 +13,20 @@ First, verify the worktree is safe to use:
|
||||
|
||||
`git status --porcelain`
|
||||
|
||||
If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Local work in progress must never end up in a maintenance PR.
|
||||
If the output is not empty, decide which of two situations you are in.
|
||||
|
||||
If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping:
|
||||
|
||||
```
|
||||
git checkout -- .
|
||||
git clean -fd
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run.
|
||||
|
||||
If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches.
|
||||
|
||||
Then run:
|
||||
|
||||
@@ -36,10 +49,26 @@ Workflow:
|
||||
- Finish each selected file. A file is finished when it has zero React Doctor diagnostics, or when every remaining diagnostic has an individual, specific reason to stay. A half-fixed file will be selected again later and cost a second pull request, a second review, and a second merge over the same code.
|
||||
- Before considering a file done, re-run `bun run doctor -- file <path>` and read what is left. Leaving more than roughly a quarter of a file's diagnostics behind means you have not finished.
|
||||
- A group of diagnostics sharing one root cause counts as one reason, and that root cause is usually worth fixing rather than deferring.
|
||||
- Skip a diagnostic only when the fix would require unclear behavior changes, or a change so large it would stop the pull request from being reviewable. Difficulty alone is not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`.
|
||||
- Skip a diagnostic when the fix would require unclear behavior changes, when the change would be so large that the pull request stops being reviewable, or when the only way you can see to close it is a change you would not defend in review. An honest skip is always better than a forced fix. Ordinary difficulty, on its own, is still not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`.
|
||||
- If a whole selected file turns out to need a deliberate architectural change rather than a cleanup, abort per "Aborting cleanly" and report that the file was a poor batch selection.
|
||||
- Do not suppress React Doctor diagnostics unless there is a clear false positive.
|
||||
- If a listed diagnostic requires changes outside the selected files, make only the minimal required supporting change. Do not expand the cleanup scope.
|
||||
|
||||
## Aborting cleanly
|
||||
|
||||
You may reach a point where the batch cannot be completed correctly: validation keeps failing, or the only remaining way to close the findings is a pattern this task forbids. Stopping there is the right decision. Stopping there and walking away from a modified working copy is not.
|
||||
|
||||
Whatever edits exist in the working copy at that moment are your own, made minutes ago in this session. They are not human work in progress, and nothing is lost by removing them. Leaving them behind jams every scheduled run that follows, because those runs correctly refuse to operate on a dirty worktree.
|
||||
|
||||
So when you abort, in this order:
|
||||
|
||||
1. Revert every file you modified: `git checkout -- <paths>`, plus `git clean -fd` for files you created. Verify with `git status --porcelain` that the result is empty.
|
||||
2. Release the claim so the files return to the pool: ``bun run doctor -- release --run <run-id>``.
|
||||
3. Return to `main`.
|
||||
4. Report what you attempted, precisely why you stopped, and confirm that both the worktree is clean and the claim is released.
|
||||
|
||||
Never leave a partially fixed working copy as a message to the next run. If a file resists a correct fix, that belongs in your report, not on disk.
|
||||
|
||||
After edits, run:
|
||||
|
||||
`bun run doctor -- check-batch --run <run-id>`
|
||||
|
||||
@@ -13,7 +13,20 @@ First, verify the worktree is safe to use:
|
||||
|
||||
`git status --porcelain`
|
||||
|
||||
If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, or switch branches.
|
||||
If the output is not empty, decide which of two situations you are in.
|
||||
|
||||
If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping:
|
||||
|
||||
```
|
||||
git checkout -- .
|
||||
git clean -fd
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run.
|
||||
|
||||
If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches.
|
||||
|
||||
List the active batches:
|
||||
|
||||
|
||||
@@ -79,6 +79,10 @@ task-required reference named by those skills. Skills are canonical for their
|
||||
detailed workflows and checklists. Treating this table as optional advice is a
|
||||
process violation.
|
||||
|
||||
**Always load `.agents/skills/communication-style/SKILL.md` at the start of
|
||||
every task, before any analysis, tool call, or response. Apply its guidance to
|
||||
all messages and written output, not only to user-facing copy or documentation.**
|
||||
|
||||
| Trigger | Required skill |
|
||||
|---|---|
|
||||
| Source/dependency changes, exports or package contracts, build/generated assets, or module ownership | `openchamber-change-discipline` |
|
||||
|
||||
@@ -4,14 +4,99 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Chat scrolling rebuilt around your message.** Sending a message parks it near the top of the view and the reply streams into the space below it, so you read from where you asked instead of chasing the bottom. Streamed text arrives a paragraph at a time (code blocks line by line) with a soft fade, and the view glides after it in one continuous motion instead of snapping per line. Scrolling up during a stream immediately hands you the wheel — nothing yanks the view back — and the scroll-to-bottom pill appears on the left, carrying the model's working status while you're away from the live edge. Sending from anywhere mid-conversation jumps you straight to your new message, and opening a session goes straight to the newest message with no scroll animation.
|
||||
- **Chat context attachments:** everything you attach to a message — diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues and PRs — now shows up in the conversation as a compact context card: a header naming the source, the captured content behind an expander, and your comment below it. Previously most of these arrived as a wall of raw text inside your message.
|
||||
- **Faster session switching in large workspaces** (thanks @c-w-xiaohei): switching sessions no longer rebuilds the whole sidebar, returning to a recently viewed session restores its rendered messages instead of re-rendering them (file links included), and scrolling long conversations costs less. In a workspace with thousands of loaded sessions, end-to-end switch time dropped by roughly half.
|
||||
- **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab.
|
||||
- Chat: comment on a reply — select text in a chat message (or in a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note to the next message. The selection stays highlighted while you type, and the selection menu was restyled — Add to chat is now Add to input.
|
||||
- Diff: comment like a review — hovering a line shows a + button in the gutter; clicking it, clicking a line, or dragging across lines opens the comment editor for that line or range. The comment editor and saved-comment cards match the chat's comment style.
|
||||
- Composer: hovering or tapping a context chip above the input opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending.
|
||||
- Mobile: the chat comment input overlays the composer exactly and rides the keyboard; Enter makes a new line there, with attach on the button.
|
||||
- Terminal: terminals no longer vanish or die behind your back. Opening the app in another browser tab, on another device, or after a reload shows the terminals already running on the server instead of an empty list, and terminals sitting in background tabs are no longer closed by the server's idle cleanup while the app is open.
|
||||
- Search: every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). Ctrl/Cmd+P also matches the whole file path, not just the file name, and the git branch and gitmoji pickers stopped silently dropping rows a second built-in filter didn't like. Sidebar session search and the Todos/Memory/Plans/Notes filters match the same way.
|
||||
- Chat: @ file mentions rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible so identical-looking index.md rows are distinguishable.
|
||||
- Chat: a new "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns the automatic following off entirely — your message still parks at the top on send, but the view never moves on its own afterwards.
|
||||
- Mobile: narrowing a browser window past phone size switches into the mobile app layout (and back when widened) instead of squeezing the desktop layout. The old/new mobile layout setting is gone — phones always get the mobile layout.
|
||||
- Chat: streamed code blocks are syntax-highlighted while they stream, and finished messages no longer jump when a code block's line numbers fill in at the end of a reply.
|
||||
- Chat: finished replies no longer flicker — tool cards stopped re-rendering (and replaying their reveal animation) when they completed, and resizing the window no longer throws the conversation up and down while you're at the bottom.
|
||||
- Chat: clicking the last item in the prompt rail now always lands on it, and rail jumps teleport instead of a long smooth scroll that could stop halfway.
|
||||
- Mobile: scrolling during a streaming reply works again — a drag immediately takes over, the scroll-to-bottom pill shows up, and the load-older button no longer throws you to the bottom of the chat.
|
||||
- Fixed file links in messages being checked twice against the filesystem, and against the wrong project directory on the first pass.
|
||||
- Fixed the selected project or session briefly jumping back to a previous choice when settings responses arrived out of order.
|
||||
- Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android.
|
||||
- Desktop: a freshly installed or updated build no longer keeps loading the previous version's interface from cache.
|
||||
- Devices: re-pairing a phone (or logging in again) keeps the device's existing name in Connected Devices instead of resetting it to "OpenChamber Mobile".
|
||||
- Relay: paired devices no longer get logged out when the app restarts (for example during an update) while another local OpenChamber process is running — the restarted app keeps serving them instead of a bystander process taking over.
|
||||
- Sessions: headers now find archived sessions too, so an archived session's title no longer goes missing.
|
||||
- Files: the editor toolbar is now always docked under the file tabs; the floating hover toolbar and its setting were removed.
|
||||
- UI: the chat's top and bottom scroll fades are back, and the first uncached open of a session fades the conversation in instead of popping.
|
||||
- UI: the timeline dialog now fits small screens instead of squeezing the message list to a couple of rows (thanks to @gaojunran).
|
||||
- Chat: OpenCode notices now share one style.
|
||||
- UI: draft target menus stay inside the chat area instead of overlapping the header.
|
||||
- UI: Linear and Cloudflare tools now show their own icons.
|
||||
- UI: sidebar item tooltips no longer appear instantly on passing hover.
|
||||
- UI: the btw panel's shadow is lighter, matching the composer.
|
||||
|
||||
## [1.20.0] - 2026-08-23
|
||||
|
||||
- **Session: /btw side questions.** Type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17).
|
||||
- **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context.
|
||||
- **Desktop/Remote instances:** adding an SSH connection now starts from the hosts in your SSH config instead of a blank command field. Ports, install method and passwords moved behind Advanced settings, and each connection shows Connected, Connecting, or Needs attention with the failure text and a button that resolves it.
|
||||
- Desktop/Remote instances: connecting to a remote machine now works when bun, OpenChamber or the opencode CLI live in your home directory rather than on the system path. Installing no longer fails with a permission error, and a missing opencode CLI is now reported before the connection starts instead of as a stack trace.
|
||||
- Desktop/Remote instances: a managed remote server can now also be published to the remote machine's own network, so other devices there reach it without the SSH tunnel. It requires a UI password, and stays private to the tunnel otherwise.
|
||||
- Desktop/Remote instances: disconnecting from a connection set to not keep the server running now actually stops that remote server.
|
||||
- Skills catalog: browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository.
|
||||
- Diff: the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not.
|
||||
- Dictation: speech is now transcribed after you stop recording. The composer shows a live waveform and timer, and long recordings split at pauses instead of cutting words.
|
||||
- Settings: the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it.
|
||||
- Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. Both sit in one Defaults for new chats group, laid out like the Sessions defaults.
|
||||
- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately.
|
||||
- Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them.
|
||||
- Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed.
|
||||
- Chat: file paths in messages now open from the session's project, even if you last browsed files in another project (thanks to @tomzx).
|
||||
- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings.
|
||||
- Files/Desktop: files opened from outside the workspace remain readable after their temporary access expires instead of failing until you reopen them (thanks to @pascalandr).
|
||||
- Diff: creating an inline comment now opens the chat and focuses the composer for your follow-up.
|
||||
- Chat: in the expanded composer, Enter now starts a new line and Cmd/Ctrl+Enter sends, so a long prompt is harder to send by accident.
|
||||
- Providers: expanded support for custom providers.
|
||||
- Small Model: summaries, goal audits, commit messages, and walkthroughs now support more providers.
|
||||
- Git: generated commit messages now match the repository's recent commit style and language.
|
||||
- Git: generating a pull request description now picks up the repository's own PR template when it has one, so the draft comes back in your project's sections and checklists instead of the built-in Summary/Why/Testing layout.
|
||||
- Sidebar: switch between the full project list and a focused view of one project. Sessions created outside OpenChamber now also appear in the sidebar and Recent list without a page refresh (thanks to @tomzx).
|
||||
- Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117).
|
||||
- Chat: while a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes.
|
||||
- Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off.
|
||||
- Chat: long user messages can be expanded even when their final layout finishes after they first appear.
|
||||
- Chat: in a chat without a project, the work status card again steps aside when the context panel is open, instead of sitting next to it.
|
||||
- Usage: Z.ai credit limits now appear alongside its other quota windows.
|
||||
- Git: pull-request checks in Work status stay current as their status changes.
|
||||
- UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer).
|
||||
- Desktop/Windows: the close button now aligns correctly with the rest of the window chrome.
|
||||
|
||||
## [1.19.0] - 2026-08-19
|
||||
|
||||
- **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders.
|
||||
- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans can be pinned as context.
|
||||
- **Files:** drag files onto the Files sidebar to upload them into the project or a specific folder; existing files require confirmation before replacement, and open previews refresh after an upload (thanks to @makeittech, @alanzchen).
|
||||
- Settings: OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech).
|
||||
- Chat: an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech).
|
||||
- Stability/Proxy: the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja).
|
||||
- Usage/Claude: Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes.
|
||||
- Usage/Command Code: Command Code plan limits now appear in the Usage page and work status panel.
|
||||
- Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech).
|
||||
- Git/Worktrees: creating a worktree from a pull request now falls back to GitHub's pull-request reference when the source fork was deleted or cannot be reached, instead of failing before creating the worktree (thanks to @makeittech).
|
||||
- Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting.
|
||||
- Chat: typing with Chinese, Japanese, or Korean input methods no longer interrupts composition or jumps the cursor to the end of the composer (thanks to @makeittech).
|
||||
- Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech).
|
||||
- Chat: saved chats in the context panel open again instead of staying blank.
|
||||
- Chat: the context meter no longer climbs over 100% (330% readouts) after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds, everywhere the value appears — header, context sidebar, work status panel, mini chat, and mobile (thanks to @pocharlies).
|
||||
- Chat/Attachments: extracted Office and OpenDocument content is now capped and presented more compactly, preventing large documents and their images from overwhelming the message context.
|
||||
- Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude` in the sidebar, window title, settings and notifications; names you renamed yourself are kept.
|
||||
- Files: files reached through a symlink inside the workspace now open correctly instead of being rejected as outside the workspace.
|
||||
- Settings: the session retention action you pick is now saved instead of being dropped (thanks to @Gautam0507).
|
||||
- Mobile: connecting through an ngrok address now bypasses ngrok's browser warning page instead of failing the server check.
|
||||
- Mobile/iOS: text selection in the chat composer now uses native CodeMirror selection handles.
|
||||
- Desktop: browser pages served from a self-signed loopback HTTPS address now load instead of being blocked by the certificate warning.
|
||||
- Browser: typing a comment on a page no longer triggers app shortcuts.
|
||||
- Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech).
|
||||
|
||||
|
||||
+11
-1
@@ -204,7 +204,7 @@ state why it remains valid. If there is genuinely no user-visible change, say
|
||||
so and provide a concrete reason; deleting the evidence section is not an
|
||||
exemption.
|
||||
|
||||
### Review Enforcement
|
||||
### Review enforcement
|
||||
|
||||
The automated reviewer performs one unified review of correctness, repository
|
||||
guidance compliance, pull request quality, and evidence. It independently
|
||||
@@ -238,6 +238,16 @@ verify a trustworthy result, in which case it applies `review:automation-failed`
|
||||
Each completed review creates a new comment tied to its reviewed HEAD so the
|
||||
conversation remains chronological. Previous review comments are not rewritten.
|
||||
|
||||
### Keeping PRs active
|
||||
|
||||
Stale PRs add review load and make it hard to tell what's still being worked on, so the stale bot keeps the open list current. A PR with no activity for 28 days is automatically labeled `stale`, and closed 7 days later if it stays inactive. To keep a PR open:
|
||||
|
||||
- Push updates or respond to review feedback
|
||||
- Leave a comment if you're waiting on a reviewer
|
||||
- Add the `pinned`, `security`, or `help wanted` label to exempt a long-running PR from the stale bot
|
||||
|
||||
Reopening a closed PR is fine if it becomes relevant again.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -6,31 +6,31 @@
|
||||
"name": "openchamber-monorepo",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.11.0",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "6.39.13",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "^6.9.7",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"@heroui/scroll-shadow": "^2.3.18",
|
||||
"@heroui/system": "^2.4.23",
|
||||
"@heroui/theme": "^2.4.23",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -97,7 +97,7 @@
|
||||
},
|
||||
"packages/electron": {
|
||||
"name": "@openchamber/electron",
|
||||
"version": "1.18.4",
|
||||
"version": "1.20.0",
|
||||
"dependencies": {
|
||||
"@openchamber/web": "workspace:*",
|
||||
"electron-context-menu": "^4.1.2",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.18.4",
|
||||
"version": "1.20.0",
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
"@base-ui/react": "^1.4.0",
|
||||
@@ -143,34 +143,35 @@
|
||||
"@capacitor/keyboard": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.1.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.11.0",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "6.39.13",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/lint": "^6.9.7",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@legendapp/list": "3.3.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"@pierre/diffs": "1.3.0-beta.6",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vim": "^6.4.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@tanstack/react-virtual": "3.14.5",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
@@ -227,6 +228,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"globals": "^16.3.0",
|
||||
"happy-dom": "^18.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
@@ -238,10 +240,10 @@
|
||||
},
|
||||
"packages/vscode": {
|
||||
"name": "openchamber",
|
||||
"version": "1.18.4",
|
||||
"version": "1.20.0",
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"adm-zip": "^0.6.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"react": "^19.1.1",
|
||||
@@ -261,16 +263,15 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@openchamber/web",
|
||||
"version": "1.18.4",
|
||||
"version": "1.20.0",
|
||||
"bin": {
|
||||
"openchamber": "./bin/cli.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"adm-zip": "^0.6.0",
|
||||
"bun-pty": "^0.4.5",
|
||||
"compression": "^1.8.1",
|
||||
"cron-parser": "^4.9.0",
|
||||
@@ -306,7 +307,6 @@
|
||||
"@remixicon/react": "^4.7.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
@@ -355,8 +355,18 @@
|
||||
"bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/view": "6.39.13",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.0",
|
||||
"@codemirror/lang-html": "6.4.12",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-markdown": "6.5.2",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "6.5.3",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
},
|
||||
"packages": {
|
||||
"7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
|
||||
@@ -603,9 +613,9 @@
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="],
|
||||
|
||||
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg=="],
|
||||
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
|
||||
|
||||
"@codemirror/commands": ["@codemirror/commands@6.10.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ=="],
|
||||
"@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="],
|
||||
|
||||
"@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="],
|
||||
|
||||
@@ -615,11 +625,11 @@
|
||||
|
||||
"@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="],
|
||||
|
||||
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="],
|
||||
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="],
|
||||
|
||||
"@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="],
|
||||
|
||||
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.4", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA=="],
|
||||
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
|
||||
|
||||
"@codemirror/lang-jinja": ["@codemirror/lang-jinja@6.0.0", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.4.0" } }, "sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw=="],
|
||||
|
||||
@@ -629,7 +639,7 @@
|
||||
|
||||
"@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.3.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw=="],
|
||||
|
||||
"@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="],
|
||||
"@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw=="],
|
||||
|
||||
"@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="],
|
||||
|
||||
@@ -647,21 +657,21 @@
|
||||
|
||||
"@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="],
|
||||
|
||||
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw=="],
|
||||
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="],
|
||||
|
||||
"@codemirror/language": ["@codemirror/language@6.12.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg=="],
|
||||
"@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="],
|
||||
|
||||
"@codemirror/language-data": ["@codemirror/language-data@6.5.2", "", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="],
|
||||
|
||||
"@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.2", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q=="],
|
||||
"@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.3", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg=="],
|
||||
|
||||
"@codemirror/lint": ["@codemirror/lint@6.9.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw=="],
|
||||
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
|
||||
|
||||
"@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="],
|
||||
"@codemirror/search": ["@codemirror/search@6.7.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="],
|
||||
|
||||
"@codemirror/state": ["@codemirror/state@6.5.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw=="],
|
||||
"@codemirror/state": ["@codemirror/state@6.7.1", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A=="],
|
||||
|
||||
"@codemirror/view": ["@codemirror/view@6.39.13", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw=="],
|
||||
"@codemirror/view": ["@codemirror/view@6.43.9", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw=="],
|
||||
|
||||
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
|
||||
|
||||
@@ -909,6 +919,8 @@
|
||||
|
||||
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
|
||||
|
||||
"@legendapp/list": ["@legendapp/list@3.3.8", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-GM4Hca/6WDvcY33XXCieR9MaG9CoZmACzwqQwRhKFSaNKfQV1lTLiTOpWuA9wnE8n8+6WeA52DwNKC9yrnPPeg=="],
|
||||
|
||||
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
||||
|
||||
"@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="],
|
||||
@@ -995,7 +1007,7 @@
|
||||
|
||||
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.18", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA=="],
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.21", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="],
|
||||
|
||||
@@ -1191,7 +1203,9 @@
|
||||
|
||||
"@remixicon/react": ["@remixicon/react@4.9.0", "", { "peerDependencies": { "react": ">=18.2.0" } }, "sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q=="],
|
||||
|
||||
"@replit/codemirror-vim": ["@replit/codemirror-vim@6.3.0", "", { "peerDependencies": { "@codemirror/commands": "6.x.x", "@codemirror/language": "6.x.x", "@codemirror/search": "6.x.x", "@codemirror/state": "6.x.x", "@codemirror/view": "6.x.x" } }, "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ=="],
|
||||
"@replit/codemirror-vim": ["@replit/codemirror-vim@6.4.0", "", { "dependencies": { "@replit/codemirror-vim-core": "^0.1.0" }, "peerDependencies": { "@codemirror/commands": "6.x.x", "@codemirror/language": "6.x.x", "@codemirror/search": "6.x.x", "@codemirror/state": "6.x.x", "@codemirror/view": "6.x.x" } }, "sha512-t9UMDNhkmeAkl0uRbiJVotv97bGD6mf4GyJJoEbrjUqa/Pov0s9eL+4AaI0Xto3OGri17i9qwIpT0JbVlVXt8A=="],
|
||||
|
||||
"@replit/codemirror-vim-core": ["@replit/codemirror-vim-core@0.1.0", "", {}, "sha512-1i6EBKpcNfDKvTmTh6N6g9lL6udD5t+uFNh4JCqozRnVlvUGOps7h/QzS2ne4zcvPUjvApKpmcP7Grc3fNbZiQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
|
||||
|
||||
@@ -1441,6 +1455,8 @@
|
||||
|
||||
"@types/vscode": ["@types/vscode@1.109.0", "", {}, "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw=="],
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="],
|
||||
@@ -2129,6 +2145,8 @@
|
||||
|
||||
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||
|
||||
"happy-dom": ["happy-dom@18.0.1", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
@@ -3341,7 +3359,7 @@
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
@@ -3579,6 +3597,8 @@
|
||||
|
||||
"cheerio/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
|
||||
|
||||
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
@@ -3619,6 +3639,8 @@
|
||||
|
||||
"globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
|
||||
|
||||
"happy-dom/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
|
||||
|
||||
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||
@@ -3805,6 +3827,8 @@
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
||||
|
||||
"micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
+25
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openchamber-monorepo",
|
||||
"version": "1.18.4",
|
||||
"version": "1.20.0",
|
||||
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -91,31 +91,31 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.11.0",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "6.39.13",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "^6.9.7",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"@heroui/scroll-shadow": "^2.3.18",
|
||||
"@heroui/system": "^2.4.23",
|
||||
"@heroui/theme": "^2.4.23",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -148,8 +148,18 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/view": "6.39.13"
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.0",
|
||||
"@codemirror/lang-html": "6.4.12",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-markdown": "6.5.2",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "6.5.3",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Integrationen
|
||||
description: Nutze dein Claude- oder Cursor-Abo als Provider.
|
||||
---
|
||||
|
||||
# Integrationen
|
||||
|
||||
Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufügt — auf Basis eines Abos, das du bereits hast. Verwalten kannst du sie unter **Settings → Integrations**.
|
||||
|
||||
> **Experimentelle Funktion.** Wir bemühen uns, die Richtlinien der Anbieter zu respektieren, aber Kontobeschränkungen und Sperrungen liegen bei jedem Anbieter. Nutze Integrationen auf eigenes Risiko.
|
||||
|
||||
Verfügbare Integrationen:
|
||||
|
||||
- **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys
|
||||
- **Cursor** — die Modell-Limits deines Cursor-Plans
|
||||
|
||||
## Integration installieren
|
||||
|
||||
1. Öffne **Settings → Integrations**.
|
||||
2. Suche die Integration und wähle **Install**.
|
||||
3. Starte OpenCode neu, wenn darum gebeten wird — der Provider erscheint nach dem Neustart.
|
||||
4. Wähle **Set up** und melde dich an. Die Modelle erscheinen danach in der Modellauswahl im Chat.
|
||||
|
||||
Integrationen werden für deinen Benutzer installiert und funktionieren damit in jedem Projekt. Aktualisieren oder entfernen kannst du sie jederzeit über dieselbe Karte.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code nutzt deinen Claude Pro- oder Max-Plan — ohne API-Keys und ohne separate Claude-App.
|
||||
|
||||
1. Installiere die Integration (siehe oben).
|
||||
2. Wähle **Set up** und melde dich an. Wenn du die Claude Code CLI noch nicht hast, bietet die Einrichtung an, sie zuerst zu installieren, und meldet dich danach an.
|
||||
|
||||
Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Cursor braucht seine CLI nicht.
|
||||
|
||||
**Wie dein Claude-Konto geschützt bleibt:** Diese Integration nutzt das offizielle Claude Agent SDK von Anthropic und deine installierte Claude Code CLI. Sie kapert kein OAuth, extrahiert oder wiederholt keine Browser-Tokens, gibt sich nicht als nicht unterstützter Client aus und umgeht nicht Anthropics Authentifizierung. Sie bleibt auf dem von Anthropic unterstützten Zugriffsweg und trägt daher nicht das mit Token-Hijacking oder unautorisierten Authentifizierungsumgehungen verbundene Sperrrisiko.
|
||||
|
||||
## Cursor
|
||||
|
||||
Cursor macht die Modelle deines Cursor-Plans in OpenChamber nutzbar.
|
||||
|
||||
1. Installiere die Integration (siehe oben).
|
||||
2. Wähle **Set up**, öffne den Link und genehmige den Zugriff im Browser. Kein API-Key nötig. Die Modellliste lädt nach der Anmeldung automatisch.
|
||||
|
||||
## Aktualisieren oder entfernen
|
||||
|
||||
- **Update** installiert die neueste veröffentlichte Version des Plugins.
|
||||
- **Remove** löscht das Plugin aus deiner OpenCode-Konfiguration. Der Provider wird beim nächsten Neuladen von OpenCode nicht mehr geladen.
|
||||
|
||||
Wenn eine Karte meldet, dass Einträge manuell verwaltet werden müssen, wähle **Manage plugins** und bereinige die Duplikate dort.
|
||||
|
||||
## Verwandtes
|
||||
|
||||
- [Anbieter, Modelle und Agenten](/de/providers/) — weitere Provider verbinden und Modelle wählen
|
||||
- [Nutzung und Kontingente](/de/usage/) — verfolge, wie viel du genutzt hast
|
||||
@@ -45,5 +45,6 @@ Provider-Anmeldungen werden von OpenCode gespeichert, nicht von OpenChamber, dah
|
||||
|
||||
## Weiterführend
|
||||
|
||||
- [Integrationen](/integrations/) — nutze ein Claude-, Command-Code- oder Cursor-Abo als Provider
|
||||
- [MCP-Server](/mcp/) — füge Agents zusätzliche Werkzeuge hinzu
|
||||
- [Nutzung & Kontingente](/usage/) — verfolge, wie viel du verbraucht hast
|
||||
|
||||
@@ -12,7 +12,7 @@ Zum Schreiben eigener Skills siehe [Skills](/skills/).
|
||||
## Einen Skill installieren
|
||||
|
||||
1. Öffne den Katalog.
|
||||
2. Durchsuche die eingebauten Quellen — das Anthropic-Skills-Repo und die ClawHub-Community-Registry — oder nutze die Suche.
|
||||
2. Durchsuche die eingebauten Quellen — wie das Anthropic-Skills-Repo — oder nutze die Suche.
|
||||
3. Wähle einen Skill aus und installiere ihn.
|
||||
4. Entscheide, wo er installiert werden soll: für alles, was du tust, oder nur für das aktuelle Projekt.
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Integraciones
|
||||
description: Usa tu suscripción de Claude o Cursor como proveedor.
|
||||
---
|
||||
|
||||
# Integraciones
|
||||
|
||||
Una integración es un pequeño plugin que añade un proveedor a OpenChamber usando una suscripción que ya tienes. Las gestionas en **Settings → Integrations**.
|
||||
|
||||
> **Función experimental.** Buscamos respetar las políticas de los proveedores, pero las restricciones y suspensiones de cuentas son decisión de cada proveedor. Usa las integraciones bajo tu propia responsabilidad.
|
||||
|
||||
Integraciones disponibles:
|
||||
|
||||
- **Claude Code** — tu plan Claude Pro o Max, sin claves de API
|
||||
- **Cursor** — los límites de modelos de tu plan de Cursor
|
||||
|
||||
## Instalar una integración
|
||||
|
||||
1. Abre **Settings → Integrations**.
|
||||
2. Busca la integración y elige **Install**.
|
||||
3. Reinicia OpenCode cuando se te pida — el proveedor aparece tras el reinicio.
|
||||
4. Elige **Set up** e inicia sesión. Los modelos aparecerán luego en el selector de modelos del chat.
|
||||
|
||||
Las integraciones se instalan para tu usuario, así que funcionan en todos los proyectos. Puedes actualizarlas o eliminarlas desde la misma tarjeta en cualquier momento.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code usa tu plan Claude Pro o Max — sin claves de API y sin una app de Claude aparte.
|
||||
|
||||
1. Instala la integración (arriba).
|
||||
2. Elige **Set up** e inicia sesión. Si aún no tienes la CLI de Claude Code, la configuración ofrece instalarla primero y luego iniciar sesión.
|
||||
|
||||
Claude Code es la única integración de esta página que requiere tener la CLI de su proveedor instalada y con sesión iniciada. Cursor no requiere su CLI.
|
||||
|
||||
**Cómo se protege tu cuenta de Claude:** esta integración usa el Claude Agent SDK oficial de Anthropic y tu CLI de Claude Code instalada. No secuestra OAuth, no extrae ni reutiliza tokens del navegador, no se hace pasar por un cliente no admitido ni omite la autenticación de Anthropic. Se mantiene en la vía de acceso admitida por Anthropic, por lo que no conlleva el riesgo de baneo asociado al secuestro de tokens o a rodeos de autenticación no autorizados.
|
||||
|
||||
## Cursor
|
||||
|
||||
Cursor hace disponibles en OpenChamber los modelos incluidos en tu plan de Cursor.
|
||||
|
||||
1. Instala la integración (arriba).
|
||||
2. Elige **Set up**, abre el enlace y autoriza el acceso en tu navegador. No necesitas clave de API. La lista de modelos se carga automáticamente tras iniciar sesión.
|
||||
|
||||
## Actualizar o eliminar
|
||||
|
||||
- **Update** instala la última versión publicada del plugin.
|
||||
- **Remove** elimina el plugin de tu configuración de OpenCode. El proveedor deja de cargarse cuando OpenCode se recarga.
|
||||
|
||||
Si una tarjeta indica que las entradas requieren gestión manual, elige **Manage plugins** y limpia ahí los duplicados.
|
||||
|
||||
## Relacionado
|
||||
|
||||
- [Proveedores, modelos y agentes](/es/providers/) — conecta otros proveedores y elige modelos
|
||||
- [Uso y cuotas](/es/usage/) — sigue cuánto has usado
|
||||
@@ -45,5 +45,6 @@ Los inicios de sesión de los proveedores los guarda OpenCode, no OpenChamber, a
|
||||
|
||||
## Relacionado
|
||||
|
||||
- [Integraciones](/es/integrations/) — usa una suscripción de Claude o Cursor como proveedor
|
||||
- [Servidores MCP](/es/mcp/) — añade herramientas extra para los agentes
|
||||
- [Uso y cuotas](/es/usage/) — controla cuánto has consumido
|
||||
|
||||
@@ -12,7 +12,7 @@ Para escribir tus propias skills, consulta [Skills](/es/skills/).
|
||||
## Instala una skill
|
||||
|
||||
1. Abre el catálogo.
|
||||
2. Explora las fuentes integradas —el repositorio de skills de Anthropic y el registro comunitario de ClawHub— o busca.
|
||||
2. Explora las fuentes integradas —como el repositorio de skills de Anthropic— o busca.
|
||||
3. Elige una skill e instálala.
|
||||
4. Elige dónde instalarla: para todo lo que hagas, o solo en el proyecto actual.
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Intégrations
|
||||
description: Utilise ton abonnement Claude ou Cursor comme fournisseur.
|
||||
---
|
||||
|
||||
# Intégrations
|
||||
|
||||
Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à partir d'un abonnement que tu possèdes déjà. Tu les gères dans **Settings → Integrations**.
|
||||
|
||||
> **Fonctionnalité expérimentale.** Nous cherchons à respecter les règles des fournisseurs, mais les restrictions et suspensions de compte relèvent de leur décision. Utilise les intégrations à tes risques.
|
||||
|
||||
Intégrations disponibles :
|
||||
|
||||
- **Claude Code** — ton plan Claude Pro ou Max, sans clés API
|
||||
- **Cursor** — les limites de modèles de ton plan Cursor
|
||||
|
||||
## Installer une intégration
|
||||
|
||||
1. Ouvre **Settings → Integrations**.
|
||||
2. Trouve l'intégration et choisis **Install**.
|
||||
3. Redémarre OpenCode quand c'est demandé — le fournisseur apparaît après le redémarrage.
|
||||
4. Choisis **Set up** et connecte-toi. Les modèles apparaissent ensuite dans le sélecteur de modèles du chat.
|
||||
|
||||
Les intégrations s'installent pour ton utilisateur et fonctionnent donc dans tous les projets. Tu peux les mettre à jour ou les retirer à tout moment depuis la même carte.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code utilise ton plan Claude Pro ou Max — sans clés API et sans application Claude séparée.
|
||||
|
||||
1. Installe l'intégration (ci-dessus).
|
||||
2. Choisis **Set up** et connecte-toi. Si tu n'as pas encore la CLI Claude Code, la configuration propose de l'installer d'abord, puis de te connecter.
|
||||
|
||||
Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Cursor n'exige pas sa CLI.
|
||||
|
||||
**Comment ton compte Claude reste protégé :** cette intégration utilise le Claude Agent SDK officiel d'Anthropic et ta CLI Claude Code installée. Elle ne détourne pas l'OAuth, n'extrait ni rejoue de tokens de navigateur, ne se fait pas passer pour un client non pris en charge et ne contourne pas l'authentification d'Anthropic. Elle reste sur la voie d'accès prise en charge par Anthropic et ne porte donc pas le risque de bannissement associé au détournement de tokens ou aux contournements d'authentification non autorisés.
|
||||
|
||||
## Cursor
|
||||
|
||||
Cursor rend disponibles dans OpenChamber les modèles inclus dans ton plan Cursor.
|
||||
|
||||
1. Installe l'intégration (ci-dessus).
|
||||
2. Choisis **Set up**, ouvre le lien et autorise l'accès dans ton navigateur. Aucune clé API n'est nécessaire. La liste des modèles se charge automatiquement après la connexion.
|
||||
|
||||
## Mettre à jour ou retirer
|
||||
|
||||
- **Update** installe la dernière version publiée du plugin.
|
||||
- **Remove** supprime le plugin de ta configuration OpenCode. Le fournisseur cesse d'être chargé au prochain rechargement d'OpenCode.
|
||||
|
||||
Si une carte indique que les entrées nécessitent une gestion manuelle, choisis **Manage plugins** et nettoie les doublons à cet endroit.
|
||||
|
||||
## À voir aussi
|
||||
|
||||
- [Fournisseurs, modèles et agents](/fr/providers/) — connecter d'autres fournisseurs et choisir des modèles
|
||||
- [Utilisation et quotas](/fr/usage/) — suis ta consommation
|
||||
@@ -45,5 +45,6 @@ Les connexions aux fournisseurs sont stockées par OpenCode, pas OpenChamber ; e
|
||||
|
||||
## Pages liées
|
||||
|
||||
- [Intégrations](/integrations/) — utiliser un abonnement Claude ou Cursor comme fournisseur
|
||||
- [Serveurs MCP](/mcp/) — ajouter des outils supplémentaires aux agents
|
||||
- [Utilisation et quotas](/usage/) — suivre votre consommation
|
||||
|
||||
@@ -12,7 +12,7 @@ Pour écrire vos propres skills, voir [Skills](/skills/).
|
||||
## Installer un skill
|
||||
|
||||
1. Ouvrez le catalogue.
|
||||
2. Parcourez les sources intégrées — le dépôt de skills Anthropic et le registre communautaire ClawHub — ou lancez une recherche.
|
||||
2. Parcourez les sources intégrées — comme le dépôt de skills Anthropic — ou lancez une recherche.
|
||||
3. Choisissez un skill et installez-le.
|
||||
4. Choisissez où l’installer : pour tout ce que vous faites, ou seulement pour le projet actuel.
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Integrations
|
||||
description: Use your Claude or Cursor subscription as a provider.
|
||||
---
|
||||
|
||||
# Integrations
|
||||
|
||||
An integration is a small plugin that adds a provider to OpenChamber using a subscription you already have. You manage them at **Settings → Integrations**.
|
||||
|
||||
> **Experimental feature.** We aim to respect provider policies, but account restrictions and suspensions remain each provider's decision. Use integrations at your own risk.
|
||||
|
||||
Available integrations:
|
||||
|
||||
- **Claude Code** — your Claude Pro or Max plan, no API keys
|
||||
- **Cursor** — the model limits of your Cursor plan
|
||||
|
||||
## Install an integration
|
||||
|
||||
1. Open **Settings → Integrations**.
|
||||
2. Find the integration and choose **Install**.
|
||||
3. Restart OpenCode when asked — the provider appears after the restart.
|
||||
4. Choose **Set up** and sign in. The models then appear in the chat model picker.
|
||||
|
||||
Integrations install for your user, so they work in every project. You can update or remove them from the same card at any time.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code uses your Claude Pro or Max plan — no API keys and no separate Claude app.
|
||||
|
||||
1. Install the integration (above).
|
||||
2. Choose **Set up** and sign in. If you don't have the Claude Code CLI yet, setup offers to install it first and then sign you in.
|
||||
|
||||
Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Cursor does not require its CLI.
|
||||
|
||||
**How your Claude account stays safe:** this integration uses Anthropic's official Claude Agent SDK and your installed Claude Code CLI. It does not hijack OAuth, extract or replay browser tokens, impersonate an unsupported client, or bypass Anthropic's authentication flow. It stays on Anthropic's supported access path, so it does not carry the account-ban risk of token hijacking or unauthorized authentication workarounds.
|
||||
|
||||
## Cursor
|
||||
|
||||
Cursor makes the models included in your Cursor plan available in OpenChamber.
|
||||
|
||||
1. Install the integration (above).
|
||||
2. Choose **Set up**, open the link, and approve access in your browser. No API key is required. The model list loads automatically after you sign in.
|
||||
|
||||
## Update or remove
|
||||
|
||||
- **Update** installs the latest published version of the plugin.
|
||||
- **Remove** deletes the plugin from your OpenCode config. The provider stops loading after OpenCode refreshes.
|
||||
|
||||
If a card says the entries need manual management, choose **Manage plugins** and clean up the duplicates there.
|
||||
|
||||
## Related
|
||||
|
||||
- [Providers, Models & Agents](/providers/) — connect other providers and pick models
|
||||
- [Usage & Quotas](/usage/) — track how much you've used
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: 統合機能
|
||||
description: Claude または Cursor のサブスクリプションをプロバイダーとして使う。
|
||||
---
|
||||
|
||||
# 統合機能
|
||||
|
||||
統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。
|
||||
|
||||
> **実験的な機能。** プロバイダーの方針を尊重するよう努めていますが、アカウントの制限や停止は各プロバイダーの判断に委ねられます。自己責任で連携を使用してください。
|
||||
|
||||
利用できる統合機能:
|
||||
|
||||
- **Claude Code** — Claude Pro または Max プラン、API キー不要
|
||||
- **Cursor** — Cursor プランのモデル利用枠
|
||||
|
||||
## 統合機能をインストールする
|
||||
|
||||
1. **Settings → Integrations** を開きます。
|
||||
2. 統合機能を見つけて **Install** を選びます。
|
||||
3. 求められたら OpenCode を再起動します — 再起動後にプロバイダーが現れます。
|
||||
4. **Set up** を選んでサインインします。するとチャットのモデル選択にモデルが表示されます。
|
||||
|
||||
統合機能はユーザー単位でインストールされるため、すべてのプロジェクトで使えます。同じカードからいつでも更新や削除ができます。
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code は Claude Pro または Max プランを使います — API キーも Claude アプリも不要です。
|
||||
|
||||
1. 統合機能をインストールします(上記)。
|
||||
2. **Set up** を選んでサインインします。Claude Code CLI がまだない場合は、セットアップがまずインストールを提案し、その後サインインします。
|
||||
|
||||
Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Cursor は CLI を必要としません。
|
||||
|
||||
**Claude アカウントが守られる仕組み:** この統合機能は Anthropic の公式 Claude Agent SDK と、インストール済みの Claude Code CLI を使用します。OAuth の乗っ取り、ブラウザートークンの抽出や再生、未対応クライアントへの偽装、Anthropic の認証フローの回避は一切行いません。Anthropic がサポートする正規のアクセス経路を使うため、トークン乗っ取りや不正な認証の回避につきもののアカウント停止リスクはありません。
|
||||
|
||||
## Cursor
|
||||
|
||||
Cursor は Cursor プランに含まれるモデルを OpenChamber で使えるようにします。
|
||||
|
||||
1. 統合機能をインストールします(上記)。
|
||||
2. **Set up** を選び、リンクを開いてブラウザーでアクセスを許可します。API キーは不要です。サインイン後、モデル一覧は自動的に読み込まれます。
|
||||
|
||||
## 更新と削除
|
||||
|
||||
- **Update** はプラグインの最新の公開バージョンをインストールします。
|
||||
- **Remove** は OpenCode の設定からプラグインを削除します。OpenCode が再読み込みされるとプロバイダーは読み込まれなくなります。
|
||||
|
||||
カードに手動での管理が必要だと表示された場合は、**Manage plugins** を選んで重複を整理してください。
|
||||
|
||||
## 関連情報
|
||||
|
||||
- [プロバイダー、モデル、エージェント](/ja/providers/) — 他のプロバイダーの接続とモデルの選択
|
||||
- [使用量とクォータ](/ja/usage/) — 利用量を追跡
|
||||
@@ -45,5 +45,6 @@ OpenChamber が何かを行うには、少なくとも 1 つの AI プロバイ
|
||||
|
||||
## 関連
|
||||
|
||||
- [統合機能](/integrations/) — Claude または Cursor のサブスクリプションをプロバイダーとして使う
|
||||
- [MCP サーバー](/mcp/) — エージェントに追加ツールを加える
|
||||
- [使用量とクォータ](/usage/) — 使った量を追跡する
|
||||
|
||||
@@ -12,7 +12,7 @@ Skills Catalog では、自分で書く代わりに、他の人が公開した
|
||||
## スキルをインストールする
|
||||
|
||||
1. カタログを開きます。
|
||||
2. 組み込みソース(Anthropic skills repo と ClawHub community registry)を閲覧するか、検索します。
|
||||
2. 組み込みソース(Anthropic skills repo など)を閲覧するか、検索します。
|
||||
3. スキルを選び、インストールします。
|
||||
4. インストール先を選びます。すべての作業で使うか、現在のプロジェクトだけで使うかです。
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: 통합 기능
|
||||
description: Claude 또는 Cursor 구독을 공급자로 사용하세요.
|
||||
---
|
||||
|
||||
# 통합 기능
|
||||
|
||||
통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다.
|
||||
|
||||
> **실험 단계 기능.** 프로바이더 정책을 존중하려 노력하지만, 계정 제한과 정지는 각 프로바이더의 결정입니다. 본인의 책임 아래 통합 기능을 사용하세요.
|
||||
|
||||
사용 가능한 통합 기능:
|
||||
|
||||
- **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요
|
||||
- **Cursor** — Cursor 플랜의 모델 한도
|
||||
|
||||
## 통합 기능 설치
|
||||
|
||||
1. **Settings → Integrations**를 엽니다.
|
||||
2. 통합 기능을 찾아 **Install**을 선택합니다.
|
||||
3. 요청되면 OpenCode를 다시 시작합니다 — 재시작 후 공급자가 나타납니다.
|
||||
4. **Set up**를 선택하고 로그인합니다. 그러면 채팅의 모델 선택기에 모델이 나타납니다.
|
||||
|
||||
통합 기능은 사용자 단위로 설치되므로 모든 프로젝트에서 작동합니다. 같은 카드에서 언제든 업데이트하거나 제거할 수 있습니다.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code는 Claude Pro 또는 Max 플랜을 사용합니다 — API 키도 별도의 Claude 앱도 필요 없습니다.
|
||||
|
||||
1. 통합 기능을 설치합니다(위 참고).
|
||||
2. **Set up**를 선택하고 로그인합니다. Claude Code CLI가 아직 없으면 설정에서 먼저 설치를 제안한 뒤 로그인을 진행합니다.
|
||||
|
||||
Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Cursor는 CLI가 필요 없습니다.
|
||||
|
||||
**Claude 계정이 안전하게 유지되는 방식:** 이 통합 기능은 Anthropic의 공식 Claude Agent SDK와 설치된 Claude Code CLI를 사용합니다. OAuth 탈취, 브라우저 토큰 추출·재사용, 지원되지 않는 클라이언트로의 위장, Anthropic 인증 우회를 하지 않습니다. Anthropic이 지원하는 정상 경로를 사용하므로 토큰 탈취나 비인가 인증 우회에 따른 계정 정지 위험이 없습니다.
|
||||
|
||||
## Cursor
|
||||
|
||||
Cursor는 Cursor 플랜에 포함된 모델을 OpenChamber에서 사용할 수 있게 합니다.
|
||||
|
||||
1. 통합 기능을 설치합니다(위 참고).
|
||||
2. **Set up**를 선택하고 링크를 열어 브라우저에서 접근을 승인합니다. API 키는 필요 없습니다. 로그인 후 모델 목록이 자동으로 로드됩니다.
|
||||
|
||||
## 업데이트 및 제거
|
||||
|
||||
- **Update**는 플러그인의 최신 공개 버전을 설치합니다.
|
||||
- **Remove**는 OpenCode 설정에서 플러그인을 삭제합니다. OpenCode가 다시 로드되면 공급자는 더 이상 로드되지 않습니다.
|
||||
|
||||
카드에 항목을 수동으로 관리해야 한다고 표시되면 **Manage plugins**를 선택해 중복 항목을 정리하세요.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [공급자, 모델, 에이전트](/ko/providers/) — 다른 공급자 연결과 모델 선택
|
||||
- [사용량 및 할당량](/ko/usage/) — 사용량 추적
|
||||
@@ -45,5 +45,6 @@ OpenChamber가 무언가를 하려면 먼저 최소한 하나의 AI 공급자가
|
||||
|
||||
## 관련 항목
|
||||
|
||||
- [통합 기능](/ko/integrations/) — Claude 또는 Cursor 구독을 공급자로 사용
|
||||
- [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다
|
||||
- [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다
|
||||
|
||||
@@ -12,7 +12,7 @@ Skills Catalog를 사용하면 직접 작성하는 대신 다른 사람이 게
|
||||
## 스킬 설치하기
|
||||
|
||||
1. 카탈로그를 엽니다.
|
||||
2. 내장된 소스(Anthropic 스킬 저장소와 ClawHub 커뮤니티 레지스트리)를 둘러보거나 검색합니다.
|
||||
2. 내장된 소스(예: Anthropic 스킬 저장소)를 둘러보거나 검색합니다.
|
||||
3. 스킬을 선택하고 설치합니다.
|
||||
4. 설치 위치를 선택합니다. 모든 작업에 적용할지, 현재 프로젝트에만 적용할지 선택합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Integracje
|
||||
description: Używaj subskrypcji Claude lub Cursor jako dostawcy.
|
||||
---
|
||||
|
||||
# Integracje
|
||||
|
||||
Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie subskrypcji, którą już masz. Zarządzasz nimi w **Settings → Integrations**.
|
||||
|
||||
> **Funkcja eksperymentalna.** Staramy się przestrzegać zasad dostawców, ale ograniczenia i zawieszenia kont pozostają decyzją każdego dostawcy. Używaj integracji na własne ryzyko.
|
||||
|
||||
Dostępne integracje:
|
||||
|
||||
- **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API
|
||||
- **Cursor** — limity modeli z Twojego planu Cursor
|
||||
|
||||
## Instalacja integracji
|
||||
|
||||
1. Otwórz **Settings → Integrations**.
|
||||
2. Znajdź integrację i wybierz **Install**.
|
||||
3. Uruchom OpenCode ponownie, gdy o to poproszą — dostawca pojawi się po restarcie.
|
||||
4. Wybierz **Set up** i zaloguj się. Modele pojawią się potem w selektorze modeli na czacie.
|
||||
|
||||
Integracje instalują się dla Twojego użytkownika, więc działają w każdym projekcie. W każdej chwili możesz je zaktualizować lub usunąć z tej samej karty.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code korzysta z Twojego planu Claude Pro lub Max — bez kluczy API i bez osobnej aplikacji Claude.
|
||||
|
||||
1. Zainstaluj integrację (patrz wyżej).
|
||||
2. Wybierz **Set up** i zaloguj się. Jeśli nie masz jeszcze Claude Code CLI, konfiguracja zaoferuje najpierw jego instalację, a potem logowanie.
|
||||
|
||||
Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Cursor nie wymaga swojego CLI.
|
||||
|
||||
**Jak chronione jest Twoje konto Claude:** ta integracja używa oficjalnego Claude Agent SDK od Anthropic i Twojego zainstalowanego Claude Code CLI. Nie przechwytuje OAuth, nie wyodrębnia ani nie odtwarza tokenów przeglądarki, nie podszywa się pod nieobsługiwany klient i nie omija uwierzytelniania Anthropic. Działa na obsługiwanej przez Anthropic ścieżce dostępu, więc nie niesie ryzyka zablokowania konta związanego z przechwytywaniem tokenów lub nieautoryzowanymi obejściami uwierzytelniania.
|
||||
|
||||
## Cursor
|
||||
|
||||
Cursor udostępnia w OpenChamber modele zawarte w Twoim planie Cursor.
|
||||
|
||||
1. Zainstaluj integrację (patrz wyżej).
|
||||
2. Wybierz **Set up**, otwórz link i zatwierdź dostęp w przeglądarce. Klucz API nie jest potrzebny. Lista modeli wczyta się automatycznie po zalogowaniu.
|
||||
|
||||
## Aktualizacja i usuwanie
|
||||
|
||||
- **Update** instaluje najnowszą opublikowaną wersję wtyczki.
|
||||
- **Remove** usuwa wtyczkę z konfiguracji OpenCode. Dostawca przestanie się ładować po odświeżeniu OpenCode.
|
||||
|
||||
Jeśli karta wskazuje, że wpisy wymagają ręcznego zarządzania, wybierz **Manage plugins** i wyczyść tam duplikaty.
|
||||
|
||||
## Powiązane
|
||||
|
||||
- [Dostawcy, modele i agenci](/pl/providers/) — podłączanie innych dostawców i wybór modeli
|
||||
- [Zużycie i limity](/pl/usage/) — śledź, ile wykorzystałeś
|
||||
@@ -45,5 +45,6 @@ Logowania dostawców są przechowywane przez OpenCode, a nie OpenChamber, więc
|
||||
|
||||
## Powiązane
|
||||
|
||||
- [Integracje](/pl/integrations/) — używaj subskrypcji Claude lub Cursor jako dostawcy
|
||||
- [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia
|
||||
- [Zużycie i limity](/pl/usage/) — śledź, ile już wykorzystałeś
|
||||
|
||||
@@ -12,7 +12,7 @@ Aby pisać własne skille, zobacz [Skille](/pl/skills/).
|
||||
## Zainstaluj skill
|
||||
|
||||
1. Otwórz katalog.
|
||||
2. Przeglądaj wbudowane źródła — repozytorium skilli Anthropic oraz rejestr społeczności ClawHub — albo wyszukaj.
|
||||
2. Przeglądaj wbudowane źródła — na przykład repozytorium skilli Anthropic — albo wyszukaj.
|
||||
3. Wybierz skill i zainstaluj go.
|
||||
4. Wybierz, gdzie go zainstalować: dla wszystkiego, co robisz, albo tylko dla bieżącego projektu.
|
||||
|
||||
|
||||
@@ -57,5 +57,6 @@ Provider sign-ins are stored by OpenCode, not OpenChamber, so they're shared wit
|
||||
|
||||
## Related
|
||||
|
||||
- [Integrations](/integrations/) — use a Claude or Cursor subscription as a provider
|
||||
- [MCP Servers](/mcp/) — add extra tools for agents
|
||||
- [Usage & Quotas](/usage/) — track how much you've used
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Integrações
|
||||
description: Use sua assinatura Claude ou Cursor como provedor.
|
||||
---
|
||||
|
||||
# Integrações
|
||||
|
||||
Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber usando uma assinatura que você já tem. Você as gerencia em **Settings → Integrations**.
|
||||
|
||||
> **Recurso experimental.** Buscamos respeitar as políticas dos provedores, mas restrições e suspensões de conta continuam sendo decisão de cada provedor. Use as integrações por sua conta e risco.
|
||||
|
||||
Integrações disponíveis:
|
||||
|
||||
- **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API
|
||||
- **Cursor** — os limites de modelos do seu plano Cursor
|
||||
|
||||
## Instalar uma integração
|
||||
|
||||
1. Abra **Settings → Integrations**.
|
||||
2. Encontre a integração e escolha **Install**.
|
||||
3. Reinicie o OpenCode quando solicitado — o provedor aparece após a reinicialização.
|
||||
4. Escolha **Set up** e faça login. Os modelos aparecem então no seletor de modelos do chat.
|
||||
|
||||
As integrações são instaladas para o seu usuário, então funcionam em todos os projetos. Você pode atualizá-las ou removê-las do mesmo cartão a qualquer momento.
|
||||
|
||||
## Claude Code
|
||||
|
||||
O Claude Code usa seu plano Claude Pro ou Max — sem chaves de API e sem um app Claude separado.
|
||||
|
||||
1. Instale a integração (acima).
|
||||
2. Escolha **Set up** e faça login. Se você ainda não tem a CLI do Claude Code, a configuração oferece instalá-la primeiro e depois fazer login.
|
||||
|
||||
O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Cursor não exige sua CLI.
|
||||
|
||||
**Como sua conta Claude fica protegida:** esta integração usa o Claude Agent SDK oficial da Anthropic e a CLI do Claude Code instalada em sua máquina. Ela não sequestra OAuth, não extrai nem reproduz tokens do navegador, não se passa por um cliente não suportado e não contorna a autenticação da Anthropic. Ela permanece no caminho de acesso suportado pela Anthropic, portanto não traz o risco de banimento de conta associado a sequestro de tokens ou a contornos de autenticação não autorizados.
|
||||
|
||||
## Cursor
|
||||
|
||||
O Cursor torna disponíveis no OpenChamber os modelos incluídos no seu plano Cursor.
|
||||
|
||||
1. Instale a integração (acima).
|
||||
2. Escolha **Set up**, abra o link e autorize o acesso no navegador. Nenhuma chave de API é necessária. A lista de modelos carrega automaticamente após o login.
|
||||
|
||||
## Atualizar ou remover
|
||||
|
||||
- **Update** instala a versão publicada mais recente do plugin.
|
||||
- **Remove** exclui o plugin da sua configuração do OpenCode. O provedor deixa de ser carregado quando o OpenCode é recarregado.
|
||||
|
||||
Se um cartão indicar que as entradas precisam de gerenciamento manual, escolha **Manage plugins** e limpe as duplicatas lá.
|
||||
|
||||
## Relacionado
|
||||
|
||||
- [Provedores, modelos e agentes](/pt-br/providers/) — conecte outros provedores e escolha modelos
|
||||
- [Uso e cotas](/pt-br/usage/) — acompanhe quanto você usou
|
||||
@@ -45,5 +45,6 @@ Os logins de provedores são armazenados pelo OpenCode, não pelo OpenChamber, e
|
||||
|
||||
## Relacionado
|
||||
|
||||
- [Integrações](/pt-br/integrations/) — use uma assinatura Claude ou Cursor como provedor
|
||||
- [Servidores MCP](/pt-br/mcp/) — adicione ferramentas extras para os agentes
|
||||
- [Uso e Cotas](/pt-br/usage/) — acompanhe quanto você já usou
|
||||
|
||||
@@ -12,7 +12,7 @@ Para escrever suas próprias skills, veja [Skills](/pt-br/skills/).
|
||||
## Instalar uma skill
|
||||
|
||||
1. Abra o catálogo.
|
||||
2. Navegue pelas fontes integradas — o repositório de skills da Anthropic e o registro comunitário ClawHub — ou pesquise.
|
||||
2. Navegue pelas fontes integradas — como o repositório de skills da Anthropic — ou pesquise.
|
||||
3. Escolha uma skill e instale-a.
|
||||
4. Escolha onde instalá-la: para tudo o que você faz, ou apenas no projeto atual.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ For writing your own skills, see [Skills](/skills/).
|
||||
## Install a skill
|
||||
|
||||
1. Open the catalog.
|
||||
2. Browse the built-in sources — the Anthropic skills repo and the ClawHub community registry — or search.
|
||||
2. Browse the built-in sources — like the Anthropic skills repo — or search.
|
||||
3. Pick a skill and install it.
|
||||
4. Choose where to install it: for everything you do, or just the current project.
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Інтеграції
|
||||
description: Використовуйте підписки Claude або Cursor як провайдерів.
|
||||
---
|
||||
|
||||
# Інтеграції
|
||||
|
||||
Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**.
|
||||
|
||||
> **Експериментальна функція.** Ми прагнемо дотримуватися політик провайдерів, але обмеження та блокування облікових записів залишаються рішенням кожного провайдера. Використовуйте інтеграції на власний ризик.
|
||||
|
||||
Доступні інтеграції:
|
||||
|
||||
- **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів
|
||||
- **Cursor** — ліміти моделей вашої підписки Cursor
|
||||
|
||||
## Встановлення інтеграції
|
||||
|
||||
1. Відкрийте **Settings → Integrations**.
|
||||
2. Знайдіть інтеграцію та натисніть **Install**.
|
||||
3. Перезапустіть OpenCode, коли про це попросять, — провайдер з'явиться після перезапуску.
|
||||
4. Натисніть **Set up** і увійдіть. Після цього моделі з'являться в перемикачі моделей у чаті.
|
||||
|
||||
Інтеграції встановлюються для вашого користувача, тож працюють у всіх проєктах. Оновити або видалити їх можна в будь-який момент з тієї ж карточки.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code використовує вашу підписку Claude Pro або Max — без API-ключів і без окремого застосунку Claude.
|
||||
|
||||
1. Встановіть інтеграцію (вище).
|
||||
2. Натисніть **Set up** і увійдіть. Якщо у вас ще немає Claude Code CLI, програма встановлення спершу запропонує його встановити, а потім виконає вхід.
|
||||
|
||||
Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Cursor CLI не потрібен.
|
||||
|
||||
**Як захищається ваш обліковий запис Claude:** ця інтеграція використовує офіційний Claude Agent SDK від Anthropic і ваш встановлений Claude Code CLI. Вона не перехоплює OAuth, не витягує й не відтворює браузерні токени, не видає себе за непідтримуваний клієнт і не обходить процес автентифікації Anthropic. Усе працює через підтримуваний Anthropic шлях доступу, тож інтеграція не несе ризику блокування облікового запису, пов'язаного з перехопленням токенів або несанкціонованими способами автентифікації.
|
||||
|
||||
## Cursor
|
||||
|
||||
Cursor робить доступними в OpenChamber моделі, що входять у вашу підписку Cursor.
|
||||
|
||||
1. Встановіть інтеграцію (вище).
|
||||
2. Натисніть **Set up**, відкрийте посилання та підтвердьте доступ у браузері. API-ключ не потрібен. Список моделей завантажиться автоматично після входу.
|
||||
|
||||
## Оновлення та видалення
|
||||
|
||||
- **Update** встановлює найновішу опубліковану версію плагіна.
|
||||
- **Remove** видаляє плагін із конфігурації OpenCode. Провайдер перестане завантажуватися після оновлення OpenCode.
|
||||
|
||||
Якщо карточка повідомляє, що записи потребують ручного керування, натисніть **Manage plugins** і приберіть дублікати там.
|
||||
|
||||
## Пов'язане
|
||||
|
||||
- [Провайдери, моделі та агенти](/uk/providers/) — підключення інших провайдерів і вибір моделей
|
||||
- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили
|
||||
@@ -45,5 +45,6 @@ description: Підключайте AI-провайдерів, обирайте
|
||||
|
||||
## Пов'язане
|
||||
|
||||
- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude або Cursor як провайдерів
|
||||
- [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти
|
||||
- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили
|
||||
|
||||
@@ -12,7 +12,7 @@ description: Переглядайте та встановлюйте готові
|
||||
## Встановлення навички
|
||||
|
||||
1. Відкрийте каталог.
|
||||
2. Перегляньте вбудовані джерела — репозиторій навичок Anthropic та спільнотний реєстр ClawHub — або скористайтеся пошуком.
|
||||
2. Перегляньте вбудовані джерела — наприклад репозиторій навичок Anthropic — або скористайтеся пошуком.
|
||||
3. Оберіть навичку й установіть її.
|
||||
4. Виберіть, куди встановити: для всього, що ви робите, чи лише для поточного проєкту.
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: 集成
|
||||
description: 将你的 Claude 或 Cursor 订阅用作提供商。
|
||||
---
|
||||
|
||||
# 集成
|
||||
|
||||
集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。
|
||||
|
||||
> **实验性功能。**我们力求遵守提供商的政策,但帐户限制和暂停仍由各提供商决定。请自行承担使用集成的风险。
|
||||
|
||||
可用的集成:
|
||||
|
||||
- **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥
|
||||
- **Cursor** — 你的 Cursor 套餐的模型额度
|
||||
|
||||
## 安装集成
|
||||
|
||||
1. 打开 **Settings → Integrations**。
|
||||
2. 找到所需的集成并选择 **Install**。
|
||||
3. 在提示时重启 OpenCode — 重启后提供商就会出现。
|
||||
4. 选择 **Set up** 并登录。之后模型会出现在聊天室的模型选择器中。
|
||||
|
||||
集成按用户安装,因此在所有项目中都可用。你可以随时在同一张卡片上更新或移除它们。
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code 使用你的 Claude Pro 或 Max 套餐 — 无需 API 密钥,也无需单独的 Claude 应用。
|
||||
|
||||
1. 安装集成(见上文)。
|
||||
2. 选择 **Set up** 并登录。如果你还没有 Claude Code CLI,安装向导会先提供安装,然后再登录。
|
||||
|
||||
Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Cursor 不需要其 CLI。
|
||||
|
||||
**你的 Claude 账户如何受到保护:** 此集成使用 Anthropic 官方的 Claude Agent SDK 和你已安装的 Claude Code CLI。它不会劫持 OAuth,不会提取或重放浏览器令牌,不会冒充不受支持的客户端,也不会绕过 Anthropic 的身份验证。它始终运行在 Anthropic 支持的访问路径上,因此不会带来与令牌劫持或未授权身份验证变通手段相关的封号风险。
|
||||
|
||||
## Cursor
|
||||
|
||||
Cursor 让你的 Cursor 套餐中包含的模型可以在 OpenChamber 中使用。
|
||||
|
||||
1. 安装集成(见上文)。
|
||||
2. 选择 **Set up**,打开链接并在浏览器中授权访问。无需 API 密钥。登录后模型列表会自动加载。
|
||||
|
||||
## 更新或移除
|
||||
|
||||
- **Update** 安装插件的最新发布版本。
|
||||
- **Remove** 从你的 OpenCode 配置中删除该插件。OpenCode 重新加载后,该提供商将不再加载。
|
||||
|
||||
如果卡片提示条目需要手动管理,请选择 **Manage plugins** 并在那里清理重复项。
|
||||
|
||||
## 相关内容
|
||||
|
||||
- [提供商、模型与智能体](/zh-cn/providers/) — 连接其他提供商并选择模型
|
||||
- [用量与配额](/zh-cn/usage/) — 跟踪你的使用量
|
||||
@@ -45,5 +45,6 @@ description: 连接 AI 提供商、选择模型并设置智能体。
|
||||
|
||||
## 相关内容
|
||||
|
||||
- [集成](/zh-cn/integrations/) — 将 Claude 或 Cursor 订阅用作提供商
|
||||
- [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具
|
||||
- [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量
|
||||
|
||||
@@ -12,7 +12,7 @@ Skills 目录让你能够安装其他人发布的 skill,而不必自己编写
|
||||
## 安装 skill
|
||||
|
||||
1. 打开目录。
|
||||
2. 浏览内置来源 — Anthropic skills 仓库和 ClawHub 社区注册表 — 或进行搜索。
|
||||
2. 浏览内置来源 — 例如 Anthropic skills 仓库 — 或进行搜索。
|
||||
3. 选择一个 skill 并安装它。
|
||||
4. 选择安装位置:用于你的所有工作,或仅用于当前项目。
|
||||
|
||||
|
||||
@@ -349,6 +349,21 @@
|
||||
"de": "Anbieter, Modelle und Agenten"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Integrations",
|
||||
"link": "/integrations/",
|
||||
"translations": {
|
||||
"uk": "Інтеграції",
|
||||
"zh-CN": "集成",
|
||||
"es": "Integraciones",
|
||||
"pt-BR": "Integrações",
|
||||
"ko": "통합 기능",
|
||||
"pl": "Integracje",
|
||||
"fr": "Intégrations",
|
||||
"ja": "統合機能",
|
||||
"de": "Integrationen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "MCP Servers",
|
||||
"link": "/mcp/",
|
||||
|
||||
@@ -143,6 +143,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
||||
## Native Features Owned Here
|
||||
|
||||
- Floating Mini Chat windows.
|
||||
- New Mini Chat windows default to the managed Chats target. Explicit project/worktree drafts retain their target, existing managed chat sessions reopen in their own directory, and the compact header omits project/branch metadata for Chats. Opening a managed draft back in the main window preserves that target.
|
||||
- Multiple native windows.
|
||||
- Native notifications.
|
||||
- User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried.
|
||||
@@ -159,7 +160,9 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
||||
grants permission requests by default when no handler is set, and the panel
|
||||
loads whatever address the user types. Tab favicons are fetched in this
|
||||
session too, so icons behind the page's own login resolve and the app's origin
|
||||
never requests anything from a third-party host.
|
||||
never requests anything from a third-party host. Self-signed loopback HTTPS
|
||||
pages may use an untrusted certificate authority; certificate failures for
|
||||
external hosts and all other certificate errors remain blocked.
|
||||
|
||||
## IPC Pattern
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
|
||||
export const shouldAllowBrowserPanelCertificateError = ({ url, error }) => {
|
||||
if (error !== 'net::ERR_CERT_AUTHORITY_INVALID') return false;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'https:' && LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs';
|
||||
|
||||
test('allows untrusted certificate authorities for loopback HTTPS pages', () => {
|
||||
for (const url of [
|
||||
'https://localhost:58580/',
|
||||
'https://127.0.0.1:58580/',
|
||||
'https://[::1]:58580/',
|
||||
]) {
|
||||
assert.equal(shouldAllowBrowserPanelCertificateError({
|
||||
url,
|
||||
error: 'net::ERR_CERT_AUTHORITY_INVALID',
|
||||
}), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps certificate validation for non-loopback pages', () => {
|
||||
for (const url of [
|
||||
'https://example.com/',
|
||||
'https://localhost.example.com/',
|
||||
'https://0.0.0.0:58580/',
|
||||
]) {
|
||||
assert.equal(shouldAllowBrowserPanelCertificateError({
|
||||
url,
|
||||
error: 'net::ERR_CERT_AUTHORITY_INVALID',
|
||||
}), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not bypass other certificate failures or malformed URLs', () => {
|
||||
assert.equal(shouldAllowBrowserPanelCertificateError({
|
||||
url: 'https://localhost:58580/',
|
||||
error: 'net::ERR_CERT_DATE_INVALID',
|
||||
}), false);
|
||||
assert.equal(shouldAllowBrowserPanelCertificateError({
|
||||
url: 'not a url',
|
||||
error: 'net::ERR_CERT_AUTHORITY_INVALID',
|
||||
}), false);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import updaterPkg from 'electron-updater';
|
||||
import { ElectronSshManager } from './ssh-manager.mjs';
|
||||
import { replaceFileWithRetry } from './windows-file-replace.mjs';
|
||||
import { createTrayController } from './tray.mjs';
|
||||
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
|
||||
import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs';
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
setLinuxAutostartEnabled,
|
||||
} from './linux-autostart.mjs';
|
||||
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs';
|
||||
import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs';
|
||||
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -559,10 +561,15 @@ const writeJsonFile = async (filePath, data) => {
|
||||
// Atomic: write to a temp file then rename. Readers never see a partial
|
||||
// JSON file that could parse-error and get coerced to {}.
|
||||
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600);
|
||||
await fsp.rename(tmp, filePath);
|
||||
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
|
||||
try {
|
||||
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600);
|
||||
await replaceFileWithRetry(tmp, filePath);
|
||||
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
|
||||
} catch (error) {
|
||||
await fsp.rm(tmp, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const readSettingsRoot = () => {
|
||||
@@ -1186,6 +1193,15 @@ const resolveBrowserPanelContents = (rawId) => {
|
||||
const hardenBrowserPanelSession = () => {
|
||||
const panelSession = session.fromPartition(BROWSER_PANEL_PARTITION);
|
||||
|
||||
app.on('certificate-error', (event, contents, url, error, _certificate, callback) => {
|
||||
if (contents.session === panelSession && shouldAllowBrowserPanelCertificateError({ url, error })) {
|
||||
event.preventDefault();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
callback(false);
|
||||
});
|
||||
|
||||
panelSession.setPermissionRequestHandler((_contents, permission, callback, details) => {
|
||||
log.info('[electron] browser panel denied a permission request', {
|
||||
permission,
|
||||
@@ -1224,7 +1240,15 @@ const registerPackagedUiProtocol = () => {
|
||||
if (filePath.endsWith('.html')) {
|
||||
const html = await fsp.readFile(filePath, 'utf8');
|
||||
const body = injectRuntimeConfigIntoHtml(html);
|
||||
return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
// index.html must never be cached: it names the hashed asset
|
||||
// bundles, and a cached copy keeps a freshly installed build
|
||||
// loading the previous version's UI from the renderer disk cache.
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
return electronNet.fetch(pathToFileURL(filePath).toString());
|
||||
}
|
||||
@@ -2455,6 +2479,9 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken, requestHeaders: desktopRequestHeaders };
|
||||
browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders);
|
||||
browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled;
|
||||
browserWindow.on('app-command', (event, command) => {
|
||||
if (command === 'browser-backward') event.preventDefault();
|
||||
});
|
||||
|
||||
if (useSaved && saved.maximized) {
|
||||
browserWindow.maximize();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/electron",
|
||||
"version": "1.18.4",
|
||||
"version": "1.20.0",
|
||||
"private": true,
|
||||
"description": "Electron desktop runtime for OpenChamber",
|
||||
"author": "OpenChamber",
|
||||
|
||||
@@ -220,7 +220,7 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const electron = spawnProcess('npx', ['electron', './main.mjs'], {
|
||||
const electron = spawnProcess('bun', ['x', 'electron', './main.mjs'], {
|
||||
cwd: electronDir,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -5,9 +5,30 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import { replaceFileWithRetry } from './windows-file-replace.mjs';
|
||||
|
||||
const LOCAL_HOST_ID = 'local';
|
||||
const DEFAULT_CONNECTION_TIMEOUT_SEC = 60;
|
||||
const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1';
|
||||
// Global npm prefixes are root-owned on most distributions, so `npm install -g`
|
||||
// fails with EACCES for a normal SSH user. Everything we install goes to a
|
||||
// prefix inside the user's home instead.
|
||||
const REMOTE_USER_PREFIX = '$HOME/.openchamber/npm-global';
|
||||
const REMOTE_BUN_CANDIDATE = '"${BUN_INSTALL:-$HOME/.bun}/bin/bun"';
|
||||
// The opencode CLI usually installs into the user's home, which an SSH login
|
||||
// shell does not have on PATH. The remote server only looks at OPENCODE_BINARY
|
||||
// and PATH, so resolve the CLI here and hand it over explicitly.
|
||||
const REMOTE_OPENCODE_CANDIDATES = [
|
||||
'"$HOME/.opencode/bin/opencode"',
|
||||
'"${BUN_INSTALL:-$HOME/.bun}/bin/opencode"',
|
||||
'"$HOME/.local/bin/opencode"',
|
||||
'"$HOME/.openchamber/npm-global/bin/opencode"',
|
||||
];
|
||||
const REMOTE_PATH_PREFIX = '$HOME/.opencode/bin:${BUN_INSTALL:-$HOME/.bun}/bin:$HOME/.local/bin:$HOME/.openchamber/npm-global/bin';
|
||||
const REMOTE_BIN_CANDIDATES = [
|
||||
'"$HOME/.openchamber/npm-global/bin/openchamber"',
|
||||
'"${BUN_INSTALL:-$HOME/.bun}/bin/openchamber"',
|
||||
];
|
||||
const DEFAULT_CONTROL_PERSIST_SEC = 300;
|
||||
const DEFAULT_READY_TIMEOUT_SEC = 30;
|
||||
const DEFAULT_RECONNECT_MAX_ATTEMPTS = 5;
|
||||
@@ -77,10 +98,15 @@ const writeJsonRoot = async (settingsFilePath, root) => {
|
||||
await fsp.mkdir(path.dirname(settingsFilePath), { recursive: true });
|
||||
// Atomic write: concurrent readers (main.mjs, web server) would otherwise
|
||||
// see partial JSON and readJsonRoot()'s catch would silently coerce to {},
|
||||
// causing the next read-modify-write to wipe the entire settings file.
|
||||
// causing the next read-modify-write wipe the entire settings file.
|
||||
const tmp = `${settingsFilePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await fsp.writeFile(tmp, JSON.stringify(root, null, 2));
|
||||
await fsp.rename(tmp, settingsFilePath);
|
||||
try {
|
||||
await fsp.writeFile(tmp, JSON.stringify(root, null, 2));
|
||||
await replaceFileWithRetry(tmp, settingsFilePath);
|
||||
} catch (error) {
|
||||
await fsp.rm(tmp, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const defaultTrue = () => true;
|
||||
@@ -578,6 +604,7 @@ export class ElectronSshManager {
|
||||
localUrl: null,
|
||||
localPort: null,
|
||||
remotePort: null,
|
||||
remoteBinPath: null,
|
||||
startedByUs: false,
|
||||
retryAttempt: 0,
|
||||
requiresUserAction: false,
|
||||
@@ -804,9 +831,10 @@ export class ElectronSshManager {
|
||||
mode: instance?.remoteOpenchamber?.mode === 'external' ? 'external' : 'managed',
|
||||
keepRunning: instance?.remoteOpenchamber?.keepRunning !== false,
|
||||
...(Number.isFinite(instance?.remoteOpenchamber?.preferredPort) ? { preferredPort: Number(instance.remoteOpenchamber.preferredPort) } : {}),
|
||||
installMethod: ['npm', 'bun', 'download_release', 'upload_bundle'].includes(instance?.remoteOpenchamber?.installMethod)
|
||||
installMethod: ['auto', 'npm', 'bun'].includes(instance?.remoteOpenchamber?.installMethod)
|
||||
? instance.remoteOpenchamber.installMethod
|
||||
: 'bun',
|
||||
: 'auto',
|
||||
bindHost: instance?.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1',
|
||||
uploadBundleOverSsh: Boolean(instance?.remoteOpenchamber?.uploadBundleOverSsh),
|
||||
},
|
||||
localForward: {
|
||||
@@ -981,38 +1009,77 @@ export class ElectronSshManager {
|
||||
return secret?.enabled && typeof secret.value === 'string' && secret.value.trim() ? secret.value.trim() : null;
|
||||
}
|
||||
|
||||
async remoteCommandExists(parsed, controlPath, commandName) {
|
||||
try {
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, `command -v ${commandName} >/dev/null 2>&1 && echo yes || echo no`);
|
||||
return output.trim() === 'yes';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// A login shell over SSH does not source the user's interactive rc files, so
|
||||
// tools installed into a home directory (bun above all) are missing from PATH
|
||||
// even when they exist. Look at their known install locations too.
|
||||
async resolveRemoteTool(parsed, controlPath, commandName, extraCandidates = []) {
|
||||
const candidateList = [...extraCandidates, `"$(command -v ${commandName} 2>/dev/null)"`].join(' ');
|
||||
const script = [
|
||||
`for candidate in ${candidateList}; do`,
|
||||
' [ -n "$candidate" ] || continue;',
|
||||
' [ -x "$candidate" ] || continue;',
|
||||
` printf '%s' "$candidate";`,
|
||||
' exit 0;',
|
||||
'done',
|
||||
].join(' ');
|
||||
|
||||
async currentRemoteOpenChamberVersion(parsed, controlPath) {
|
||||
try {
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true');
|
||||
return parseVersionToken(output);
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, script);
|
||||
return output.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async installOpenChamberManaged(parsed, controlPath, version, preferred) {
|
||||
const hasBun = await this.remoteCommandExists(parsed, controlPath, 'bun');
|
||||
const hasNpm = await this.remoteCommandExists(parsed, controlPath, 'npm');
|
||||
const commands = [];
|
||||
// Every place OpenChamber may live on the remote host, with the version each
|
||||
// one reports. Installs land in the user prefix while an older copy can still
|
||||
// sit on PATH, so the caller picks by version instead of trusting PATH order.
|
||||
async remoteOpenChamberCandidates(parsed, controlPath) {
|
||||
const script = [
|
||||
`for candidate in ${REMOTE_BIN_CANDIDATES.join(' ')} "$(command -v openchamber 2>/dev/null)"; do`,
|
||||
' [ -n "$candidate" ] || continue;',
|
||||
' [ -x "$candidate" ] || continue;',
|
||||
` printf '%s\t%s\n' "$candidate" "$("$candidate" --version 2>/dev/null | head -n 1)";`,
|
||||
'done',
|
||||
].join(' ');
|
||||
|
||||
if (preferred === 'bun') {
|
||||
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
|
||||
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
|
||||
} else if (preferred === 'npm') {
|
||||
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
|
||||
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
|
||||
let output = '';
|
||||
try {
|
||||
output = await this.runRemoteCommand(parsed, controlPath, script);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const [binPath, versionRaw] = line.split('\t');
|
||||
const trimmed = (binPath || '').trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
candidates.push({ binPath: trimmed, version: parseVersionToken(versionRaw || '') });
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async installOpenChamberManaged(parsed, controlPath, version, preferred) {
|
||||
const bunPath = await this.resolveRemoteTool(parsed, controlPath, 'bun', [REMOTE_BUN_CANDIDATE]);
|
||||
const npmPath = await this.resolveRemoteTool(parsed, controlPath, 'npm');
|
||||
|
||||
// bun's global install already targets ~/.bun; npm is pinned to a prefix in
|
||||
// the user's home so it never touches the root-owned global directory.
|
||||
const bunCommand = bunPath ? `${shellQuote(bunPath)} add -g @openchamber/web@${version}` : null;
|
||||
const npmCommand = npmPath
|
||||
? `mkdir -p "${REMOTE_USER_PREFIX}" && ${shellQuote(npmPath)} install -g --prefix "${REMOTE_USER_PREFIX}" @openchamber/web@${version}`
|
||||
: null;
|
||||
|
||||
const commands = [];
|
||||
if (preferred === 'npm') {
|
||||
if (npmCommand) commands.push(npmCommand);
|
||||
if (bunCommand) commands.push(bunCommand);
|
||||
} else {
|
||||
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
|
||||
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
|
||||
if (bunCommand) commands.push(bunCommand);
|
||||
if (npmCommand) commands.push(npmCommand);
|
||||
}
|
||||
|
||||
if (commands.length === 0) {
|
||||
@@ -1072,24 +1139,35 @@ export class ElectronSshManager {
|
||||
}
|
||||
}
|
||||
|
||||
async startRemoteServerManaged(parsed, controlPath, instance, desiredPort) {
|
||||
let envPrefix = 'OPENCHAMBER_RUNTIME=ssh-remote';
|
||||
async startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binPath) {
|
||||
const opencodePath = await this.resolveRemoteTool(parsed, controlPath, 'opencode', REMOTE_OPENCODE_CANDIDATES);
|
||||
if (!opencodePath) {
|
||||
throw new Error('The opencode CLI is not installed on the remote machine. Install it there, then connect again');
|
||||
}
|
||||
|
||||
const secret = this.configuredOpenChamberPassword(instance);
|
||||
const remoteBindHost = instance.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1';
|
||||
// Binding the remote server to every interface publishes its UI to the
|
||||
// remote machine's whole network, so it may not run without a password.
|
||||
if (remoteBindHost === '0.0.0.0' && !secret) {
|
||||
throw new Error('Exposing the remote server to its network requires a UI password');
|
||||
}
|
||||
|
||||
let envPrefix = `PATH="${REMOTE_PATH_PREFIX}:$PATH" OPENCODE_BINARY=${shellQuote(opencodePath)} OPENCHAMBER_RUNTIME=ssh-remote`;
|
||||
if (secret) {
|
||||
envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`;
|
||||
}
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --port ${desiredPort}`);
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} ${shellQuote(binPath)} serve --hostname ${remoteBindHost} --port ${desiredPort}`);
|
||||
const port = output.split(/\s+/).map((token) => Number.parseInt(token, 10)).find((value) => Number.isFinite(value));
|
||||
return port || desiredPort;
|
||||
}
|
||||
|
||||
async stopRemoteServerBestEffort(parsed, controlPath, remotePort) {
|
||||
// `openchamber stop` owns the daemon lifecycle. The HTTP shutdown route sits
|
||||
// behind UI authentication, so it cannot stop a password-protected server.
|
||||
async stopRemoteServerBestEffort(parsed, controlPath, remotePort, remoteBinPath) {
|
||||
if (!remoteBinPath) return;
|
||||
try {
|
||||
await this.runRemoteCommand(
|
||||
parsed,
|
||||
controlPath,
|
||||
`if command -v curl >/dev/null 2>&1; then curl -fsS -X POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; elif command -v wget >/dev/null 2>&1; then wget -qO- --method=POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; fi`,
|
||||
);
|
||||
await this.runRemoteCommand(parsed, controlPath, `${shellQuote(remoteBinPath)} stop --port ${remotePort}`);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@@ -1143,17 +1221,27 @@ export class ElectronSshManager {
|
||||
const port = instance.remoteOpenchamber.preferredPort;
|
||||
this.setStatus(instance.id, 'server_detecting', 'Probing external OpenChamber server', null, null, port, false, 0, false);
|
||||
await this.probeRemoteSystemInfo(parsed, controlPath, port, this.configuredOpenChamberPassword(instance));
|
||||
return { remotePort: port, startedByUs: false };
|
||||
return { remotePort: port, startedByUs: false, remoteBinPath: null };
|
||||
}
|
||||
|
||||
this.setStatus(instance.id, 'remote_probe', 'Checking remote OpenChamber installation');
|
||||
const installedVersion = await this.currentRemoteOpenChamberVersion(parsed, controlPath);
|
||||
if (!installedVersion) {
|
||||
this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host');
|
||||
await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod);
|
||||
} else if (installedVersion !== this.appVersion) {
|
||||
this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${installedVersion} to ${this.appVersion}`);
|
||||
const installed = await this.remoteOpenChamberCandidates(parsed, controlPath);
|
||||
let binary = installed.find((candidate) => candidate.version === this.appVersion) || null;
|
||||
|
||||
if (!binary) {
|
||||
const existing = installed[0] || null;
|
||||
if (existing) {
|
||||
this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${existing.version || 'unknown'} to ${this.appVersion}`);
|
||||
} else {
|
||||
this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host');
|
||||
}
|
||||
await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod);
|
||||
|
||||
const afterInstall = await this.remoteOpenChamberCandidates(parsed, controlPath);
|
||||
binary = afterInstall.find((candidate) => candidate.version === this.appVersion) || afterInstall[0] || existing;
|
||||
if (!binary) {
|
||||
throw new Error('OpenChamber was installed on the remote host but no openchamber binary could be found');
|
||||
}
|
||||
}
|
||||
|
||||
this.setStatus(instance.id, 'server_detecting', 'Detecting managed OpenChamber server');
|
||||
@@ -1165,13 +1253,13 @@ export class ElectronSshManager {
|
||||
if (!remotePort) {
|
||||
this.setStatus(instance.id, 'server_starting', 'Starting managed OpenChamber server');
|
||||
const desiredPort = instance.remoteOpenchamber.preferredPort || randomPortCandidate(instance.id);
|
||||
remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort);
|
||||
remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binary.binPath);
|
||||
startedByUs = true;
|
||||
}
|
||||
if (!(await this.remoteServerRunning(parsed, controlPath, remotePort, this.configuredOpenChamberPassword(instance)))) {
|
||||
throw new Error('Managed OpenChamber server failed to become reachable');
|
||||
}
|
||||
return { remotePort, startedByUs };
|
||||
return { remotePort, startedByUs, remoteBinPath: binary.binPath };
|
||||
}
|
||||
|
||||
async disconnectInternal(id, reportIdle) {
|
||||
@@ -1186,7 +1274,7 @@ export class ElectronSshManager {
|
||||
|
||||
if (session) {
|
||||
if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
|
||||
await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort);
|
||||
await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort, session.remoteBinPath);
|
||||
}
|
||||
await this.stopControlMasterBestEffort(session.parsed, session.controlPath);
|
||||
const auth = this.sshAuth.get(session.parsed);
|
||||
@@ -1262,9 +1350,10 @@ export class ElectronSshManager {
|
||||
throw new Error(`Unsupported remote OS: ${remoteOs}`);
|
||||
}
|
||||
|
||||
const { remotePort, startedByUs } = await this.ensureRemoteServer(instance, parsed, controlPath);
|
||||
const { remotePort, startedByUs, remoteBinPath } = await this.ensureRemoteServer(instance, parsed, controlPath);
|
||||
session.remotePort = remotePort;
|
||||
session.startedByUs = startedByUs;
|
||||
session.remoteBinPath = remoteBinPath;
|
||||
this.setStatus(id, 'forwarding', 'Setting up port forwards', null, null, remotePort, startedByUs, 0, false);
|
||||
|
||||
const bindHost = sanitizeBindHost(instance.localForward?.bindHost);
|
||||
|
||||
@@ -289,4 +289,161 @@ describe('ElectronSshManager', () => {
|
||||
});
|
||||
expect(settings.desktopHosts).toEqual([{ id: 'ssh-1', label: 'SSH Host', url: localUrl, apiUrl: localUrl, clientToken: 'ssh-client-token' }]);
|
||||
});
|
||||
test('installs OpenChamber into a home-owned npm prefix instead of the root-owned global one', async () => {
|
||||
const commands = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '1.2.3',
|
||||
emit: () => undefined,
|
||||
});
|
||||
manager.resolveRemoteTool = async (_parsed, _controlPath, name) => (name === 'npm' ? '/usr/bin/npm' : null);
|
||||
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
|
||||
commands.push(script);
|
||||
return '';
|
||||
};
|
||||
|
||||
await manager.installOpenChamberManaged({ destination: 'user@example.test', args: [] }, '/tmp/control.sock', '1.2.3', 'auto');
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]).toContain('--prefix "$HOME/.openchamber/npm-global"');
|
||||
expect(commands[0]).not.toMatch(/npm install -g @openchamber/);
|
||||
});
|
||||
|
||||
test('lists every remote OpenChamber binary with its reported version', async () => {
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '1.2.3',
|
||||
emit: () => undefined,
|
||||
});
|
||||
manager.runRemoteCommand = async () => [
|
||||
'/home/pi/.openchamber/npm-global/bin/openchamber\t1.2.3',
|
||||
'/usr/bin/openchamber\t0.9.0',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const candidates = await manager.remoteOpenChamberCandidates({ destination: 'user@example.test', args: [] }, '/tmp/control.sock');
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{ binPath: '/home/pi/.openchamber/npm-global/bin/openchamber', version: '1.2.3' },
|
||||
{ binPath: '/usr/bin/openchamber', version: '0.9.0' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('starts the resolved OpenChamber binary rather than whatever PATH exposes', async () => {
|
||||
let started = '';
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '1.2.3',
|
||||
emit: () => undefined,
|
||||
});
|
||||
manager.resolveRemoteTool = async () => '/home/pi/.opencode/bin/opencode';
|
||||
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
|
||||
started = script;
|
||||
return '4321\n';
|
||||
};
|
||||
|
||||
const instance = { id: 'ssh-1', auth: {}, remoteOpenchamber: { mode: 'managed' } };
|
||||
const port = await manager.startRemoteServerManaged(
|
||||
{ destination: 'user@example.test', args: [] },
|
||||
'/tmp/control.sock',
|
||||
instance,
|
||||
4321,
|
||||
'/home/pi/.openchamber/npm-global/bin/openchamber',
|
||||
);
|
||||
|
||||
expect(port).toBe(4321);
|
||||
expect(started).toContain("'/home/pi/.openchamber/npm-global/bin/openchamber' serve");
|
||||
expect(started).toContain("OPENCODE_BINARY='/home/pi/.opencode/bin/opencode'");
|
||||
expect(started).toContain('$HOME/.opencode/bin:');
|
||||
});
|
||||
|
||||
test('refuses to start when the remote machine has no opencode CLI', async () => {
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '1.2.3',
|
||||
emit: () => undefined,
|
||||
});
|
||||
manager.resolveRemoteTool = async () => null;
|
||||
manager.runRemoteCommand = async () => {
|
||||
throw new Error('should not start the server without a CLI');
|
||||
};
|
||||
|
||||
await expect(manager.startRemoteServerManaged(
|
||||
{ destination: 'user@example.test', args: [] },
|
||||
'/tmp/control.sock',
|
||||
{ id: 'ssh-1', auth: {}, remoteOpenchamber: { mode: 'managed' } },
|
||||
4321,
|
||||
'/home/pi/.bun/bin/openchamber',
|
||||
)).rejects.toThrow(/opencode CLI is not installed/);
|
||||
});
|
||||
test('prefers a bun that only exists in the home directory over npm', async () => {
|
||||
const commands = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '1.2.3',
|
||||
emit: () => undefined,
|
||||
});
|
||||
// A login shell over SSH does not put ~/.bun/bin on PATH.
|
||||
manager.resolveRemoteTool = async (_parsed, _controlPath, name) =>
|
||||
(name === 'bun' ? '/home/pi/.bun/bin/bun' : '/usr/bin/npm');
|
||||
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
|
||||
commands.push(script);
|
||||
return '';
|
||||
};
|
||||
|
||||
await manager.installOpenChamberManaged({ destination: 'user@example.test', args: [] }, '/tmp/control.sock', '1.2.3', 'auto');
|
||||
|
||||
expect(commands).toEqual(["'/home/pi/.bun/bin/bun' add -g @openchamber/web@1.2.3"]);
|
||||
});
|
||||
test('stops a remote server it started through the CLI, not the authenticated HTTP route', async () => {
|
||||
const scripts = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '1.2.3',
|
||||
emit: () => undefined,
|
||||
});
|
||||
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
|
||||
scripts.push(script);
|
||||
return '';
|
||||
};
|
||||
|
||||
await manager.stopRemoteServerBestEffort(
|
||||
{ destination: 'user@example.test', args: [] },
|
||||
'/tmp/control.sock',
|
||||
41777,
|
||||
'/home/pi/.bun/bin/openchamber',
|
||||
);
|
||||
|
||||
expect(scripts).toEqual(["'/home/pi/.bun/bin/openchamber' stop --port 41777"]);
|
||||
});
|
||||
test('publishes the remote server to its network only with a UI password', async () => {
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '1.2.3',
|
||||
emit: () => undefined,
|
||||
});
|
||||
manager.resolveRemoteTool = async () => '/home/pi/.opencode/bin/opencode';
|
||||
let started = '';
|
||||
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
|
||||
started = script;
|
||||
return '4321\n';
|
||||
};
|
||||
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
const exposed = {
|
||||
id: 'ssh-1',
|
||||
auth: {},
|
||||
remoteOpenchamber: { mode: 'managed', bindHost: '0.0.0.0' },
|
||||
};
|
||||
|
||||
await expect(manager.startRemoteServerManaged(parsed, '/tmp/control.sock', exposed, 4321, '/bin/openchamber'))
|
||||
.rejects.toThrow(/requires a UI password/);
|
||||
|
||||
const secured = {
|
||||
...exposed,
|
||||
auth: { openchamberPassword: { enabled: true, value: 'remote-secret', store: 'settings' } },
|
||||
};
|
||||
await manager.startRemoteServerManaged(parsed, '/tmp/control.sock', secured, 4321, '/bin/openchamber');
|
||||
expect(started).toContain('--hostname 0.0.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
|
||||
const WINDOWS_RETRY_DELAYS_MS = [50, 100, 200, 400, 800, 1_000, 1_000];
|
||||
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
const isTransientWindowsFileError = (error, platform) => {
|
||||
if (platform !== 'win32') return false;
|
||||
const code = error?.code;
|
||||
return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
|
||||
};
|
||||
|
||||
export const replaceFileWithRetry = async (source, target, options = {}) => {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const rename = options.rename ?? fsp.rename;
|
||||
const wait = options.wait ?? sleep;
|
||||
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await rename(source, target);
|
||||
return;
|
||||
} catch (error) {
|
||||
const delay = WINDOWS_RETRY_DELAYS_MS[attempt];
|
||||
if (delay === undefined || !isTransientWindowsFileError(error, platform)) throw error;
|
||||
await wait(delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { replaceFileWithRetry } from './windows-file-replace.mjs';
|
||||
|
||||
const fileError = (code = 'EPERM') => Object.assign(new Error(code), { code });
|
||||
|
||||
test('retries transient Windows rename failures until replacement succeeds', async () => {
|
||||
const delays = [];
|
||||
let attempts = 0;
|
||||
|
||||
await replaceFileWithRetry('settings.tmp', 'settings.json', {
|
||||
platform: 'win32',
|
||||
rename: async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 4) throw fileError();
|
||||
},
|
||||
wait: async (delay) => delays.push(delay),
|
||||
});
|
||||
|
||||
assert.equal(attempts, 4);
|
||||
assert.deepEqual(delays, [50, 100, 200]);
|
||||
});
|
||||
|
||||
test('does not retry rename errors that are not transient Windows locks', async () => {
|
||||
let attempts = 0;
|
||||
const error = fileError('ENOENT');
|
||||
|
||||
await assert.rejects(
|
||||
replaceFileWithRetry('settings.tmp', 'settings.json', {
|
||||
platform: 'win32',
|
||||
rename: async () => {
|
||||
attempts += 1;
|
||||
throw error;
|
||||
},
|
||||
wait: async () => assert.fail('unexpected wait'),
|
||||
}),
|
||||
error,
|
||||
);
|
||||
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test('does not retry transient error codes outside Windows', async () => {
|
||||
let attempts = 0;
|
||||
const error = fileError();
|
||||
|
||||
await assert.rejects(
|
||||
replaceFileWithRetry('settings.tmp', 'settings.json', {
|
||||
platform: 'linux',
|
||||
rename: async () => {
|
||||
attempts += 1;
|
||||
throw error;
|
||||
},
|
||||
wait: async () => assert.fail('unexpected wait'),
|
||||
}),
|
||||
error,
|
||||
);
|
||||
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test('returns the final Windows lock error after the retry window', async () => {
|
||||
const delays = [];
|
||||
let attempts = 0;
|
||||
|
||||
await assert.rejects(
|
||||
replaceFileWithRetry('settings.tmp', 'settings.json', {
|
||||
platform: 'win32',
|
||||
rename: async () => {
|
||||
attempts += 1;
|
||||
throw fileError();
|
||||
},
|
||||
wait: async (delay) => delays.push(delay),
|
||||
}),
|
||||
{ code: 'EPERM' },
|
||||
);
|
||||
|
||||
assert.equal(attempts, 8);
|
||||
assert.deepEqual(delays, [50, 100, 200, 400, 800, 1_000, 1_000]);
|
||||
});
|
||||
+17
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.18.4",
|
||||
"version": "1.20.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/main.tsx",
|
||||
@@ -19,34 +19,35 @@
|
||||
"@capacitor/keyboard": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.1.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.11.0",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "6.39.13",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/lint": "^6.9.7",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@legendapp/list": "3.3.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"@pierre/diffs": "1.3.0-beta.6",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vim": "^6.4.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@tanstack/react-virtual": "3.14.5",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
@@ -103,6 +104,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"globals": "^16.3.0",
|
||||
"happy-dom": "^18.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
|
||||
+16
-10
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { FireworksProvider } from '@/contexts/FireworksContext';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -11,9 +12,11 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { useMenuActions } from '@/hooks/useMenuActions';
|
||||
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
||||
import { useTraySync } from '@/hooks/useTraySync';
|
||||
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
|
||||
import { useAgentMemorySync } from '@/hooks/useAgentMemorySync';
|
||||
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
@@ -32,7 +35,6 @@ import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionR
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { markSessionViewed } from '@/sync/notification-store';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
@@ -624,7 +626,6 @@ function App({ apis }: AppProps) {
|
||||
const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
|
||||
? detail.directory.trim()
|
||||
: null;
|
||||
useUIStore.getState().setActiveMainTab('chat');
|
||||
void useSessionUIStore.getState().setCurrentSession(sessionId, directory);
|
||||
};
|
||||
|
||||
@@ -638,12 +639,9 @@ function App({ apis }: AppProps) {
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const onOpenMiniChat = () => {
|
||||
const currentDir = useDirectoryStore.getState().currentDirectory;
|
||||
const { activeProjectId, projects } = useProjectsStore.getState();
|
||||
const activeProject = projects.find((p) => p.id === activeProjectId) ?? null;
|
||||
void invokeDesktop('desktop_open_draft_mini_chat_window', {
|
||||
directory: currentDir || activeProject?.path || '',
|
||||
projectId: activeProject?.id ?? null,
|
||||
directory: '',
|
||||
projectId: null,
|
||||
});
|
||||
};
|
||||
window.addEventListener('openchamber:open-mini-chat', onOpenMiniChat);
|
||||
@@ -675,11 +673,12 @@ function App({ apis }: AppProps) {
|
||||
const projectId = typeof detail?.projectId === 'string' && detail.projectId.trim().length > 0
|
||||
? detail.projectId.trim()
|
||||
: null;
|
||||
useUIStore.getState().setActiveMainTab('chat');
|
||||
const hasProjectTarget = Boolean(directory || projectId);
|
||||
useUIStore.getState().setSessionSwitcherOpen(false);
|
||||
useSessionUIStore.getState().openNewSessionDraft({
|
||||
selectedProjectId: projectId,
|
||||
directoryOverride: directory,
|
||||
target: hasProjectTarget ? 'project' : 'chat',
|
||||
selectedProjectId: hasProjectTarget ? projectId : null,
|
||||
directoryOverride: hasProjectTarget ? directory : null,
|
||||
preserveDirectoryOverride: Boolean(directory),
|
||||
});
|
||||
};
|
||||
@@ -703,6 +702,10 @@ function App({ apis }: AppProps) {
|
||||
|
||||
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
|
||||
useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled });
|
||||
// Loaded here rather than by the Memory tab: the session index is built from
|
||||
// this snapshot, so leaving it to the panel meant a user who never opened
|
||||
// Project notes sent every message with no memory index at all.
|
||||
useAgentMemorySync(currentDirectory || null);
|
||||
usePwaInstallPrompt();
|
||||
|
||||
useWindowTitle();
|
||||
@@ -716,6 +719,7 @@ function App({ apis }: AppProps) {
|
||||
useMenuActions(handleToggleMemoryDebug);
|
||||
|
||||
useTraySync();
|
||||
useGlobalSessionsPolling(!embeddedSessionChat);
|
||||
|
||||
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
||||
|
||||
@@ -903,6 +907,7 @@ function App({ apis }: AppProps) {
|
||||
isVSCodeRuntime={isVSCodeRuntime}
|
||||
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
|
||||
/>
|
||||
<AppLinkConfirmDialog />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</RuntimeAPIProvider>
|
||||
@@ -946,6 +951,7 @@ function App({ apis }: AppProps) {
|
||||
<OpenCodeUpdateToast />
|
||||
<MainLayout />
|
||||
<Toaster />
|
||||
<AppLinkConfirmDialog />
|
||||
{!isBootShell && (
|
||||
<>
|
||||
<ConfigUpdateOverlay />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
worktreeMapsEqual,
|
||||
} from '@/lib/worktrees/worktreeManager';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
|
||||
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
|
||||
|
||||
@@ -153,9 +155,9 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : '';
|
||||
if (!sessionId) return;
|
||||
if (useSessionUIStore.getState().currentSessionId === sessionId) return;
|
||||
const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
|
||||
? detail.directory.trim()
|
||||
: (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory ?? null;
|
||||
const sessionDirectory = (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory?.trim();
|
||||
const directory = sessionDirectory
|
||||
|| (typeof detail?.directory === 'string' && detail.directory.trim().length > 0 ? detail.directory.trim() : null);
|
||||
void sync.ensureSessionRenderable(sessionId);
|
||||
setCurrentSession(sessionId, directory);
|
||||
sessionBootstrappedRef.current = true;
|
||||
@@ -166,9 +168,11 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
|
||||
const hasProjectTarget = Boolean(config.projectId || config.directory);
|
||||
openNewSessionDraft({
|
||||
selectedProjectId: config.projectId,
|
||||
directoryOverride: config.directory,
|
||||
target: hasProjectTarget ? 'project' : 'chat',
|
||||
selectedProjectId: hasProjectTarget ? config.projectId : CHAT_DRAFT_PROJECT_ID,
|
||||
directoryOverride: hasProjectTarget ? config.directory : null,
|
||||
preserveDirectoryOverride: Boolean(config.directory),
|
||||
});
|
||||
}, [config, currentSessionId, draftOpen, openNewSessionDraft]);
|
||||
@@ -278,10 +282,11 @@ const MiniChatPresencePublisher: React.FC = () => {
|
||||
const useSessionUnavailable = (config: MiniChatConfig): boolean => {
|
||||
const sessions = useSessions();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const draftOpen = useSessionUIStore((state) => state.newSessionDraft.open);
|
||||
const [timedOut, setTimedOut] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config.mode !== 'session' || !config.sessionId || currentSessionId === config.sessionId) {
|
||||
if (draftOpen || config.mode !== 'session' || !config.sessionId || currentSessionId) {
|
||||
setTimedOut(false);
|
||||
return;
|
||||
}
|
||||
@@ -291,7 +296,7 @@ const useSessionUnavailable = (config: MiniChatConfig): boolean => {
|
||||
}
|
||||
const timeout = window.setTimeout(() => setTimedOut(true), 5000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [config.mode, config.sessionId, currentSessionId, sessions]);
|
||||
}, [config.mode, config.sessionId, currentSessionId, draftOpen, sessions]);
|
||||
|
||||
return timedOut;
|
||||
};
|
||||
@@ -321,6 +326,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<ElectronMiniChatContent config={config} />
|
||||
<AppLinkConfirmDialog />
|
||||
<Toaster />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
import { PlanView } from '@/components/views/PlanView';
|
||||
import { SettingsView } from '@/components/views/SettingsView';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
@@ -109,7 +110,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
const [workspaceTab, setWorkspaceTab] = React.useState<MobileWorkspaceTab>('changes');
|
||||
// A plan opened from the workspace drawer's Notes tab, shown as a fullscreen
|
||||
// layer on top of it (back returns to the notes).
|
||||
const [openPlan, setOpenPlan] = React.useState<{ path: string; title: string } | null>(null);
|
||||
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null);
|
||||
const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav');
|
||||
// When set, the Changes surface opens directly into the per-file diff for this path.
|
||||
const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null);
|
||||
@@ -540,7 +541,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<PlanView
|
||||
targetPath={openPlan.path}
|
||||
projectPlanId={openPlan.id}
|
||||
onNavigatedToChat={() => {
|
||||
closeSurface();
|
||||
closeWorkspace();
|
||||
@@ -1258,6 +1259,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
||||
setConnectionEpoch((value) => value + 1);
|
||||
}} />
|
||||
<AppLinkConfirmDialog />
|
||||
<Toaster position="top-center" offset="calc(var(--oc-safe-area-top, 0px) + 16px)" />
|
||||
{isInitialized ? <ConfigUpdateOverlay /> : null}
|
||||
</div>
|
||||
|
||||
@@ -388,6 +388,7 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
|
||||
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
|
||||
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
|
||||
tokens?: {
|
||||
total?: unknown;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
reasoning?: unknown;
|
||||
@@ -395,6 +396,11 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
|
||||
};
|
||||
};
|
||||
if (message.role !== 'assistant' || !message.tokens) continue;
|
||||
// Multi-step turns accumulate the fields across API round-trips, so
|
||||
// summing them overstates the window. The server-reported total is the
|
||||
// final round-trip's window; sum only when the server did not send it.
|
||||
const reportedTotal = getTokenCount(message.tokens.total);
|
||||
if (reportedTotal > 0) return reportedTotal;
|
||||
const total = getTokenCount(message.tokens.input)
|
||||
+ getTokenCount(message.tokens.output)
|
||||
+ getTokenCount(message.tokens.reasoning)
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
|
||||
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
|
||||
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
||||
import { useSwitcherItems } from '@/components/session/sidebar/shell/useSwitcherItems';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getProjectLabel, normalizePath } from './mobilePaths';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -188,11 +189,8 @@ const findExactProjectMatch = (projects: ProjectMeta[], directory: string): Proj
|
||||
return projects.find((project) => projectMatchesExactDirectory(project, normalizedDirectory)) ?? null;
|
||||
};
|
||||
|
||||
const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => {
|
||||
if (!query) return true;
|
||||
const haystack = `${session.title ?? ''} ${session.id} ${getSessionDirectory(session)} ${projectLabel}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
};
|
||||
const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean =>
|
||||
matchesRankQuery([session.title, session.id, getSessionDirectory(session), projectLabel], query);
|
||||
|
||||
const MobileProjectIcon: React.FC<{
|
||||
project: Pick<ProjectMeta, 'id' | 'icon' | 'color' | 'iconImage' | 'iconBackground'>;
|
||||
@@ -1355,7 +1353,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
const filteredNodes = React.useMemo(() => {
|
||||
if (!normalizedQuery) return projectNodes;
|
||||
return projectNodes.filter((node) => {
|
||||
if (`${node.project.label} ${node.project.path}`.toLowerCase().includes(normalizedQuery)) return true;
|
||||
if (matchesRankQuery([node.project.label, node.project.path], normalizedQuery)) return true;
|
||||
return node.buckets.some((bucket) =>
|
||||
bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)),
|
||||
);
|
||||
@@ -1385,8 +1383,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
|
||||
const searchProjectMatches = React.useMemo(() => {
|
||||
if (!normalizedQuery) return [] as Array<ProjectMeta & { sessionCount: number }>;
|
||||
return projectsMeta
|
||||
.filter((project) => `${project.label} ${project.path}`.toLowerCase().includes(normalizedQuery))
|
||||
return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path])
|
||||
.map((project) => ({
|
||||
...project,
|
||||
sessionCount: sessions.filter((session) => {
|
||||
|
||||
@@ -105,7 +105,7 @@ export const MobileWorkspaceDrawer: React.FC<{
|
||||
/** When set, the Changes tab opens directly into the per-file diff. */
|
||||
pendingChangesDiff: { path: string; staged: boolean } | null;
|
||||
/** Notes tab: opens a plan fullscreen (layered above the drawer). */
|
||||
onOpenPlan: (plan: { path: string; title: string }) => void;
|
||||
onOpenPlan: (plan: { id: string; title: string }) => void;
|
||||
/** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */
|
||||
onOpenMcpSettings: () => void;
|
||||
variant?: 'drawer' | 'panel';
|
||||
|
||||
@@ -8,8 +8,10 @@ import { Toaster } from '@/components/ui/sonner';
|
||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
@@ -56,6 +58,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
usePushVisibilityBeacon({ enabled: true });
|
||||
useWindowTitle();
|
||||
useRouter();
|
||||
useGlobalSessionsPolling(panelType !== 'agentManager');
|
||||
|
||||
React.useEffect(() => {
|
||||
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
|
||||
@@ -108,6 +111,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<AgentManagerView />
|
||||
<AppLinkConfirmDialog />
|
||||
<OpenCodeUpdateToast />
|
||||
<Toaster position="top-center" />
|
||||
</div>
|
||||
@@ -127,6 +131,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<VSCodeLayout />
|
||||
<AppLinkConfirmDialog />
|
||||
<OpenCodeUpdateToast />
|
||||
<Toaster position="top-center" />
|
||||
<ConfigUpdateOverlay />
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/c
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
|
||||
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { addRuntimeProxyHeaders, runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
|
||||
import { recordMobileConnectDebug } from './mobileConnectionDebug';
|
||||
@@ -346,11 +346,11 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise<Mobil
|
||||
if (!isCapacitorApp()) return null;
|
||||
try {
|
||||
const { CapacitorHttp } = await import('@capacitor/core');
|
||||
const headers = Object.fromEntries(new Headers(init?.headers).entries());
|
||||
const requestHeaders = addRuntimeProxyHeaders(url, new Headers(init?.headers));
|
||||
const response = await CapacitorHttp.request({
|
||||
url,
|
||||
method: init?.method || 'GET',
|
||||
headers,
|
||||
headers: Object.fromEntries(requestHeaders.entries()),
|
||||
data: getJsonRequestData(init?.body),
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { RuntimeEndpointChangedDetail } from '@/lib/runtime-switch';
|
||||
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
@@ -36,7 +36,6 @@ export const reconnectAppForTransportSwitch = (): void => {
|
||||
|
||||
export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedDetail): void => {
|
||||
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
if (detail.previousRuntimeKey) {
|
||||
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
|
||||
}
|
||||
@@ -52,6 +51,9 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
lastDisconnectReason: null,
|
||||
});
|
||||
useProjectsStore.getState().resetForRuntimeSwitch();
|
||||
// Notes, todos, plans and the pinned-context bookkeeping are keyed by a
|
||||
// path-derived project id, which two runtimes can collide on.
|
||||
useProjectContextStore.getState().reset();
|
||||
// Cross-project session list (mobile sessions sheet & co) belongs to the
|
||||
// previous instance — drop it so stale sessions can't linger after a switch.
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
|
||||
@@ -67,7 +69,6 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
resetStreamingState();
|
||||
queueMicrotask(() => void syncDesktopSettings());
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Command Code</title>
|
||||
<path
|
||||
d="M8 2.4C5.7 2.45 4.35 2.75 3.45 3.7C2.55 4.65 2.3 6.1 2.25 8.35C2.2 10.15 2.2 13.85 2.25 15.65C2.3 17.9 2.55 19.35 3.45 20.3C4.35 21.25 5.7 21.55 8 21.6C9.9 21.65 14.1 21.65 16 21.6C18.3 21.55 19.65 21.25 20.55 20.3C21.45 19.35 21.7 17.9 21.75 15.65C21.8 13.85 21.8 10.15 21.75 8.35C21.7 6.1 21.45 4.65 20.55 3.7C19.65 2.75 18.3 2.45 16 2.4C14.1 2.35 9.9 2.35 8 2.4Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 8H14V6.5C14 4.567 15.567 3 17.5 3C19.433 3 21 4.567 21 6.5C21 8.433 19.433 10 17.5 10H16V14H17.5C19.433 14 21 15.567 21 17.5C21 19.433 19.433 21 17.5 21C15.567 21 14 19.433 14 17.5V16H10V17.5C10 19.433 8.433 21 6.5 21C4.567 21 3 19.433 3 17.5C3 15.567 4.567 14 6.5 14H8V10H6.5C4.567 10 3 8.433 3 6.5C3 4.567 4.567 3 6.5 3C8.433 3 10 4.567 10 6.5V8ZM8 8V6.5C8 5.67157 7.32843 5 6.5 5C5.67157 5 5 5.67157 5 6.5C5 7.32843 5.67157 8 6.5 8H8ZM8 16H6.5C5.67157 16 5 16.6716 5 17.5C5 18.3284 5.67157 19 6.5 19C7.32843 19 8 18.3284 8 17.5V16ZM16 8H17.5C18.3284 8 19 7.32843 19 6.5C19 5.67157 18.3284 5 17.5 5C16.6716 5 16 5.67157 16 6.5V8ZM16 16V17.5C16 18.3284 16.6716 19 17.5 19C18.3284 19 19 18.3284 19 17.5C19 16.6716 18.3284 16 17.5 16H16ZM10 10V14H14V10H10Z"
|
||||
fill="currentColor"
|
||||
transform="translate(4.56 4.56) scale(.62)"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
|
||||
mock.module('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog');
|
||||
const {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
openAppLinkWithConfirmation,
|
||||
settleAppLinkConfirmation,
|
||||
} = await import('./appLinkConfirmation');
|
||||
|
||||
describe('AppLinkConfirmDialog', () => {
|
||||
beforeEach(() => {
|
||||
if (getAppLinkConfirmationSnapshot()) {
|
||||
settleAppLinkConfirmation('cancel');
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps cancel visible and focused beside both open choices', () => {
|
||||
void openAppLinkWithConfirmation('obsidian://open?vault=Notebook');
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<AppLinkConfirmDialog />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('>Cancel</button>');
|
||||
expect(markup).toContain('autofocus=""');
|
||||
expect(markup).toContain('>Open once</button>');
|
||||
expect(markup).toContain('>Trust and open</button>');
|
||||
|
||||
settleAppLinkConfirmation('cancel');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getUrlScheme } from '@/lib/url';
|
||||
|
||||
import {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
settleAppLinkConfirmation,
|
||||
subscribeAppLinkConfirmation,
|
||||
type AppLinkConfirmationChoice,
|
||||
} from './appLinkConfirmation';
|
||||
|
||||
/**
|
||||
* App-level dialog confirming application deep links (obsidian://, vscode://,
|
||||
* ...) rendered in chat markdown before the OS is asked to open them.
|
||||
* Dismissing via the close button, Escape, or the backdrop cancels the open.
|
||||
*/
|
||||
export const AppLinkConfirmDialog = () => {
|
||||
const { t } = useI18n();
|
||||
const request = React.useSyncExternalStore(
|
||||
subscribeAppLinkConfirmation,
|
||||
getAppLinkConfirmationSnapshot,
|
||||
getAppLinkConfirmationSnapshot,
|
||||
);
|
||||
|
||||
const url = request?.url ?? '';
|
||||
const scheme = getUrlScheme(url) ?? '';
|
||||
|
||||
const settle = React.useCallback((choice: AppLinkConfirmationChoice) => {
|
||||
settleAppLinkConfirmation(choice);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(request)}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
settle('cancel');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('chat.appLink.confirm.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{scheme
|
||||
? t('chat.appLink.confirm.description', { scheme: `${scheme}://` })
|
||||
: t('chat.appLink.confirm.descriptionPlain')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
|
||||
{url}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" autoFocus onClick={() => settle('cancel')}>
|
||||
{t('chat.appLink.confirm.cancel')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => settle('trust')}>
|
||||
{t('chat.appLink.confirm.trustAndOpen')}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => settle('open')}>
|
||||
{t('chat.appLink.confirm.open')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer';
|
||||
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useScrollShadow } from '@/components/ui/useScrollShadow';
|
||||
import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
|
||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||
import { TimelineDialog } from './TimelineDialog';
|
||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||
@@ -60,11 +60,14 @@ import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shel
|
||||
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance';
|
||||
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
|
||||
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
|
||||
const DRAFT_EXIT_DURATION_MS = 120;
|
||||
const COMPOSER_MOVE_DURATION_MS = 180;
|
||||
const CHAT_SCROLL_STYLE = {
|
||||
overflowAnchor: 'none',
|
||||
overscrollBehavior: 'contain',
|
||||
@@ -148,11 +151,15 @@ type ChatViewportProps = {
|
||||
currentSessionKey: string;
|
||||
isDesktopExpandedInput: boolean;
|
||||
isMobile: boolean;
|
||||
stickyUserHeader: boolean;
|
||||
directory?: string;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
messageListRef: React.RefObject<MessageListHandle | null>;
|
||||
pendingRevealWork: boolean;
|
||||
registerList: (list: TimelineListHandle | null) => void;
|
||||
anchorMessageId: string | null;
|
||||
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||
onAnchorSizeChanged: (messageId: string) => void;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onTimelineDataChange: () => void;
|
||||
renderedMessages: SessionMessageRecord[];
|
||||
isLoadingOlder: boolean;
|
||||
sessionIsWorking: boolean;
|
||||
@@ -164,10 +171,11 @@ type ChatViewportProps = {
|
||||
confirmedAt?: number;
|
||||
fallbackTimestamp?: number;
|
||||
} | null;
|
||||
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
handleHistoryScroll: () => void;
|
||||
scrollToBottom: () => void;
|
||||
endPinningReleased: boolean;
|
||||
// One-shot fade for content that replaced the hydration skeleton;
|
||||
// cached sessions render instantly without it.
|
||||
revealContent: boolean;
|
||||
sessionQuestions: QuestionRequest[];
|
||||
sessionPermissions: PermissionRequest[];
|
||||
isProgrammaticFollowActive: boolean;
|
||||
@@ -187,21 +195,24 @@ const ChatViewport = React.memo(({
|
||||
currentSessionKey,
|
||||
isDesktopExpandedInput,
|
||||
isMobile,
|
||||
stickyUserHeader,
|
||||
directory,
|
||||
scrollRef,
|
||||
messageListRef,
|
||||
pendingRevealWork,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onTimelineDataChange,
|
||||
renderedMessages,
|
||||
isLoadingOlder,
|
||||
sessionIsWorking,
|
||||
streamingMessageId,
|
||||
activeStreamingPhase,
|
||||
retryOverlay,
|
||||
handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
handleHistoryScroll,
|
||||
scrollToBottom,
|
||||
endPinningReleased,
|
||||
revealContent,
|
||||
sessionQuestions,
|
||||
sessionPermissions,
|
||||
isProgrammaticFollowActive,
|
||||
@@ -312,83 +323,96 @@ const ChatViewport = React.memo(({
|
||||
scrollRef.current?.focus({ preventScroll: true });
|
||||
}, [scrollRef]);
|
||||
|
||||
// Everything that used to sit beside the list inside the scroll container
|
||||
// now renders as the list's header/footer, so it keeps scrolling with the
|
||||
// rows exactly as before.
|
||||
const listHeader = React.useMemo(() => (
|
||||
showLoadOlderButton ? (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLoadOlder}
|
||||
disabled={isLoadingOlder}
|
||||
>
|
||||
{isLoadingOlder && (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('chat.history.loadOlder')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]);
|
||||
|
||||
const listFooter = React.useMemo(() => (
|
||||
<>
|
||||
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
||||
<div>
|
||||
{sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</>
|
||||
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
|
||||
|
||||
const scrollContainerProps = React.useMemo(() => ({
|
||||
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
|
||||
style: CHAT_SCROLL_STYLE,
|
||||
tabIndex: 0,
|
||||
onClick: focusScrollContainer,
|
||||
'data-scrollbar': 'chat',
|
||||
'data-scroll-shadow': 'true',
|
||||
'data-orientation': 'vertical',
|
||||
}), [focusScrollContainer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1'
|
||||
: 'flex-1',
|
||||
revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
|
||||
)}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<ScrollShadow
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||
ref={scrollRef}
|
||||
style={CHAT_SCROLL_STYLE}
|
||||
observeMutations={false}
|
||||
hideTopShadow={isMobile && stickyUserHeader}
|
||||
tabIndex={0}
|
||||
onClick={focusScrollContainer}
|
||||
onScroll={handleHistoryScroll}
|
||||
data-scroll-shadow="true"
|
||||
data-scrollbar="chat"
|
||||
>
|
||||
<div className="relative z-0 min-h-full">
|
||||
{showLoadOlderButton && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLoadOlder}
|
||||
disabled={isLoadingOlder}
|
||||
>
|
||||
{isLoadingOlder && (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('chat.history.loadOlder')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
disableStaging={pendingRevealWork}
|
||||
messages={renderedMessages}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
activeStreamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
onMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
scrollRef={scrollRef}
|
||||
directory={directory}
|
||||
/>
|
||||
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
||||
<div>
|
||||
{sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="mb-3">
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
messages={renderedMessages}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
activeStreamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
endPinningReleased={endPinningReleased}
|
||||
directory={directory}
|
||||
registerList={registerList}
|
||||
anchorMessageId={anchorMessageId}
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
// Zero end inset: the footer spacer already reserves the
|
||||
// zone the floating status row covers; adding its height
|
||||
// again produced a double-tall blank band at rest.
|
||||
composerOverlayHeight={0}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
scrollContainerProps={scrollContainerProps}
|
||||
/>
|
||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
||||
<PromptNavigatorRail
|
||||
turnIds={promptTurnIds}
|
||||
@@ -408,21 +432,18 @@ const ChatViewport = React.memo(({
|
||||
&& prev.currentSessionKey === next.currentSessionKey
|
||||
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.stickyUserHeader === next.stickyUserHeader
|
||||
&& prev.directory === next.directory
|
||||
&& prev.scrollRef === next.scrollRef
|
||||
&& prev.messageListRef === next.messageListRef
|
||||
&& prev.pendingRevealWork === next.pendingRevealWork
|
||||
&& prev.renderedMessages === next.renderedMessages
|
||||
&& prev.isLoadingOlder === next.isLoadingOlder
|
||||
&& prev.sessionIsWorking === next.sessionIsWorking
|
||||
&& prev.streamingMessageId === next.streamingMessageId
|
||||
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
||||
&& prev.retryOverlay === next.retryOverlay
|
||||
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
||||
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
||||
&& prev.handleHistoryScroll === next.handleHistoryScroll
|
||||
&& prev.scrollToBottom === next.scrollToBottom
|
||||
&& prev.endPinningReleased === next.endPinningReleased
|
||||
&& prev.revealContent === next.revealContent
|
||||
&& prev.sessionQuestions === next.sessionQuestions
|
||||
&& prev.sessionPermissions === next.sessionPermissions
|
||||
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
|
||||
@@ -475,9 +496,11 @@ const ReadOnlyPromptBanner: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-[var(--surface-background)] px-4 py-3 typography-ui-label text-muted-foreground">
|
||||
{t('chat.container.readOnlySubagentPromptBanner')}
|
||||
<div className="w-full py-3">
|
||||
<div className="chat-input-column">
|
||||
<div className="rounded-2xl border border-border/70 bg-[var(--surface-background)] px-4 py-3 text-center typography-ui-label text-muted-foreground">
|
||||
{t('chat.container.readOnlySubagentPromptBanner')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -502,19 +525,24 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea
|
||||
);
|
||||
};
|
||||
|
||||
const DraftWelcome: React.FC = () => {
|
||||
const DraftWelcome: React.FC<{ exiting?: boolean }> = ({ exiting = false }) => {
|
||||
const { t } = useI18n();
|
||||
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
|
||||
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
|
||||
const projectLabel = useProjectsStore(React.useCallback((state) => {
|
||||
if (draftTarget === 'chat') return null;
|
||||
const projectId = selectedProjectId ?? state.activeProjectId;
|
||||
const project = (projectId
|
||||
? state.projects.find((candidate) => candidate.id === projectId)
|
||||
: null) ?? state.projects[0] ?? null;
|
||||
return project ? getProjectDisplayLabel(project) : null;
|
||||
}, [selectedProjectId]));
|
||||
}, [draftTarget, selectedProjectId]));
|
||||
|
||||
return (
|
||||
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<div className={cn(
|
||||
'oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
|
||||
exiting && 'pointer-events-none opacity-0',
|
||||
)}>
|
||||
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||
{renderDraftTitle(
|
||||
projectLabel
|
||||
@@ -558,6 +586,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
// Session UI state
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
|
||||
const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId);
|
||||
const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
@@ -731,12 +761,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const draftOpen = Boolean(newSessionDraft?.open);
|
||||
const isManagedChatContext = draftOpen
|
||||
? newSessionDraft?.target === 'chat'
|
||||
: isChatDirectoryPath(effectiveSessionDirectory);
|
||||
// A draft can target another project or a pending worktree before it has a
|
||||
// session. Keep the panel on that same directory so its project, MCP, and
|
||||
// usage readouts describe where the draft will run rather than the project
|
||||
// the user came from.
|
||||
const workStatusDirectory = draftOpen
|
||||
? newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory
|
||||
? (isManagedChatContext ? null : newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory)
|
||||
: effectiveSessionDirectory;
|
||||
const initError = useGlobalSyncStore((s) => s.error);
|
||||
// Despite the historical name, this now covers mobile too: the mobile
|
||||
@@ -748,7 +781,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
// row that holds both columns, so its width never depends on the panel's
|
||||
// own visibility.
|
||||
const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({
|
||||
directory: workStatusDirectory,
|
||||
isMobile,
|
||||
isVSCode,
|
||||
});
|
||||
@@ -785,6 +817,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
return () => setWorkStatusPanelVisible(false);
|
||||
}, [setWorkStatusPanelVisible, showWorkStatusPanel]);
|
||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
||||
// Session keys that showed the hydration skeleton this app run; their
|
||||
// content gets a one-shot reveal fade once it replaces the skeleton.
|
||||
const hydrationRevealKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
|
||||
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
|
||||
|
||||
@@ -877,23 +913,67 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
activeTurnChangeRef.current(turnId);
|
||||
}, []);
|
||||
|
||||
// The composer sits below the timeline, but the status/working row floats
|
||||
// OVER the timeline's bottom edge; its measured height keeps the live
|
||||
// streaming line above it and reserves matching end inset in the list.
|
||||
const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0);
|
||||
const composerOverlayHeight = statusOverlayHeight;
|
||||
const statusOverlayObserverRef = React.useRef<ResizeObserver | null>(null);
|
||||
const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => {
|
||||
statusOverlayObserverRef.current?.disconnect();
|
||||
statusOverlayObserverRef.current = null;
|
||||
if (!node || !globalThis.ResizeObserver) {
|
||||
setStatusOverlayHeight(0);
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
// +8 for the mb-2 gap between the row and the composer, which the
|
||||
// node's own box does not include.
|
||||
const height = node.getBoundingClientRect().height + 8;
|
||||
setStatusOverlayHeight((prev) => (Math.abs(prev - height) < 1 ? prev : height));
|
||||
};
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(node);
|
||||
statusOverlayObserverRef.current = observer;
|
||||
update();
|
||||
}, []);
|
||||
React.useEffect(() => () => {
|
||||
statusOverlayObserverRef.current?.disconnect();
|
||||
statusOverlayObserverRef.current = null;
|
||||
}, []);
|
||||
const lastUserMessageId = React.useMemo(() => {
|
||||
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = sessionMessages[index];
|
||||
if (message.info.role === 'user') {
|
||||
return message.info.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [sessionMessages]);
|
||||
|
||||
const {
|
||||
scrollRef,
|
||||
notifyContentChange: handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
scrollNode,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onManualNavigation,
|
||||
onTimelineDataChange,
|
||||
goToBottom,
|
||||
scrollToBottomOnSend,
|
||||
releaseAutoFollow,
|
||||
restoreSnapshot,
|
||||
isPinned,
|
||||
isFollowingProgrammatically,
|
||||
showScrollButton,
|
||||
} = useChatAutoFollow({
|
||||
userOwnsScroll,
|
||||
} = useChatTimelineScroll({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
isMobile,
|
||||
composerOverlayHeight,
|
||||
lastUserMessageId,
|
||||
onActiveTurnChange: handleActiveTurnChange,
|
||||
});
|
||||
|
||||
@@ -908,33 +988,49 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
messageListRef,
|
||||
loadMoreMessages,
|
||||
goToBottom,
|
||||
releaseAutoFollow,
|
||||
releaseAutoFollow: onManualNavigation,
|
||||
isPinned,
|
||||
showScrollButton,
|
||||
});
|
||||
// The list owns the scroll element, so the shadows and the load-older
|
||||
// trigger bind to its node rather than to a wrapper we render.
|
||||
const scrollNodeRef = React.useMemo(() => ({ current: scrollNode }), [scrollNode]);
|
||||
useScrollShadow(scrollNodeRef, {
|
||||
observeMutations: false,
|
||||
hideTopShadow: isMobile && stickyUserHeader,
|
||||
});
|
||||
|
||||
const handleHistoryScroll = timelineController.handleHistoryScroll;
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode) return;
|
||||
const onScroll = () => handleHistoryScroll();
|
||||
scrollNode.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
scrollNode.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [handleHistoryScroll, scrollNode]);
|
||||
|
||||
const resumeToLatestInstant = React.useCallback(() => {
|
||||
goToBottom('instant');
|
||||
}, [goToBottom]);
|
||||
|
||||
// Mobile loads older history via an explicit top button instead of a
|
||||
// scroll-position trigger (see handleHistoryScroll in the controller).
|
||||
const showLoadOlderButton = isMobileSurfaceRuntime()
|
||||
&& timelineController.historySignals.canLoadEarlier;
|
||||
const timelineLoadEarlier = timelineController.loadEarlier;
|
||||
const handleLoadOlderClick = React.useCallback(() => {
|
||||
// Loading older history is an explicit move INTO the past: release
|
||||
// live follow first, or the prepend's content growth would trigger an
|
||||
// end correction and throw the viewport to the bottom.
|
||||
onManualNavigation();
|
||||
void timelineLoadEarlier({ userInitiated: true });
|
||||
}, [timelineLoadEarlier]);
|
||||
}, [onManualNavigation, timelineLoadEarlier]);
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||
}, [timelineController.handleActiveTurnChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (sessionPermissions.length === 0 && sessionQuestions.length === 0) {
|
||||
return;
|
||||
}
|
||||
handleMessageContentChange('permission');
|
||||
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
|
||||
|
||||
const navigation = useChatTurnNavigation({
|
||||
sessionId: currentSessionId,
|
||||
turnIds: timelineController.turnIds,
|
||||
@@ -944,7 +1040,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
resumeToBottom: timelineController.resumeToBottomInstant,
|
||||
});
|
||||
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
|
||||
void navigation.scrollToTurnId(turnId, { behavior: 'smooth' });
|
||||
// Instant on purpose: a long smooth scroll through a virtualized
|
||||
// timeline gets cancelled by row remounts and lands mid-way or on the
|
||||
// wrong message; a teleport always arrives.
|
||||
void navigation.scrollToTurnId(turnId, { behavior: 'auto' });
|
||||
}, [navigation]);
|
||||
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
|
||||
const showPromptNavigator = !isMobile
|
||||
@@ -992,8 +1091,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { activeMainTab } = useUIStore.getState();
|
||||
if (activeMainTab !== 'chat' || hasBlockingChatOverlay()) {
|
||||
if (hasBlockingChatOverlay()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1058,6 +1156,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
const isSessionHydrating =
|
||||
Boolean(currentSessionId)
|
||||
&& !hasRenderableSessionSnapshot;
|
||||
React.useEffect(() => {
|
||||
if (isSessionHydrating || hydrationRevealKeyRef.current === null) return;
|
||||
// One-shot: forget the key after the reveal animation has played so a
|
||||
// later (now cached) visit to the same session opens instantly.
|
||||
const timer = setTimeout(() => {
|
||||
hydrationRevealKeyRef.current = null;
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSessionHydrating, currentSessionKey]);
|
||||
const retrySessionLoad = React.useCallback(() => {
|
||||
if (!messagesEnabled || !currentSessionId) return;
|
||||
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
|
||||
@@ -1071,7 +1178,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
lastScrolledSessionKeyRef.current = currentSessionKey;
|
||||
if (hasHashTarget) {
|
||||
// Hash navigation handler will scroll to target; we just release auto-follow.
|
||||
releaseAutoFollow();
|
||||
onManualNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1083,7 +1190,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
} else {
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
|
||||
}, [active, currentSessionId, currentSessionKey, onManualNavigation, restoreSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!messagesEnabled || !currentSessionId) return;
|
||||
@@ -1091,11 +1198,75 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
void ensureSessionRenderable(currentSessionId);
|
||||
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]);
|
||||
|
||||
const composerSlotRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const previousComposerRectRef = React.useRef<DOMRect | null>(null);
|
||||
const previousDraftOpenRef = React.useRef(draftOpen);
|
||||
const previousDraftLayoutVisibleRef = React.useRef(draftOpen);
|
||||
const [draftExitAnimating, setDraftExitAnimating] = React.useState(false);
|
||||
const shouldAnimateDraftTransition = Boolean(
|
||||
currentSessionId && materializedDraftSessionId === currentSessionId,
|
||||
);
|
||||
const draftPresentationExiting = draftExitAnimating
|
||||
|| (previousDraftOpenRef.current && !draftOpen && shouldAnimateDraftTransition);
|
||||
const draftLayoutVisible = draftOpen || draftPresentationExiting;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (draftOpen) {
|
||||
setDraftExitAnimating(false);
|
||||
return;
|
||||
}
|
||||
if (!previousDraftOpenRef.current || !shouldAnimateDraftTransition) return;
|
||||
|
||||
setDraftExitAnimating(true);
|
||||
const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [draftOpen, shouldAnimateDraftTransition]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
previousDraftOpenRef.current = draftOpen;
|
||||
}, [draftOpen]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const composerSlot = composerSlotRef.current;
|
||||
if (!composerSlot) return;
|
||||
|
||||
const composerEditor = composerSlot.querySelector('[data-testid="chat-input"]');
|
||||
const currentRect = composerEditor?.getBoundingClientRect() ?? composerSlot.getBoundingClientRect();
|
||||
const previousRect = previousComposerRectRef.current;
|
||||
const leftDraftLayout = previousDraftLayoutVisibleRef.current
|
||||
&& !draftLayoutVisible
|
||||
&& Boolean(currentSessionId);
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
|
||||
const shouldMoveComposer = leftDraftLayout && shouldAnimateDraftTransition;
|
||||
if (shouldMoveComposer && previousRect && !reduceMotion && !useCompactDraftLayout && !isDesktopExpandedInput) {
|
||||
const deltaX = previousRect.left - currentRect.left;
|
||||
const deltaY = previousRect.top - currentRect.top;
|
||||
composerSlot.animate(
|
||||
[
|
||||
{ transform: `translate(${deltaX}px, ${deltaY}px)` },
|
||||
{ transform: 'translate(0, 0)' },
|
||||
],
|
||||
{ duration: COMPOSER_MOVE_DURATION_MS, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
|
||||
);
|
||||
}
|
||||
previousComposerRectRef.current = currentRect;
|
||||
previousDraftLayoutVisibleRef.current = draftLayoutVisible;
|
||||
if (leftDraftLayout && currentSessionId) {
|
||||
clearMaterializedDraftSession(currentSessionId);
|
||||
}
|
||||
}, [
|
||||
clearMaterializedDraftSession,
|
||||
currentSessionId,
|
||||
draftLayoutVisible,
|
||||
isDesktopExpandedInput,
|
||||
shouldAnimateDraftTransition,
|
||||
useCompactDraftLayout,
|
||||
]);
|
||||
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
// With auto-open, the draft welcome opens on the next tick (effect below),
|
||||
// so the empty state is only ever transient here — render a neutral
|
||||
// background instead of flashing the logo / "start a new chat" on refresh.
|
||||
// Keep the empty state when there's nothing to auto-open or an init error to show.
|
||||
// The auto-open effect runs on the next tick. Use a neutral background
|
||||
// until then instead of flashing the standard empty state.
|
||||
if (autoOpenDraft && !initError) {
|
||||
return <div className="flex h-full flex-col bg-background" />;
|
||||
}
|
||||
@@ -1106,82 +1277,42 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentSessionId && draftOpen) {
|
||||
return (
|
||||
// No transform on this root: it would become the containing block for
|
||||
// the fullscreen composer's position:fixed visual-viewport pinning in
|
||||
// mobile browsers (see ChatInput's composerFormRef effect).
|
||||
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
|
||||
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col bg-background">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 bg-background'
|
||||
: useCompactDraftLayout
|
||||
? 'bg-background px-0'
|
||||
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
{workStatusOverlayMountable ? (
|
||||
<WorkStatusPanel
|
||||
overlay
|
||||
visible={showWorkStatusOverlay}
|
||||
sessionId={null}
|
||||
directory={workStatusDirectory ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{workStatusPanelMountable ? (
|
||||
<WorkStatusPanel
|
||||
visible={showWorkStatusPanel}
|
||||
sessionId={null}
|
||||
directory={workStatusDirectory ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const sessionSurface = (() => {
|
||||
if (draftOpen || draftPresentationExiting) {
|
||||
if (!useCompactDraftLayout || isDesktopExpandedInput) {
|
||||
return null;
|
||||
}
|
||||
return <DraftWelcome exiting={draftPresentationExiting} />;
|
||||
}
|
||||
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
|
||||
if (showHydrationSkeleton) {
|
||||
hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
|
||||
}
|
||||
if (showHydrationSkeleton) {
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
|
||||
<div className="max-w-sm text-center">
|
||||
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
|
||||
<Icon name="error-warning" className="size-4" />
|
||||
</div>
|
||||
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
||||
{t('chat.container.sessionLoadError.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
return (
|
||||
<div data-composer-bound className="relative flex h-full flex-col bg-background">
|
||||
{returnToParentButton}
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
|
||||
<div className="max-w-sm text-center">
|
||||
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
|
||||
<Icon name="error-warning" className="size-4" />
|
||||
</div>
|
||||
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
||||
{t('chat.container.sessionLoadError.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 bg-background">
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-composer-bound className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1'
|
||||
return (
|
||||
<div
|
||||
data-chat-hydration-skeleton=""
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
|
||||
)}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
@@ -1192,20 +1323,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
<div className="chat-message-column">
|
||||
<div className="space-y-2.5 px-4 py-3">
|
||||
<div className="space-y-1.5">
|
||||
{item.toolRows.map((row) => {
|
||||
return (
|
||||
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
|
||||
<Skeleton className="h-3.5 w-3.5 rounded-full flex-shrink-0" />
|
||||
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
|
||||
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{item.toolRows.map((row) => (
|
||||
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
|
||||
<Skeleton className="h-3.5 w-3.5 shrink-0 rounded-full" />
|
||||
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
|
||||
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} />
|
||||
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} />
|
||||
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} />
|
||||
{item.textWidths.map((width, index) => (
|
||||
<Skeleton key={`${item.id}-text-${index}`} className={cn('h-4 rounded-md', width)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1214,79 +1343,45 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
return (
|
||||
// No transform here either — same fixed-positioning constraint as the
|
||||
// draft branch above.
|
||||
<div data-composer-bound className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1'
|
||||
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
|
||||
)}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
{!isDesktopExpandedInput ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<ChatEmptyState />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
|
||||
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<ChatViewport
|
||||
currentSessionId={currentSessionId}
|
||||
currentSessionKey={currentSessionKey ?? currentSessionId}
|
||||
return (
|
||||
<ChatViewport
|
||||
currentSessionId={currentSessionId ?? ''}
|
||||
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
|
||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||
isMobile={isMobile}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
directory={effectiveSessionDirectory}
|
||||
scrollRef={scrollRef}
|
||||
registerList={registerList}
|
||||
anchorMessageId={anchorMessageId}
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
messageListRef={messageListRef}
|
||||
pendingRevealWork={timelineController.pendingRevealWork}
|
||||
renderedMessages={timelineController.renderedMessages}
|
||||
isLoadingOlder={timelineController.isLoadingOlder}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
streamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
handleMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
handleHistoryScroll={timelineController.handleHistoryScroll}
|
||||
scrollToBottom={resumeToLatestInstant}
|
||||
endPinningReleased={userOwnsScroll}
|
||||
revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)}
|
||||
sessionQuestions={sessionQuestions}
|
||||
sessionPermissions={sessionPermissions}
|
||||
isProgrammaticFollowActive={isFollowingProgrammatically}
|
||||
@@ -1300,22 +1395,69 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
isLoadingOlderPrompts={timelineController.isLoadingOlder}
|
||||
onLoadEarlierPrompts={handleLoadOlderClick}
|
||||
/>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
|
||||
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
{sessionSurface}
|
||||
|
||||
<div
|
||||
ref={composerSlotRef}
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
'relative z-10 flex min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: draftLayoutVisible && !useCompactDraftLayout
|
||||
? 'flex-1 items-center justify-center bg-background pb-[6vh]'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
|
||||
<ScrollToBottomButton
|
||||
visible={timelineController.showScrollToBottom}
|
||||
onClick={navigation.resumeToLatest}
|
||||
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
|
||||
<>
|
||||
<ScrollToBottomButton
|
||||
visible={timelineController.showScrollToBottom}
|
||||
working={sessionIsWorking}
|
||||
onClick={navigation.resumeToLatest}
|
||||
/>
|
||||
{/* Same anchor and column as the pill, so the status
|
||||
row and the pill it hands off to share the exact
|
||||
distance from the input and the same left edge. */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
|
||||
userOwnsScroll && 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="chat-input-column">
|
||||
{/* The glass chip itself is rendered inside
|
||||
StatusRow (its root is a size container
|
||||
that cannot shrink-wrap). */}
|
||||
<div
|
||||
ref={onStatusOverlayNode}
|
||||
className={cn(
|
||||
'[&:not(:has(*))]:hidden',
|
||||
userOwnsScroll ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{promptReadOnly ? (
|
||||
<ReadOnlyPromptBanner />
|
||||
) : (
|
||||
<ChatInput
|
||||
active={active}
|
||||
scrollToBottom={scrollToBottomOnSend}
|
||||
scrollToLatest={resumeToLatestInstant}
|
||||
draftPresentationExiting={draftPresentationExiting}
|
||||
/>
|
||||
)}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
|
||||
{/* Inside the chat column, not beside it: as a row sibling it took
|
||||
@@ -1327,6 +1469,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
visible={showWorkStatusOverlay}
|
||||
sessionId={currentSessionId ?? null}
|
||||
directory={workStatusDirectory ?? null}
|
||||
repositoryEnabled={!isManagedChatContext}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1349,6 +1492,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
visible={showWorkStatusPanel}
|
||||
sessionId={currentSessionId ?? null}
|
||||
directory={workStatusDirectory ?? null}
|
||||
repositoryEnabled={!isManagedChatContext}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,12 @@ import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, typ
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { prepareLocalAttachments, useInputStore } from '@/sync/input-store';
|
||||
import {
|
||||
ACCEPTED_ATTACHMENT_EXTENSIONS,
|
||||
ATTACHMENT_ACCEPT,
|
||||
getUnsupportedAttachmentInputs,
|
||||
isDocumentAttachmentFilename,
|
||||
type AttachmentInputModality,
|
||||
} from '@/sync/attachment-files';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
@@ -20,10 +21,10 @@ import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { useUserMessageHistory } from "@/sync/sync-context";
|
||||
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import {
|
||||
createChatDraftIdentity,
|
||||
readChatDraft,
|
||||
@@ -32,6 +33,9 @@ import {
|
||||
type ChatDraftSnapshot,
|
||||
} from '@/lib/chatDraftPersistence';
|
||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||
import { BtwPanel } from './btw/BtwPanel';
|
||||
import { useBtwPanelState } from './btw/useBtwPanelState';
|
||||
import { destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
|
||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
@@ -44,12 +48,12 @@ import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ModelControls } from './ModelControls';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
import { StatusRow } from './StatusRow';
|
||||
import { ComposerStatusBar } from './ComposerStatusBar';
|
||||
import { PendingChangesBar } from './PendingChangesBar';
|
||||
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
||||
import { MobileAgentButton } from './MobileAgentButton';
|
||||
import { MobileModelButton } from './MobileModelButton';
|
||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { useCurrentSessionActivity, useSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { toast } from '@/components/ui';
|
||||
// useMessageStore removed — messages now come from sync system
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -216,12 +220,17 @@ const MemoModelControls = React.memo(ModelControls);
|
||||
const MemoComposerDictation = React.memo(ComposerDictation);
|
||||
const MemoMobileAgentButton = React.memo(MobileAgentButton);
|
||||
const MemoMobileModelButton = React.memo(MobileModelButton);
|
||||
const MemoStatusRow = React.memo(StatusRow);
|
||||
const MemoComposerStatusBar = React.memo(ComposerStatusBar);
|
||||
|
||||
interface ChatInputProps {
|
||||
onOpenSettings?: () => void;
|
||||
scrollToBottom?: () => void;
|
||||
// Queued sends do not create a user row (the queue delivers later), so
|
||||
// the anchor-arming scrollToBottom is wrong for them; this returns the
|
||||
// viewport to the live edge instead.
|
||||
scrollToLatest?: () => void;
|
||||
active?: boolean;
|
||||
draftPresentationExiting?: boolean;
|
||||
}
|
||||
|
||||
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
|
||||
@@ -235,7 +244,13 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
|
||||
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
|
||||
};
|
||||
|
||||
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom, active = true }) => {
|
||||
const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onOpenSettings,
|
||||
scrollToBottom,
|
||||
scrollToLatest,
|
||||
active = true,
|
||||
draftPresentationExiting = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Track if we restored a draft on mount (for text selection)
|
||||
const initialDraftRef = React.useRef<string | null>(null);
|
||||
@@ -307,6 +322,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const currentSessionDirectoryForSync = useSessionUIStore(
|
||||
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
|
||||
);
|
||||
// btw mode: the CURRENT session's metadata links an active btw fork and
|
||||
// the panel is expanded, so this composer's sends route to the fork
|
||||
// instead of the main session. Collapsed keeps the fork alive (chip stays
|
||||
// visible) while the composer talks to the main session again.
|
||||
const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined);
|
||||
const btwSessionId = btwPanel.btwSessionId;
|
||||
const btwDirectory = btwPanel.btwDirectory;
|
||||
const btwSessionRef = React.useMemo<BtwSessionRef | null>(
|
||||
() => (currentSessionId && btwSessionId && btwDirectory
|
||||
? { parentSessionId: currentSessionId, btwSessionId, directory: btwDirectory }
|
||||
: null),
|
||||
[btwDirectory, btwSessionId, currentSessionId],
|
||||
);
|
||||
const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed;
|
||||
const activeRuntimeKey = getRuntimeKey();
|
||||
const chatDraftIdentity = React.useMemo(
|
||||
() => createChatDraftIdentity(
|
||||
@@ -324,6 +353,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
|
||||
const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled);
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory);
|
||||
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
|
||||
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
|
||||
const attachedFiles = useInputStore((s) => s.attachedFiles);
|
||||
@@ -334,6 +364,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit);
|
||||
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
||||
const pendingInputText = useInputStore((s) => s.pendingInputText);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!newSessionDraftOpen || newSessionDraft.target !== 'chat' || message.trim().length === 0) return;
|
||||
void prepareChatDraftDirectory();
|
||||
}, [message, newSessionDraft.target, newSessionDraftOpen, prepareChatDraftDirectory]);
|
||||
const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts);
|
||||
const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort);
|
||||
const abortCurrentOperation = React.useCallback(
|
||||
@@ -549,7 +584,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const availableSkills = useSkillsStore((s) => s.skills);
|
||||
const knownSlashNames = React.useMemo(() => {
|
||||
const names = new Set<string>([
|
||||
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
|
||||
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'btw', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
|
||||
]);
|
||||
if (!isMobile && !isVSCodeRuntime()) names.add('handoff-review');
|
||||
for (const command of availableCommands) names.add(command.name.toLowerCase());
|
||||
@@ -596,59 +631,62 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[],
|
||||
);
|
||||
|
||||
const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => {
|
||||
const resolveInlineFileMention = React.useCallback((mentionPath: string): { serverPath: string; filename: string } | null => {
|
||||
const kind = classifyMention(mentionPath, {
|
||||
knownAgentNames: knownAgentNamesRef.current,
|
||||
confirmedMentions: confirmedMentionsRef.current,
|
||||
});
|
||||
if (kind !== 'file') return null;
|
||||
|
||||
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
||||
if (!normalizedMentionPath) return null;
|
||||
|
||||
const clientDirectory = opencodeClient.getDirectory() || '';
|
||||
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
let serverPath: string | null = null;
|
||||
if (mentionPath.startsWith('/')) {
|
||||
serverPath = mentionPath.replace(/\\/g, '/');
|
||||
} else if (root) {
|
||||
serverPath = `${root}/${normalizedMentionPath}`;
|
||||
}
|
||||
if (!serverPath) return null;
|
||||
|
||||
return {
|
||||
serverPath: serverPath.replace(/\/+/g, '/'),
|
||||
filename: normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath,
|
||||
};
|
||||
}, [chatSearchDirectory]);
|
||||
|
||||
const extractInlineFileMentions = React.useCallback((
|
||||
rawText: string,
|
||||
preparedDocumentMentions?: ReadonlyMap<string, AttachedFile[]>,
|
||||
) => {
|
||||
if (!rawText || !rawText.includes('@')) {
|
||||
return { sanitizedText: rawText, attachments: [] };
|
||||
}
|
||||
|
||||
const clientDirectory = opencodeClient.getDirectory() || '';
|
||||
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const seenPaths = new Set<string>();
|
||||
const attachments: AttachedFile[] = [];
|
||||
|
||||
for (const token of scanMentions(rawText)) {
|
||||
const mentionPath = token.name;
|
||||
const kind = classifyMention(mentionPath, {
|
||||
knownAgentNames: knownAgentNamesRef.current,
|
||||
confirmedMentions: confirmedMentionsRef.current,
|
||||
});
|
||||
// Agents are routed separately by parseAgentMentions; only file
|
||||
// references become attachments here.
|
||||
if (kind !== 'file') {
|
||||
const mention = resolveInlineFileMention(token.name);
|
||||
if (!mention || seenPaths.has(mention.serverPath)) continue;
|
||||
seenPaths.add(mention.serverPath);
|
||||
|
||||
const prepared = preparedDocumentMentions?.get(mention.serverPath);
|
||||
if (prepared) {
|
||||
attachments.push(...prepared);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
||||
if (!normalizedMentionPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const serverPath = mentionPath.startsWith('/')
|
||||
? mentionPath.replace(/\\/g, '/')
|
||||
: root
|
||||
? `${root}/${normalizedMentionPath}`
|
||||
: null;
|
||||
|
||||
if (!serverPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedServerPath = serverPath.replace(/\/+/g, '/');
|
||||
if (seenPaths.has(normalizedServerPath)) {
|
||||
continue;
|
||||
}
|
||||
seenPaths.add(normalizedServerPath);
|
||||
|
||||
const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath;
|
||||
attachments.push({
|
||||
id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
file: new File([], filename, { type: 'text/plain' }),
|
||||
filename,
|
||||
file: new File([], mention.filename, { type: 'text/plain' }),
|
||||
filename: mention.filename,
|
||||
mimeType: 'text/plain',
|
||||
size: 0,
|
||||
dataUrl: toServerFileUrl(normalizedServerPath),
|
||||
dataUrl: toServerFileUrl(mention.serverPath),
|
||||
source: 'server',
|
||||
serverPath: normalizedServerPath,
|
||||
serverPath: mention.serverPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -656,7 +694,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
sanitizedText: rawText,
|
||||
attachments,
|
||||
};
|
||||
}, [chatSearchDirectory]);
|
||||
}, [resolveInlineFileMention]);
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
@@ -719,55 +757,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[inlineDraftKey]
|
||||
)
|
||||
);
|
||||
const draftSourceKey = useInlineCommentDraftStore(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
|
||||
let previewConsole = 0;
|
||||
let previewAnnotation = 0;
|
||||
let review = 0;
|
||||
let terminal = 0;
|
||||
let prComment = 0;
|
||||
let prCheck = 0;
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === 'preview-console') previewConsole += 1;
|
||||
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
||||
else if (draft.source === 'terminal') terminal += 1;
|
||||
else if (draft.source === 'pr-comment') prComment += 1;
|
||||
else if (draft.source === 'pr-check') prCheck += 1;
|
||||
else review += 1;
|
||||
}
|
||||
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`;
|
||||
},
|
||||
[inlineDraftKey]
|
||||
)
|
||||
);
|
||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const hasDrafts = draftCount > 0;
|
||||
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const terminalContextDrafts = terminalContextCount > 0
|
||||
? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal')
|
||||
: [];
|
||||
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => {
|
||||
if (!inlineDraftTarget) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === source) {
|
||||
removeInlineCommentDraft(inlineDraftTarget, draft.id);
|
||||
}
|
||||
}
|
||||
}, [inlineDraftTarget, removeInlineCommentDraft]);
|
||||
// Review comments are the inline-comment drafts that aren't preview sources.
|
||||
const removeReviewDrafts = React.useCallback(() => {
|
||||
if (!inlineDraftTarget) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||
for (const draft of drafts) {
|
||||
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') {
|
||||
removeInlineCommentDraft(inlineDraftTarget, draft.id);
|
||||
}
|
||||
}
|
||||
}, [inlineDraftTarget, removeInlineCommentDraft]);
|
||||
|
||||
// User message history for up/down arrow navigation.
|
||||
// Keep this on a narrow hook instead of full session message records.
|
||||
@@ -816,8 +807,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
prevNewSessionDraftOpenRef.current = newSessionDraftOpen;
|
||||
}, [newSessionDraftOpen, isMobile]);
|
||||
|
||||
// Session activity for queue availability and controls
|
||||
const { phase: sessionPhase } = useCurrentSessionActivity();
|
||||
// Session activity for queue availability and controls. In btw mode the
|
||||
// composer controls the temporary fork, so the stop button and send-button
|
||||
// state follow the FORK's activity; the queue affordance stays tied to the
|
||||
// main session (queued messages always belong to the main chat).
|
||||
const { phase: currentSessionPhase } = useCurrentSessionActivity();
|
||||
const { phase: btwSessionPhase } = useSessionActivity(btwSessionId, btwDirectory ?? undefined);
|
||||
const sessionPhase = isBtwActive ? btwSessionPhase : currentSessionPhase;
|
||||
const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => {
|
||||
if (!currentSessionId) return false;
|
||||
const run = state.runsByOriginalSessionID[currentSessionId];
|
||||
@@ -891,12 +887,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
|
||||
|
||||
const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : [];
|
||||
|
||||
let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
if (drafts.length > 0) {
|
||||
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
||||
}
|
||||
// Context drafts stay in their store: the send that later delivers the
|
||||
// queue consumes them and attaches them as structured context parts.
|
||||
const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
|
||||
|
||||
addToQueue(messageQueueTarget, {
|
||||
@@ -910,6 +903,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
// Sending while the agent works must still take the reader to the
|
||||
// live edge — a queued message produces no user row yet, so the
|
||||
// anchor path has nothing to claim and would leave the viewport
|
||||
// parked mid-history.
|
||||
scrollToLatest?.();
|
||||
|
||||
// Clear input and attachments
|
||||
// Note: confirmedMentionsRef is NOT cleared here because queued messages
|
||||
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
|
||||
@@ -922,7 +921,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (!isMobile) {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||
setMessage(content);
|
||||
@@ -960,6 +959,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
};
|
||||
|
||||
const handleSubmit = async (options?: SubmitOptions) => {
|
||||
const submitRuntimeKey = getRuntimeKey();
|
||||
const queuedOnly = options?.queuedOnly ?? false;
|
||||
const queuedMessageId = options?.queuedMessageId;
|
||||
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
||||
@@ -1014,12 +1014,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
// queued-message auto-send hook delivers it as the next turn once the
|
||||
// rejected turn winds down and the session returns to idle. This avoids
|
||||
// aborting the turn (which would surface an "aborted" notice).
|
||||
if (currentSessionId && !queuedOnly && autoReviewRunning) {
|
||||
if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive) {
|
||||
handleQueueMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSessionId && !queuedOnly) {
|
||||
// btw mode: the child fork's blocking prompts are answered inside the
|
||||
// panel; the composer send goes straight to the fork (routeMessage
|
||||
// queues if the fork's own turn is busy).
|
||||
if (currentSessionId && !queuedOnly && !isBtwActive) {
|
||||
// Sending is authoritative for blocking prompts: deny pending
|
||||
// permissions and dismiss open questions for the session subtree,
|
||||
// then queue the message once if either was open. The deny/clear
|
||||
@@ -1039,22 +1042,69 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessageOptions: {
|
||||
let sendMessageOptions: {
|
||||
target?: NonNullable<typeof capturedTarget>;
|
||||
sessionId?: string;
|
||||
directory?: string;
|
||||
draftSnapshot?: NonNullable<typeof capturedDraftSnapshot>;
|
||||
delivery?: 'steer';
|
||||
} | undefined = (capturedTarget || capturedDraftSnapshot || delivery)
|
||||
? {
|
||||
...(capturedTarget ? { target: capturedTarget } : {}),
|
||||
...(capturedDraftSnapshot ? { draftSnapshot: capturedDraftSnapshot } : {}),
|
||||
...(delivery ? { delivery } : {}),
|
||||
} | undefined;
|
||||
if (isBtwActive && btwSessionId && btwDirectory) {
|
||||
sendMessageOptions = {
|
||||
sessionId: btwSessionId,
|
||||
directory: btwDirectory,
|
||||
};
|
||||
} else if (capturedTarget || capturedDraftSnapshot || delivery) {
|
||||
sendMessageOptions = {};
|
||||
if (capturedTarget) sendMessageOptions.target = capturedTarget;
|
||||
if (capturedDraftSnapshot) sendMessageOptions.draftSnapshot = capturedDraftSnapshot;
|
||||
}
|
||||
if (delivery && sendMessageOptions) sendMessageOptions.delivery = delivery;
|
||||
|
||||
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
|
||||
const reservedFilenames = new Set([
|
||||
...attachedFiles.map((attachment) => attachment.filename),
|
||||
...queuedMessagesToSend.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
|
||||
]);
|
||||
const mentionTexts = [
|
||||
...queuedMessagesToSend.map((queued) => queued.content),
|
||||
...(!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : []),
|
||||
];
|
||||
for (const rawText of mentionTexts) {
|
||||
for (const token of scanMentions(rawText)) {
|
||||
const mention = resolveInlineFileMention(token.name);
|
||||
if (
|
||||
!mention
|
||||
|| !isDocumentAttachmentFilename(mention.filename)
|
||||
|| preparedDocumentMentions.has(mention.serverPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } });
|
||||
if (!response.ok) throw new Error(`Failed to read ${mention.filename}`);
|
||||
const sourceBlob = await response.blob();
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
const source = new File([sourceBlob], mention.filename);
|
||||
const prepared = await prepareLocalAttachments(source, reservedFilenames);
|
||||
if (!prepared || prepared.length === 0) throw new Error(`Failed to prepare ${mention.filename}`);
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
preparedDocumentMentions.set(mention.serverPath, prepared);
|
||||
for (const attachment of prepared) reservedFilenames.add(attachment.filename);
|
||||
} catch {
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: mention.filename }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Inline review comments and synthetic context are consumed before
|
||||
// assembly so a failed send can restore exactly what it took.
|
||||
// assembly so a failed send can restore exactly what it took. Context
|
||||
// drafts ride with whichever send goes out next, including queued
|
||||
// auto-sends: queueing leaves them in the store on purpose.
|
||||
const syntheticParts = consumePendingSyntheticParts();
|
||||
const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget;
|
||||
const consumedDraftTarget = inlineDraftTarget;
|
||||
const drafts: InlineCommentDraft[] = consumedDraftTarget
|
||||
? consumeDrafts(consumedDraftTarget)
|
||||
: [];
|
||||
@@ -1069,9 +1119,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
composerAttachments: attachedFiles,
|
||||
inlineComments: drafts,
|
||||
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
|
||||
linkedIssueContext: linkedIssue?.contextText ?? null,
|
||||
linkedIssue: linkedIssue
|
||||
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
|
||||
: null,
|
||||
linkedPr: linkedPr
|
||||
? { instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
: null,
|
||||
}, {
|
||||
parseAgentMention: (text) => {
|
||||
@@ -1079,13 +1131,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
return { text: sanitizedText, agentName: mention?.name };
|
||||
},
|
||||
extractFileMentions: (text) => {
|
||||
const { sanitizedText, attachments } = extractInlineFileMentions(text);
|
||||
const { sanitizedText, attachments } = extractInlineFileMentions(text, preparedDocumentMentions);
|
||||
return { text: sanitizedText, attachments };
|
||||
},
|
||||
sanitizeAttachments: sanitizeAttachmentsForSend,
|
||||
collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames),
|
||||
appendComments: (text, comments) =>
|
||||
appendInlineComments(text, comments as InlineCommentDraft[]),
|
||||
buildSkillInstruction: buildSkillMentionInstruction,
|
||||
});
|
||||
|
||||
@@ -1152,6 +1202,40 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (commandName === 'btw' && currentSessionId) {
|
||||
const question = argument.trim();
|
||||
if (!question) {
|
||||
toast.error(t('chat.btw.toast.emptyArgument'));
|
||||
return;
|
||||
}
|
||||
const targetDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId)
|
||||
|| currentDirectory
|
||||
|| null;
|
||||
if (!targetDirectory) {
|
||||
toast.error(t('chat.btw.toast.createFailed'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// A new btw replaces this session's current one: destroy
|
||||
// the previous fork first so forks never accumulate.
|
||||
if (btwSessionRef) {
|
||||
await destroyBtwSession(btwSessionRef);
|
||||
}
|
||||
await startBtwSession({
|
||||
parentSessionId: currentSessionId,
|
||||
question,
|
||||
directory: targetDirectory,
|
||||
providerID: providerIdToSend,
|
||||
modelID: modelIdToSend,
|
||||
agent: agentNameToSend,
|
||||
variant: variantToSend,
|
||||
});
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The rest render a visible prompt plus synthetic instructions and
|
||||
// send them as one message.
|
||||
@@ -1187,7 +1271,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
|
||||
const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory;
|
||||
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
|
||||
// btw mode: the fork already carries the question plus full history,
|
||||
// so the response-style instruction never applies there.
|
||||
const shouldAddResponseStyle = !isBtwActive && (newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false));
|
||||
if (shouldAddResponseStyle) {
|
||||
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
|
||||
if (responseStyleInstruction) {
|
||||
@@ -1214,6 +1300,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
...additionalParts.flatMap(p => p.attachments ?? []),
|
||||
];
|
||||
|
||||
// Arm the timeline anchor BEFORE the optimistic user row can commit;
|
||||
// arming after (or a frame later) races the commit and the anchor
|
||||
// never claims the new message.
|
||||
scrollToBottom?.();
|
||||
|
||||
const sendPromise = sendMessage(
|
||||
primaryText,
|
||||
providerIdToSend,
|
||||
@@ -1232,14 +1323,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
scrollToBottom?.();
|
||||
} else {
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToBottom?.();
|
||||
});
|
||||
}
|
||||
|
||||
void sendPromise.then(() => {
|
||||
// Record what this session was pointed at, so the work-status panel
|
||||
// can show it as a context source long after the message scrolled
|
||||
@@ -1357,7 +1440,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
// Primary action for send/queue button — respects selected follow-up behavior
|
||||
const handlePrimaryAction = React.useCallback(() => {
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
|
||||
const canQueue = !isBtwActive && inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
|
||||
if (followUpBehavior === 'queue' && canQueue) {
|
||||
handleQueueMessage();
|
||||
} else if (followUpBehavior === 'steer' && canQueue) {
|
||||
@@ -1365,7 +1448,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
} else {
|
||||
void handleSubmitRef.current();
|
||||
}
|
||||
}, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]);
|
||||
}, [inputMode, getCurrentInputSnapshot, currentSessionId, currentSessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage, isBtwActive]);
|
||||
|
||||
// Draft welcome presets: submit immediately.
|
||||
const submitPresetPrompt = React.useCallback((text: string, type: 'command' | 'skill') => {
|
||||
@@ -1569,15 +1652,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter/Ctrl+Enter based on selected follow-up behavior.
|
||||
if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) {
|
||||
// Handle Enter/Ctrl+Enter based on selected follow-up behavior. On
|
||||
// mobile, and in desktop focus mode, plain Enter writes a newline and
|
||||
// only Cmd/Ctrl+Enter sends: both are surfaces for composing long
|
||||
// prompts, where an accidental send costs more than an extra keypress.
|
||||
const requiresModifierToSend = isMobile || isDesktopExpanded;
|
||||
if (e.key === 'Enter' && !e.shiftKey && (!requiresModifierToSend || e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Queueing / steering only works when there's an existing busy
|
||||
// session (or an active auto-review run).
|
||||
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
|
||||
const canQueue = !isBtwActive && inputMode === 'normal' && hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
|
||||
|
||||
if (followUpBehavior === 'queue') {
|
||||
if (isCtrlEnter || !canQueue) {
|
||||
@@ -1627,8 +1714,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
clearAbortPrompt();
|
||||
startAbortIndicator();
|
||||
|
||||
void abortCurrentOperation(currentSessionId || undefined);
|
||||
}, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]);
|
||||
// btw mode: the stop button stops the fork's turn, not the main
|
||||
// session's.
|
||||
const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId;
|
||||
void abortCurrentOperation(abortTarget || undefined);
|
||||
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive, startAbortIndicator]);
|
||||
|
||||
const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => {
|
||||
const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction);
|
||||
@@ -2325,6 +2415,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
|
||||
const showDesktopDraftPresentation = (newSessionDraftOpen || draftPresentationExiting)
|
||||
&& !isDesktopExpanded
|
||||
&& !isMobile
|
||||
&& !isVSCode
|
||||
&& !isMiniChatSurface;
|
||||
const draftPresentationClassName = cn(
|
||||
'transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
|
||||
draftPresentationExiting && 'pointer-events-none opacity-0',
|
||||
);
|
||||
|
||||
const hasPendingChanges = React.useMemo(() => {
|
||||
if (isMiniChatSurface) {
|
||||
@@ -2338,7 +2437,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) {
|
||||
if (!showDraftTargetSelectors || !selectedDraftProject || selectedDraftProject.kind === 'chat' || !selectedDraftDirectory) {
|
||||
return;
|
||||
}
|
||||
if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) {
|
||||
@@ -2502,8 +2601,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
)}
|
||||
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
||||
>
|
||||
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
|
||||
<div className="chat-input-column mb-7 text-center">
|
||||
{showDesktopDraftPresentation ? (
|
||||
<div className={cn('chat-input-column mb-7 text-center', draftPresentationClassName)}>
|
||||
<h1 className="text-balance text-2xl font-normal tracking-tight text-foreground md:text-3xl">
|
||||
{renderDraftTitle(
|
||||
draftProjectLabel
|
||||
@@ -2523,16 +2622,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
<AutoReviewBanner />
|
||||
{hasDrafts ? (
|
||||
<ComposerContextChips
|
||||
terminalDrafts={terminalContextDrafts}
|
||||
reviewCount={reviewCount}
|
||||
prCommentCount={prCommentCount}
|
||||
prCheckCount={prCheckCount}
|
||||
previewConsoleCount={previewConsoleCount}
|
||||
previewAnnotationCount={previewAnnotationCount}
|
||||
draftTarget={inlineDraftTarget}
|
||||
onRemoveDraft={removeInlineCommentDraft}
|
||||
onRemoveReviewDrafts={removeReviewDrafts}
|
||||
onRemovePreviewDrafts={removePreviewDrafts}
|
||||
colors={currentTheme.colors}
|
||||
/>
|
||||
) : null}
|
||||
@@ -2566,29 +2656,30 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
/>
|
||||
<MemoStatusRow
|
||||
<MemoComposerStatusBar
|
||||
showAbortStatus={showAbortStatus}
|
||||
showAssistantStatus={false}
|
||||
showTodos={composerStatusExtrasEnabled}
|
||||
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
|
||||
? null
|
||||
: <PendingChangesBar />}
|
||||
/>
|
||||
{!isMobile && showDraftTargetSelectors && selectedDraftProject ? (
|
||||
<DraftTargetSelectors
|
||||
projects={draftProjects}
|
||||
selectedProject={selectedDraftProject}
|
||||
selectedDirectory={selectedDraftDirectory}
|
||||
selectedBranchLabel={selectedDraftBranchLabel}
|
||||
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
||||
projectRootBranchOption={projectRootBranchOption}
|
||||
worktreeBranchOptions={worktreeBranchOptions}
|
||||
branchItems={draftBranchItems}
|
||||
showBranchSelector={shouldShowDraftBranchSelector}
|
||||
onProjectChange={handleDraftProjectChange}
|
||||
onDirectoryChange={handleDraftDirectoryChange}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
{!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? (
|
||||
<div className={draftPresentationClassName}>
|
||||
<DraftTargetSelectors
|
||||
projects={draftProjects}
|
||||
selectedProject={selectedDraftProject}
|
||||
selectedDirectory={selectedDraftDirectory}
|
||||
selectedBranchLabel={selectedDraftBranchLabel}
|
||||
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
||||
projectRootBranchOption={projectRootBranchOption}
|
||||
worktreeBranchOptions={worktreeBranchOptions}
|
||||
branchItems={draftBranchItems}
|
||||
showBranchSelector={shouldShowDraftBranchSelector}
|
||||
onProjectChange={handleDraftProjectChange}
|
||||
onDirectoryChange={handleDraftDirectoryChange}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{isMobile && showDraftTargetSelectors && selectedDraftProject ? (
|
||||
<MobileDraftTargetTriggers
|
||||
@@ -2755,11 +2846,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}}
|
||||
onFocus={mobileShell.onEditorFocus}
|
||||
onBlur={mobileShell.onEditorBlur}
|
||||
placeholder={currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? t('chat.chatInput.placeholder.shell')
|
||||
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
|
||||
: t('chat.chatInput.placeholder.selectSession')}
|
||||
placeholder={isBtwActive
|
||||
? t('chat.btw.mainComposerPlaceholder')
|
||||
: currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? t('chat.chatInput.placeholder.shell')
|
||||
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
|
||||
: t('chat.chatInput.placeholder.selectSession')}
|
||||
editable={Boolean(currentSessionId || newSessionDraftOpen)}
|
||||
autoCorrect={isMobile}
|
||||
autoCapitalize={isMobile ? 'sentences' : 'none'}
|
||||
@@ -2852,12 +2945,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
|
||||
{showDesktopDraftPresentation ? (
|
||||
<DraftPresetChips
|
||||
onSubmit={(starter) => submitPresetPrompt(starter.submitText, starter.ref.type)}
|
||||
className="chat-input-column mt-4"
|
||||
className={cn('chat-input-column mt-4', draftPresentationClassName)}
|
||||
/>
|
||||
) : null}
|
||||
{currentSessionId ? <BtwPanel parentSessionId={currentSessionId} panel={btwPanel} /> : null}
|
||||
</form>
|
||||
|
||||
{/* Issue Picker Dialog */}
|
||||
|
||||
@@ -12,8 +12,8 @@ import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
||||
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import MessageBody from './message/MessageBody';
|
||||
import type { AgentMentionInfo } from './message/types';
|
||||
import type { StreamPhase, ToolPopupContent } from './message/types';
|
||||
@@ -131,8 +131,6 @@ interface ChatMessageProps {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
animationHandlers?: AnimationHandlers;
|
||||
scrollToBottom?: () => void;
|
||||
turnGroupingContext?: TurnGroupingContext;
|
||||
assistantHeaderMessageId?: string;
|
||||
@@ -147,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
message,
|
||||
previousMessage,
|
||||
nextMessage,
|
||||
onContentChange,
|
||||
animationHandlers,
|
||||
turnGroupingContext,
|
||||
assistantHeaderMessageId,
|
||||
isInActiveTurn = false,
|
||||
@@ -202,6 +198,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
|
||||
const isUser = messageRole.isUser;
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
|
||||
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
|
||||
|
||||
@@ -703,30 +700,25 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
}
|
||||
if (errorName === 'SessionRetry') {
|
||||
return {
|
||||
text: `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``,
|
||||
variant: 'info' as const,
|
||||
text: `Opencode failed to send a message. Retry attempt info: ${detail}`,
|
||||
};
|
||||
}
|
||||
if (isLikelyProviderAuthFailure(detail)) {
|
||||
return {
|
||||
text: PROVIDER_AUTH_FAILURE_MESSAGE,
|
||||
variant: 'error' as const,
|
||||
};
|
||||
}
|
||||
if (detail.trim().toLowerCase() === 'aborted') {
|
||||
return {
|
||||
text: 'The running turn was stopped before OpenCode could send the next message.',
|
||||
variant: 'info' as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: `Opencode failed to send message with error:\n\`${detail}\``,
|
||||
variant: 'error' as const,
|
||||
text: `Opencode failed to send message with error: ${detail}`,
|
||||
};
|
||||
}, [isUser, message.info]);
|
||||
|
||||
const assistantErrorText = assistantError?.text;
|
||||
const assistantErrorVariant = assistantError?.variant;
|
||||
|
||||
const messageTextContent = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
@@ -853,35 +845,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
});
|
||||
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]);
|
||||
|
||||
const resolvedAnimationHandlers = animationHandlers ?? null;
|
||||
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
|
||||
|
||||
const animationCompletedRef = React.useRef(false);
|
||||
const hasRequestedReservationRef = React.useRef(false);
|
||||
const animationStartNotifiedRef = React.useRef(false);
|
||||
const hasTriggeredReservationOnceRef = React.useRef(false);
|
||||
const hasEverStreamedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
animationCompletedRef.current = false;
|
||||
hasRequestedReservationRef.current = false;
|
||||
animationStartNotifiedRef.current = false;
|
||||
hasTriggeredReservationOnceRef.current = false;
|
||||
hasAnnouncedAuxiliaryScrollRef.current = false;
|
||||
hasEverStreamedRef.current = false;
|
||||
}, [message.info.id]);
|
||||
|
||||
const handleAuxiliaryContentComplete = React.useCallback(() => {
|
||||
if (isUser) {
|
||||
return;
|
||||
}
|
||||
if (hasAnnouncedAuxiliaryScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
hasAnnouncedAuxiliaryScrollRef.current = true;
|
||||
onContentChange?.('structural');
|
||||
}, [isUser, onContentChange]);
|
||||
|
||||
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
|
||||
|
||||
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
|
||||
@@ -904,114 +873,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
hasEverStreamedRef.current = true;
|
||||
}
|
||||
|
||||
const hasReasoningParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return false;
|
||||
}
|
||||
return visibleParts.some((part) => part.type === 'reasoning');
|
||||
}, [isUser, visibleParts]);
|
||||
|
||||
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current;
|
||||
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolvedAnimationHandlers?.onStreamingCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldReserveAnimationSpace) {
|
||||
if (hasRequestedReservationRef.current) {
|
||||
if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
|
||||
resolvedAnimationHandlers.onReasoningBlock();
|
||||
} else if (resolvedAnimationHandlers?.onReservationCancelled) {
|
||||
resolvedAnimationHandlers.onReservationCancelled();
|
||||
}
|
||||
hasRequestedReservationRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTriggeredReservationOnceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriggeredReservationOnceRef.current = true;
|
||||
resolvedAnimationHandlers.onStreamingCandidate();
|
||||
hasRequestedReservationRef.current = true;
|
||||
}, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolvedAnimationHandlers?.onAnimationStart) {
|
||||
return;
|
||||
}
|
||||
if (!allowAnimation) {
|
||||
return;
|
||||
}
|
||||
if (animationStartNotifiedRef.current) {
|
||||
return;
|
||||
}
|
||||
resolvedAnimationHandlers.onAnimationStart();
|
||||
animationStartNotifiedRef.current = true;
|
||||
}, [resolvedAnimationHandlers, allowAnimation]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
|
||||
if (!shouldTrackHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = messageContainerRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
|
||||
handler(element.getBoundingClientRect().height);
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId: number | null = null;
|
||||
const notifyHeight = (height: number) => {
|
||||
if (typeof window === 'undefined') {
|
||||
handler(height);
|
||||
return;
|
||||
}
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
handler(height);
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
notifyHeight(entry.contentRect.height);
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
notifyHeight(element.getBoundingClientRect().height);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
|
||||
|
||||
if (shouldHideUserMessage) {
|
||||
return null;
|
||||
@@ -1044,7 +906,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
respectReducedMotion
|
||||
>
|
||||
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
|
||||
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined)}>
|
||||
{/* peek: the action row under the bubble is suppressed, so
|
||||
reserve its gap to the next message here, OUTSIDE the
|
||||
bubble background. */}
|
||||
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined, chatSurfaceMode === 'peek' ? 'pb-3' : undefined)}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--chat-user-message-bg)',
|
||||
@@ -1070,13 +935,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={false}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
@@ -1084,7 +947,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
|
||||
stickyUserHeaderEnabled={stickyUserHeader}
|
||||
/>
|
||||
@@ -1107,13 +969,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={false}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
@@ -1121,7 +981,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
userActionsMode="external-actions"
|
||||
stickyUserHeaderEnabled={stickyUserHeader}
|
||||
/>
|
||||
@@ -1154,17 +1013,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={shouldShowHeader}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
agentMention={agentMention}
|
||||
turnGroupingContext={turnGroupingContext}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
footerProviderID={headerProviderID}
|
||||
footerModelName={headerModelName}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
|
||||
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
||||
|
||||
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
||||
|
||||
@@ -84,7 +85,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
const keyboardNavigationRef = React.useRef(false);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
|
||||
const ignoreClickRef = React.useRef(false);
|
||||
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
|
||||
const pointerMovedRef = React.useRef(false);
|
||||
@@ -152,6 +153,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
: []
|
||||
),
|
||||
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
@@ -226,6 +231,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
: []
|
||||
),
|
||||
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
@@ -376,6 +385,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
const isSystem = command.isBuiltIn;
|
||||
const isOpenChamberBadge = command.isOpenChamber;
|
||||
return (
|
||||
<AutocompleteRowTooltip description={command.description} active={!isMobile && index === selectedIndex}>
|
||||
<div
|
||||
key={command.id}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
@@ -471,13 +481,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{command.description && !isMobile && (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AutocompleteRowTooltip>
|
||||
);
|
||||
})}
|
||||
{commands.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import React from "react";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDirectorySync } from "@/sync/sync-context";
|
||||
import type { Todo } from "@opencode-ai/sdk/v2/client";
|
||||
import { useUIStore } from "@/stores/useUIStore";
|
||||
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
|
||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
// The bar that sits in the composer stack: pending-changes accessory, abort
|
||||
// status, and the todos dropdown. Deliberately a separate component from
|
||||
// StatusRow — that one is the floating assistant-status chip above the
|
||||
// composer, and sharing markup meant every restyle of the chip (glass,
|
||||
// placement) silently restyled this bar and its dropdown too.
|
||||
|
||||
type TodoItem = Todo & { id?: string };
|
||||
|
||||
const COMPOSER_STATUS_BAR_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "composer-status-bar" };
|
||||
|
||||
const statusConfig = {
|
||||
in_progress: { textClassName: "text-foreground" },
|
||||
pending: { textClassName: "text-foreground" },
|
||||
completed: { textClassName: "text-muted-foreground line-through" },
|
||||
cancelled: { textClassName: "text-muted-foreground line-through" },
|
||||
};
|
||||
|
||||
const priorityClassName = {
|
||||
high: "text-[var(--status-warning)]",
|
||||
medium: "text-muted-foreground",
|
||||
low: "text-muted-foreground/70",
|
||||
};
|
||||
|
||||
const priorityIcon = {
|
||||
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
};
|
||||
|
||||
const statusLabelKey = {
|
||||
in_progress: "chat.statusRow.todo.status.inProgress",
|
||||
pending: "chat.statusRow.todo.status.pending",
|
||||
completed: "chat.statusRow.todo.status.completed",
|
||||
cancelled: "chat.statusRow.todo.status.cancelled",
|
||||
};
|
||||
|
||||
const priorityLabelKey = {
|
||||
high: "chat.statusRow.todo.priority.high",
|
||||
medium: "chat.statusRow.todo.priority.medium",
|
||||
low: "chat.statusRow.todo.priority.low",
|
||||
};
|
||||
|
||||
// SAFETY: todo.status / todo.priority arrive from the SDK as open strings;
|
||||
// lookups treat them as candidate keys and every call site falls back to a
|
||||
// default entry when the value is outside the known set.
|
||||
const knownStatus = (status: string) =>
|
||||
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
|
||||
status as keyof typeof statusConfig;
|
||||
const knownPriority = (priority: string) =>
|
||||
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
|
||||
priority as keyof typeof priorityClassName;
|
||||
|
||||
const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
|
||||
const { t } = useI18n();
|
||||
const config = statusConfig[knownStatus(todo.status)] || statusConfig.pending;
|
||||
// SAFETY: the label keys are literal members of the i18n dictionary; the
|
||||
// lookup narrows an open SDK string with a known fallback, and t() accepts
|
||||
// only the generated key union.
|
||||
const statusKey = (statusLabelKey[knownStatus(todo.status)] ?? statusLabelKey.pending) as Parameters<typeof t>[0];
|
||||
// SAFETY: same literal-member narrowing as statusKey above.
|
||||
const priorityKey = (priorityLabelKey[knownPriority(todo.priority)] ?? priorityLabelKey.medium) as Parameters<typeof t>[0];
|
||||
|
||||
const statusIcon =
|
||||
todo.status === "in_progress" ? (
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
|
||||
) : todo.status === "completed" ? (
|
||||
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
|
||||
) : (
|
||||
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-0 py-0.5 gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-shrink-0">{statusIcon}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{t(statusKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className={cn("flex-1 typography-ui-label", config.textClassName)}>
|
||||
{todo.content}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
|
||||
priorityClassName[knownPriority(todo.priority)] ?? priorityClassName.medium,
|
||||
)}
|
||||
>
|
||||
{priorityIcon[knownPriority(todo.priority)] ?? priorityIcon.medium}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{t(priorityKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EMPTY_TODOS: TodoItem[] = [];
|
||||
|
||||
interface ComposerStatusBarProps {
|
||||
showAbortStatus?: boolean;
|
||||
showTodos?: boolean;
|
||||
leftAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
|
||||
showAbortStatus,
|
||||
showTodos = true,
|
||||
leftAccessory,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const liveTodos = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
|
||||
return state.todo[currentSessionId] ?? EMPTY_TODOS;
|
||||
},
|
||||
[currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const persistedSessionTodos = useTodosPersistStore(
|
||||
React.useCallback(
|
||||
(state) => (showTodos && currentSessionId && currentSessionDirectory
|
||||
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
|
||||
: undefined),
|
||||
[currentSessionDirectory, currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const todos: TodoItem[] = React.useMemo(() => {
|
||||
if (!currentSessionId) return EMPTY_TODOS;
|
||||
if (liveTodos.length > 0) return liveTodos;
|
||||
return persistedSessionTodos ?? EMPTY_TODOS;
|
||||
}, [liveTodos, persistedSessionTodos, currentSessionId]);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const isCompact = isMobile || isVSCodeRuntime();
|
||||
|
||||
const visibleTodos = React.useMemo(() => {
|
||||
return todos.filter((todo) => todo.status !== "cancelled");
|
||||
}, [todos]);
|
||||
|
||||
const activeTodo = React.useMemo(() => {
|
||||
return (
|
||||
visibleTodos.find((todo) => todo.status === "in_progress") ||
|
||||
visibleTodos.find((todo) => todo.status === "pending") ||
|
||||
null
|
||||
);
|
||||
}, [visibleTodos]);
|
||||
|
||||
const progress = React.useMemo(() => {
|
||||
const total = todos.filter((todo) => todo.status !== "cancelled").length;
|
||||
const completed = todos.filter((todo) => todo.status === "completed").length;
|
||||
return { completed, total };
|
||||
}, [todos]);
|
||||
|
||||
const statusSummary = React.useMemo(() => {
|
||||
const active = visibleTodos.filter((todo) => todo.status === "in_progress").length;
|
||||
const left = visibleTodos.filter((todo) => todo.status === "in_progress" || todo.status === "pending").length;
|
||||
return { active, left };
|
||||
}, [visibleTodos]);
|
||||
|
||||
const hasTodoContent = showTodos && statusSummary.left > 0;
|
||||
const hasLeftAccessory = Boolean(leftAccessory);
|
||||
const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory;
|
||||
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
// SAFETY: mousedown targets are DOM nodes; contains() only needs Node.
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isExpanded]);
|
||||
|
||||
const toggleExpanded = () => setIsExpanded((prev) => !prev);
|
||||
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
|
||||
active: statusSummary.active,
|
||||
left: statusSummary.left,
|
||||
});
|
||||
|
||||
const todoTrigger = hasTodoContent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
|
||||
aria-label={todoSummaryLabel}
|
||||
title={todoSummaryLabel}
|
||||
>
|
||||
{!isCompact && activeTodo ? (
|
||||
<span className="composer-status-bar__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
|
||||
{activeTodo.content}
|
||||
</span>
|
||||
) : (
|
||||
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
|
||||
)}
|
||||
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
|
||||
{statusSummary.active}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
{statusSummary.left}
|
||||
</span>
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2" style={COMPOSER_STATUS_BAR_CONTAINER_STYLE}>
|
||||
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
|
||||
{/* Left: abort status | pending-changes accessory */}
|
||||
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
||||
{showAbortStatus ? (
|
||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
||||
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||
<Icon name="close-circle" aria-hidden="true" />
|
||||
{t('chat.statusRow.aborted')}
|
||||
</span>
|
||||
</div>
|
||||
) : leftAccessory ? (
|
||||
leftAccessory
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: todos dropdown */}
|
||||
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory && "pr-1.5")} ref={popoverRef}>
|
||||
{todoTrigger}
|
||||
|
||||
{isExpanded && hasTodoContent && (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "min(28rem, calc(100cqw - 4ch))",
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
color: "var(--surface-elevated-foreground)",
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-0 bottom-full mb-1 z-50",
|
||||
"w-max min-w-[200px] rounded-xl p-1",
|
||||
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
|
||||
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
|
||||
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
||||
"duration-150",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
||||
<span>{t('chat.statusRow.tasksTitle')}</span>
|
||||
<span className="typography-meta tabular-nums">
|
||||
{progress.completed}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-1 max-h-[200px] overflow-y-auto">
|
||||
{visibleTodos.map((todo, index) => (
|
||||
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,17 +3,29 @@ import { cn } from '@/lib/utils';
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
|
||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||
import {
|
||||
useWorkerHighlightedLines,
|
||||
type WorkerHighlightedLinesResult,
|
||||
} from '@/components/code/useWorkerHighlightedLines';
|
||||
import { parseDiffToUnified } from './message/toolRenderers';
|
||||
|
||||
// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls
|
||||
// back to plain text while loading or on failure.
|
||||
const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) =>
|
||||
html !== undefined ? (
|
||||
<span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap break-all">{content}</span>
|
||||
);
|
||||
// Keep the line's layout stable while a cold worker request finishes. Plain
|
||||
// text appears only if highlighting fails, avoiding a visible color flash.
|
||||
interface CodeLineContentProps {
|
||||
content: string;
|
||||
html: string | undefined;
|
||||
status: WorkerHighlightedLinesResult['status'];
|
||||
}
|
||||
|
||||
const CodeLineContent: React.FC<CodeLineContentProps> = ({ content, html, status }) => {
|
||||
if (status === 'ready' && html !== undefined) {
|
||||
return <span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
if (status === 'loading') {
|
||||
return <span aria-hidden className="invisible whitespace-pre-wrap break-all">{content}</span>;
|
||||
}
|
||||
return <span className="whitespace-pre-wrap break-all">{content}</span>;
|
||||
};
|
||||
|
||||
interface DiffPreviewProps {
|
||||
diff: string;
|
||||
@@ -44,7 +56,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
|
||||
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIdx) => {
|
||||
const html = highlighted?.[lineCursor];
|
||||
const html = highlighted.lines?.[lineCursor];
|
||||
lineCursor += 1;
|
||||
return (
|
||||
<div
|
||||
@@ -67,7 +79,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
|
||||
{line.lineNumber || ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CodeLineContent content={line.content} html={html} />
|
||||
<CodeLineContent content={line.content} html={html} status={highlighted.status} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -106,7 +118,11 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath })
|
||||
{lineIdx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} />
|
||||
<CodeLineContent
|
||||
content={line || ' '}
|
||||
html={highlighted.lines?.[lineIdx]}
|
||||
status={highlighted.status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef, memo } from 'react';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import type { AttachedFile } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
@@ -833,7 +834,10 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
useUIStore.getState().navigateToDiagram(filePath);
|
||||
const directory = useDirectoryStore.getState().currentDirectory;
|
||||
if (directory) {
|
||||
useUIStore.getState().openContextFile(directory, filePath);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left cursor-pointer",
|
||||
|
||||
@@ -14,6 +14,9 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
||||
|
||||
type FileInfo = ProjectFileSearchHit;
|
||||
type AgentInfo = {
|
||||
@@ -80,7 +83,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
|
||||
const normalizedSearchQuery = (searchQuery ?? '').trim();
|
||||
const recentFiles = React.useMemo(() => {
|
||||
if (!projectRoot || !projectTabs) {
|
||||
@@ -93,14 +96,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const queryLower = normalizedSearchQuery.toLowerCase();
|
||||
const mapped = ordered
|
||||
.filter((filePath) => {
|
||||
if (seen.has(filePath)) return false;
|
||||
seen.add(filePath);
|
||||
const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
|
||||
if (!queryLower) return true;
|
||||
return relative.toLowerCase().includes(queryLower);
|
||||
return matchesRankQuery([relative], normalizedSearchQuery);
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map((filePath) => {
|
||||
@@ -123,9 +124,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
() => normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2),
|
||||
[agents, normalizedSearchQuery.length],
|
||||
);
|
||||
const visibleDirectories = directories;
|
||||
const visibleRecentFiles = recentFiles;
|
||||
const visibleFiles = files;
|
||||
const visibleResults = React.useMemo(
|
||||
() => rankFileMentionResults(files, directories, normalizedSearchQuery, 20),
|
||||
[files, directories, normalizedSearchQuery],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
@@ -151,13 +154,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQuery = (debouncedQuery ?? '').trim();
|
||||
const normalizedQueryLower = normalizedQuery
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase();
|
||||
const serverQuery = mentionServerQuery(debouncedQuery ?? '');
|
||||
|
||||
if (!normalizedQueryLower) {
|
||||
if (!serverQuery) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
@@ -166,7 +165,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
pendingSearchRef.current++;
|
||||
setLoading(true);
|
||||
|
||||
searchFiles(currentDirectory, normalizedQueryLower, 80, {
|
||||
searchFiles(currentDirectory, serverQuery, 80, {
|
||||
includeHidden: showHidden,
|
||||
respectGitignore: !showGitignored,
|
||||
type: 'file',
|
||||
@@ -177,7 +176,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
}
|
||||
|
||||
const recentSet = new Set(recentFiles.map((file) => file.path));
|
||||
setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15));
|
||||
setFiles(hits.filter((hit) => !recentSet.has(hit.path)));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
@@ -209,13 +208,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQuery = (debouncedQuery ?? '').trim();
|
||||
const normalizedQueryLower = normalizedQuery
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase();
|
||||
const serverQuery = mentionServerQuery(debouncedQuery ?? '');
|
||||
|
||||
if (!normalizedQueryLower) {
|
||||
if (!serverQuery) {
|
||||
setDirectories([]);
|
||||
return;
|
||||
}
|
||||
@@ -224,14 +219,14 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
pendingSearchRef.current++;
|
||||
setLoading(true);
|
||||
|
||||
searchFiles(currentDirectory, normalizedQueryLower, 20, {
|
||||
searchFiles(currentDirectory, serverQuery, 20, {
|
||||
includeHidden: showHidden,
|
||||
respectGitignore: !showGitignored,
|
||||
type: 'directory',
|
||||
})
|
||||
.then((hits) => {
|
||||
if (!cancelled) {
|
||||
setDirectories(hits.slice(0, 10));
|
||||
setDirectories(hits);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -260,28 +255,22 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
|
||||
React.useEffect(() => {
|
||||
const visibleAgents = getVisibleAgents();
|
||||
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
|
||||
const filtered = visibleAgents
|
||||
const subagents = visibleAgents
|
||||
.filter((agent) => agent.mode && agent.mode !== 'primary')
|
||||
.filter((agent) => {
|
||||
if (!normalizedQuery) return true;
|
||||
const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
})
|
||||
.map((agent) => ({
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
mode: agent.mode,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setAgents(filtered);
|
||||
setAgents(rankByQuery(subagents, searchQuery ?? '', (agent) => [agent.name, agent.description]));
|
||||
}, [getVisibleAgents, searchQuery]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setOverflowMap({});
|
||||
setMarqueeDurations({});
|
||||
}, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]);
|
||||
}, [visibleResults, visibleRecentFiles.length, visibleAgents.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
@@ -331,7 +320,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
}
|
||||
window.removeEventListener('resize', updateOverflow);
|
||||
};
|
||||
}, [visibleFiles, visibleDirectories]);
|
||||
}, [visibleResults]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const labelNode = labelRefs.current[selectedIndex];
|
||||
@@ -375,7 +364,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
return;
|
||||
}
|
||||
|
||||
const total = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + visibleFiles.length;
|
||||
const total = visibleAgents.length + visibleRecentFiles.length + visibleResults.length;
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -399,24 +388,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
}
|
||||
return;
|
||||
}
|
||||
const dirIndex = safeIndex - visibleAgents.length;
|
||||
if (dirIndex < visibleDirectories.length) {
|
||||
const dir = visibleDirectories[dirIndex];
|
||||
if (dir) {
|
||||
handleFileSelect(dir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const fileIndex = dirIndex - visibleDirectories.length;
|
||||
const selectedFile = fileIndex < visibleRecentFiles.length
|
||||
? visibleRecentFiles[fileIndex]
|
||||
: visibleFiles[fileIndex - visibleRecentFiles.length];
|
||||
const recentIndex = safeIndex - visibleAgents.length;
|
||||
const selectedFile = recentIndex < visibleRecentFiles.length
|
||||
? visibleRecentFiles[recentIndex]
|
||||
: visibleResults[recentIndex - visibleRecentFiles.length];
|
||||
if (selectedFile) {
|
||||
handleFileSelect(selectedFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
|
||||
}), [visibleResults, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
|
||||
|
||||
const getFileIcon = (file: FileInfo) => {
|
||||
const ext = file.extension?.toLowerCase();
|
||||
@@ -458,6 +439,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
{visibleAgents.map((agent, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
return (
|
||||
<AutocompleteRowTooltip description={agent.description} active={!isMobile && isSelected}>
|
||||
<div
|
||||
key={`agent-${agent.name}`}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
@@ -470,11 +452,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">@{agent.name}</div>
|
||||
{agent.description && !isMobile ? (
|
||||
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AutocompleteRowTooltip>
|
||||
);
|
||||
})}
|
||||
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
|
||||
@@ -482,38 +462,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
{t('chat.fileMentionAutocomplete.searchMoreAgents')}
|
||||
</div>
|
||||
)}
|
||||
{visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{visibleDirectories.map((dir, index) => {
|
||||
const rowIndex = visibleAgents.length + index;
|
||||
const relativePath = dir.relativePath || dir.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`dir-${dir.path}`}
|
||||
ref={(el) => { itemRefs.current[rowIndex] = el; }}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
|
||||
isSelected && "bg-interactive-selection"
|
||||
)}
|
||||
onClick={() => handleFileSelect(dir)}
|
||||
onMouseMove={() => setSelectedIndex(rowIndex)}
|
||||
>
|
||||
<Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
|
||||
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
|
||||
{displayPath}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleDirectories.length > 0 && (visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
|
||||
{visibleAgents.length > 0 && (visibleRecentFiles.length > 0 || visibleResults.length > 0) && (
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{visibleRecentFiles.map((file, index) => {
|
||||
const rowIndex = visibleAgents.length + visibleDirectories.length + index;
|
||||
const rowIndex = visibleAgents.length + index;
|
||||
const relativePath = file.relativePath || file.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
@@ -561,11 +514,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleRecentFiles.length > 0 && visibleFiles.length > 0 && (
|
||||
{visibleRecentFiles.length > 0 && visibleResults.length > 0 && (
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{visibleFiles.map((file, index) => {
|
||||
const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index;
|
||||
{visibleResults.map((file, index) => {
|
||||
const rowIndex = visibleAgents.length + visibleRecentFiles.length + index;
|
||||
const relativePath = file.relativePath || file.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
@@ -582,7 +535,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
onClick={() => handleFileSelect(file)}
|
||||
onMouseMove={() => setSelectedIndex(rowIndex)}
|
||||
>
|
||||
{getFileIcon(file)}
|
||||
{file.kind === 'directory'
|
||||
? <Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
|
||||
: getFileIcon(file)}
|
||||
<span
|
||||
ref={(el) => { labelRefs.current[rowIndex] = el; }}
|
||||
className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
|
||||
@@ -613,12 +568,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={file.path}>
|
||||
<React.Fragment key={`${file.kind}-${file.path}`}>
|
||||
{item}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
|
||||
{visibleResults.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||
{t('chat.fileMentionAutocomplete.empty')}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { TextPart } from '@opencode-ai/sdk/v2';
|
||||
|
||||
type OperationCounts = {
|
||||
innerHTMLWrites: number;
|
||||
spriteIconInnerHTMLWrites: number;
|
||||
querySelectorAllCalls: number;
|
||||
appendCalls: number;
|
||||
replaceCalls: number;
|
||||
removeCalls: number;
|
||||
getBoundingClientRectCalls: number;
|
||||
viewBoxWrites: number;
|
||||
resizeObserverCreates: number;
|
||||
resizeObserverObserveCalls: number;
|
||||
geometrySequence: Array<'read' | 'write'>;
|
||||
};
|
||||
|
||||
type FixtureMetrics = OperationCounts & {
|
||||
renderers: number;
|
||||
markdownBlocks: number;
|
||||
mermaidBlocks: number;
|
||||
mermaidRenderedCount: number;
|
||||
mermaidSvgCount: number;
|
||||
};
|
||||
|
||||
const fixture = [
|
||||
'# Synthetic mount fixture',
|
||||
'',
|
||||
'A paragraph with **bold text**, a table, and a stable link.',
|
||||
'',
|
||||
'| name | value |',
|
||||
'| --- | ---: |',
|
||||
'| alpha | 1 |',
|
||||
'| beta | 2 |',
|
||||
'',
|
||||
'```typescript',
|
||||
'const answer = 42;',
|
||||
'console.log(answer);',
|
||||
'```',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' A[Start] --> B[Finish]',
|
||||
'```',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph LR',
|
||||
' Client[Client] --> Server[Server]',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const fixtureWorkload = {
|
||||
rendererCount: 3,
|
||||
domBlocksPerRenderer: 1,
|
||||
mermaidBlocksPerRenderer: 2,
|
||||
};
|
||||
|
||||
let windowInstance: Window;
|
||||
let previousGlobals: Map<string, PropertyDescriptor | undefined>;
|
||||
let activeCounts: OperationCounts | null = null;
|
||||
let animationFrameQueue: FrameRequestCallback[] = [];
|
||||
let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) | null = null;
|
||||
let MarkdownRenderer: React.ComponentType<{
|
||||
content: string;
|
||||
messageId: string;
|
||||
part?: TextPart;
|
||||
isAnimated?: boolean;
|
||||
isStreaming?: boolean;
|
||||
enableFileReferences?: boolean;
|
||||
}>;
|
||||
let clearDetachedMarkdownDomCache: () => void;
|
||||
let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number };
|
||||
|
||||
const makeCounts = (): OperationCounts => ({
|
||||
innerHTMLWrites: 0,
|
||||
spriteIconInnerHTMLWrites: 0,
|
||||
querySelectorAllCalls: 0,
|
||||
appendCalls: 0,
|
||||
replaceCalls: 0,
|
||||
removeCalls: 0,
|
||||
getBoundingClientRectCalls: 0,
|
||||
viewBoxWrites: 0,
|
||||
resizeObserverCreates: 0,
|
||||
resizeObserverObserveCalls: 0,
|
||||
geometrySequence: [],
|
||||
});
|
||||
|
||||
const installGlobal = (name: string, value: Window[keyof Window]): void => {
|
||||
previousGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
|
||||
const waitForSettledEffects = async (): Promise<void> => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const flushAnimationFrame = async (): Promise<void> => {
|
||||
const callbacks = animationFrameQueue;
|
||||
animationFrameQueue = [];
|
||||
await act(async () => {
|
||||
for (const callback of callbacks) callback(windowInstance.performance.now());
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const flushDeferredMermaidInitialization = async (): Promise<void> => {
|
||||
await flushAnimationFrame();
|
||||
await flushAnimationFrame();
|
||||
};
|
||||
|
||||
const mountFixture = async (rendererCount: number): Promise<{
|
||||
root: Root;
|
||||
host: HTMLDivElement;
|
||||
operations: OperationCounts;
|
||||
counts: FixtureMetrics;
|
||||
}> => {
|
||||
const counts = makeCounts();
|
||||
activeCounts = counts;
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<>
|
||||
{Array.from({ length: rendererCount }, (_, index) => (
|
||||
<MarkdownRenderer
|
||||
key={`fixture-${index}`}
|
||||
content={fixture}
|
||||
messageId={`fixture-message-${index}`}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>
|
||||
))}
|
||||
</>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => waitForSettledEffects());
|
||||
|
||||
const mermaidBlocks = host.querySelectorAll('[data-markdown="mermaid-block"]').length;
|
||||
const mermaidRenderedCount = host.querySelectorAll('[data-mermaid-render]').length;
|
||||
const mermaidSvgCount = host.querySelectorAll('[data-markdown="mermaid"] svg').length;
|
||||
return {
|
||||
root,
|
||||
host,
|
||||
operations: counts,
|
||||
counts: {
|
||||
...counts,
|
||||
renderers: rendererCount,
|
||||
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks,
|
||||
mermaidRenderedCount,
|
||||
mermaidSvgCount,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const runFixture = async (rendererCount: number): Promise<FixtureMetrics> => {
|
||||
const { root, host, operations } = await mountFixture(rendererCount);
|
||||
await flushDeferredMermaidInitialization();
|
||||
const counts: FixtureMetrics = {
|
||||
...operations,
|
||||
renderers: rendererCount,
|
||||
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks: host.querySelectorAll('[data-markdown="mermaid-block"]').length,
|
||||
mermaidRenderedCount: host.querySelectorAll('[data-mermaid-render]').length,
|
||||
mermaidSvgCount: host.querySelectorAll('[data-markdown="mermaid"] svg').length,
|
||||
};
|
||||
await act(async () => root.unmount());
|
||||
return counts;
|
||||
};
|
||||
|
||||
const initializePerformanceDom = async (): Promise<void> => {
|
||||
windowInstance = new Window({ url: 'http://localhost/' });
|
||||
windowInstance.document.write('<!doctype html><html><head></head><body></body></html>');
|
||||
windowInstance.document.close();
|
||||
previousGlobals = new Map();
|
||||
installGlobal('window', windowInstance);
|
||||
installGlobal('document', windowInstance.document);
|
||||
installGlobal('navigator', windowInstance.navigator);
|
||||
installGlobal('customElements', windowInstance.customElements);
|
||||
for (const name of ['Document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Text', 'NodeFilter', 'MutationObserver', 'DOMParser', 'XMLSerializer', 'HTMLAnchorElement', 'HTMLButtonElement']) {
|
||||
// SAFETY: these names are the DOM constructors installed by this happy-dom Window.
|
||||
const globalValue = windowInstance[name as keyof Window];
|
||||
if (globalValue === undefined) throw new Error(`happy-dom global is unavailable: ${name}`);
|
||||
installGlobal(name, globalValue);
|
||||
}
|
||||
Object.defineProperty(windowInstance, 'matchMedia', { configurable: true, value: () => ({ matches: false, media: '', onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false }) });
|
||||
Object.defineProperty(windowInstance, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => {
|
||||
animationFrameQueue.push(callback);
|
||||
return animationFrameQueue.length;
|
||||
} });
|
||||
Object.defineProperty(windowInstance, 'cancelAnimationFrame', { configurable: true, value: () => undefined });
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const elementPrototype = Element.prototype;
|
||||
const nodePrototype = Node.prototype;
|
||||
const documentPrototype = Document.prototype;
|
||||
const innerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
|
||||
if (!innerHTMLDescriptor?.set || !innerHTMLDescriptor.get) throw new Error('happy-dom innerHTML descriptor unavailable');
|
||||
Object.defineProperty(Element.prototype, 'innerHTML', {
|
||||
configurable: true,
|
||||
get: innerHTMLDescriptor.get,
|
||||
set(value: string) {
|
||||
if (activeCounts) {
|
||||
activeCounts.innerHTMLWrites += 1;
|
||||
if (value.includes('href="#oc-')) activeCounts.spriteIconInnerHTMLWrites += 1;
|
||||
}
|
||||
innerHTMLDescriptor.set?.call(this, value);
|
||||
},
|
||||
});
|
||||
const originalQuerySelectorAll = elementPrototype.querySelectorAll;
|
||||
Object.defineProperty(elementPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
|
||||
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
|
||||
return originalQuerySelectorAll.call(this, selectors);
|
||||
} });
|
||||
const originalDocumentQuerySelectorAll = documentPrototype.querySelectorAll;
|
||||
Object.defineProperty(documentPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
|
||||
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
|
||||
return originalDocumentQuerySelectorAll.call(this, selectors);
|
||||
} });
|
||||
const originalAppendChild = nodePrototype.appendChild;
|
||||
Object.defineProperty(nodePrototype, 'appendChild', { configurable: true, value: function (node: Node): Node {
|
||||
if (activeCounts) activeCounts.appendCalls += 1;
|
||||
return originalAppendChild.call(this, node);
|
||||
} });
|
||||
const originalReplaceWith = elementPrototype.replaceWith;
|
||||
Object.defineProperty(elementPrototype, 'replaceWith', { configurable: true, value: function (...nodes: (Node | string)[]): void {
|
||||
if (activeCounts) activeCounts.replaceCalls += 1;
|
||||
return originalReplaceWith.apply(this, nodes);
|
||||
} });
|
||||
const originalRemove = elementPrototype.remove;
|
||||
Object.defineProperty(elementPrototype, 'remove', { configurable: true, value: function (): void {
|
||||
if (activeCounts) activeCounts.removeCalls += 1;
|
||||
return originalRemove.call(this);
|
||||
} });
|
||||
const originalGetBoundingClientRect = elementPrototype.getBoundingClientRect;
|
||||
Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (): DOMRect {
|
||||
if (activeCounts) {
|
||||
activeCounts.getBoundingClientRectCalls += 1;
|
||||
activeCounts.geometrySequence.push('read');
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
} });
|
||||
const svgSetAttribute = SVGElement.prototype.setAttribute;
|
||||
Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void {
|
||||
if (name === 'viewBox' && activeCounts && this.closest('[data-markdown="mermaid"]')) {
|
||||
activeCounts.viewBoxWrites += 1;
|
||||
activeCounts.geometrySequence.push('write');
|
||||
}
|
||||
return svgSetAttribute.call(this, name, value);
|
||||
} });
|
||||
class CountingResizeObserver {
|
||||
constructor(callback: (entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) {
|
||||
if (activeCounts) activeCounts.resizeObserverCreates += 1;
|
||||
notifyResize = callback;
|
||||
}
|
||||
|
||||
observe(): void {
|
||||
if (activeCounts) activeCounts.resizeObserverObserveCalls += 1;
|
||||
}
|
||||
|
||||
unobserve(): void {}
|
||||
|
||||
disconnect(): void {}
|
||||
}
|
||||
installGlobal('ResizeObserver', CountingResizeObserver);
|
||||
|
||||
const fakeState = {
|
||||
openContextPreview: () => undefined,
|
||||
codeBlockLineWrap: false,
|
||||
mermaidRenderingMode: 'svg',
|
||||
};
|
||||
type UIStateSelection = typeof fakeState[keyof typeof fakeState];
|
||||
const { mock } = await import('bun:test');
|
||||
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
|
||||
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => null }));
|
||||
mock.module('@/stores/useUIStore', () => ({ useUIStore: Object.assign((selector: (state: typeof fakeState) => UIStateSelection) => selector(fakeState), { getState: () => fakeState }) }));
|
||||
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
||||
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
|
||||
mock.module('@/lib/url', () => ({ getUrlScheme: () => null, isAppLinkUrl: () => false, isExternalHttpUrl: () => false, openConfirmedAppLinkUrl: async () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false }));
|
||||
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
|
||||
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
||||
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
||||
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '', normalizeFilePath: (value: string) => value, isAbsoluteFilePath: (value: string) => value.startsWith('/') }));
|
||||
mock.module('@/lib/clipboard', () => ({ copyTextToClipboard: async () => undefined }));
|
||||
mock.module('beautiful-mermaid', () => ({
|
||||
renderMermaidASCII: () => 'diagram',
|
||||
renderMermaidSVG: () => '<svg viewBox="0 0 240 120" width="240" height="120"><path d="M0 0h1v1z" /></svg>',
|
||||
}));
|
||||
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
|
||||
mock.module('./markdown/markdown-worker', () => ({
|
||||
highlightCodeInWorker: async () => null,
|
||||
highlightLinesInWorker: async () => null,
|
||||
highlightTokensInWorker: async () => null,
|
||||
}));
|
||||
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children }));
|
||||
const imported = await import('./MarkdownRendererImpl');
|
||||
MarkdownRenderer = imported.MarkdownRenderer;
|
||||
const { detachedMarkdownDomCache } = await import('./markdown/detachedMarkdownDomCache');
|
||||
clearDetachedMarkdownDomCache = () => detachedMarkdownDomCache.clear();
|
||||
detachedMarkdownDomCacheStats = () => detachedMarkdownDomCache.stats();
|
||||
};
|
||||
|
||||
await initializePerformanceDom();
|
||||
|
||||
afterAll(() => {
|
||||
for (const [name, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
|
||||
describe('MarkdownRenderer DOM mount performance contract', () => {
|
||||
test('builds Markdown sprite controls without parsing SVG markup', async () => {
|
||||
const mounted = await mountFixture(1);
|
||||
|
||||
const spriteControlCount = mounted.host.querySelectorAll('[data-md-action] use[href^="#oc-"]').length;
|
||||
const spriteIconInnerHTMLWrites = mounted.operations.spriteIconInnerHTMLWrites;
|
||||
await act(async () => mounted.root.unmount());
|
||||
|
||||
expect(spriteControlCount).toBeGreaterThan(0);
|
||||
expect(spriteIconInnerHTMLWrites).toBe(0);
|
||||
});
|
||||
|
||||
test('reuses settled Markdown DOM without parsing or decorating it again', async () => {
|
||||
clearDetachedMarkdownDomCache();
|
||||
const content = '# Cached viewport\n\nA settled paragraph.';
|
||||
const part: TextPart = {
|
||||
id: 'part-cache',
|
||||
sessionID: 'session-cache',
|
||||
messageID: 'message-cache',
|
||||
type: 'text',
|
||||
text: content,
|
||||
time: { start: 0, end: 1 },
|
||||
};
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const render = (root: Root) => root.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
messageId="message-cache"
|
||||
part={part}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstCounts = makeCounts();
|
||||
activeCounts = firstCounts;
|
||||
const firstRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
render(firstRoot);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
const originalBlock = host.querySelector('[data-md-block]');
|
||||
expect(originalBlock).not.toBeNull();
|
||||
expect(firstCounts.innerHTMLWrites).toBeGreaterThan(0);
|
||||
await act(async () => firstRoot.unmount());
|
||||
|
||||
const secondCounts = makeCounts();
|
||||
activeCounts = secondCounts;
|
||||
const secondRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
render(secondRoot);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
expect(host.querySelector('[data-md-block]')).toBe(originalBlock);
|
||||
expect(secondCounts.innerHTMLWrites).toBe(0);
|
||||
await act(async () => secondRoot.unmount());
|
||||
clearDetachedMarkdownDomCache();
|
||||
});
|
||||
|
||||
test('does not cache streaming, unfinished, or Mermaid DOM', async () => {
|
||||
clearDetachedMarkdownDomCache();
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const renderScoped = (
|
||||
root: Root,
|
||||
content: string,
|
||||
partId: string,
|
||||
isStreaming = false,
|
||||
) => root.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
messageId="message-cache"
|
||||
part={{
|
||||
id: partId,
|
||||
sessionID: 'session-cache',
|
||||
messageID: 'message-cache',
|
||||
type: 'text',
|
||||
text: content,
|
||||
time: { start: 0, end: 1 },
|
||||
}}
|
||||
isAnimated={false}
|
||||
isStreaming={isStreaming}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const streamingRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
renderScoped(streamingRoot, 'streaming content', 'part-streaming', true);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => streamingRoot.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
|
||||
const unfinalizedRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
unfinalizedRoot.render(
|
||||
<MarkdownRenderer
|
||||
content="unfinalized content"
|
||||
messageId="message-unfinalized"
|
||||
part={{
|
||||
id: 'part-unfinalized',
|
||||
sessionID: 'session-cache',
|
||||
messageID: 'message-unfinalized',
|
||||
type: 'text',
|
||||
text: 'unfinalized content',
|
||||
}}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => unfinalizedRoot.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
|
||||
const mermaidRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
renderScoped(mermaidRoot, '```mermaid\ngraph TD\nA --> B\n```', 'part-mermaid');
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => mermaidRoot.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
|
||||
clearDetachedMarkdownDomCache();
|
||||
});
|
||||
|
||||
test('does not detach Markdown DOM that intersects the active selection', async () => {
|
||||
clearDetachedMarkdownDomCache();
|
||||
const content = 'selected content';
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
messageId="message-selected"
|
||||
part={{
|
||||
id: 'part-selected',
|
||||
sessionID: 'session-selected',
|
||||
messageID: 'message-selected',
|
||||
type: 'text',
|
||||
text: content,
|
||||
time: { start: 0, end: 1 },
|
||||
}}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
const markdown = host.querySelector<HTMLElement>('[data-markdown-content]');
|
||||
if (!markdown) throw new Error('Expected mounted Markdown content');
|
||||
const originalGetSelection = window.getSelection;
|
||||
Object.defineProperty(window, 'getSelection', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
rangeCount: 1,
|
||||
isCollapsed: false,
|
||||
getRangeAt: () => ({ intersectsNode: (node: Node) => node === markdown }),
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => root.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
} finally {
|
||||
Object.defineProperty(window, 'getSelection', { configurable: true, value: originalGetSelection });
|
||||
clearDetachedMarkdownDomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('defers and batches Mermaid controller initialization after Markdown mount', async () => {
|
||||
const mounted = await mountFixture(fixtureWorkload.rendererCount);
|
||||
const critical = mounted.counts;
|
||||
|
||||
expect(critical.getBoundingClientRectCalls).toBe(0);
|
||||
expect(critical.viewBoxWrites).toBe(0);
|
||||
expect(critical.resizeObserverCreates).toBe(0);
|
||||
expect(mounted.host.querySelectorAll('[data-markdown="mermaid"] svg')).toHaveLength(6);
|
||||
|
||||
await flushDeferredMermaidInitialization();
|
||||
const metrics = {
|
||||
...mounted.operations,
|
||||
renderers: fixtureWorkload.rendererCount,
|
||||
markdownBlocks: mounted.host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks: mounted.host.querySelectorAll('[data-markdown="mermaid-block"]').length,
|
||||
mermaidRenderedCount: mounted.host.querySelectorAll('[data-mermaid-render]').length,
|
||||
mermaidSvgCount: mounted.host.querySelectorAll('[data-markdown="mermaid"] svg').length,
|
||||
};
|
||||
|
||||
expect(metrics.renderers).toBe(3);
|
||||
expect(metrics.markdownBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.domBlocksPerRenderer);
|
||||
expect(metrics.mermaidBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.mermaidBlocksPerRenderer);
|
||||
expect(metrics.mermaidRenderedCount).toBeGreaterThan(0);
|
||||
expect(metrics.innerHTMLWrites).toBeGreaterThan(0);
|
||||
expect(metrics.querySelectorAllCalls).toBeGreaterThan(0);
|
||||
expect(metrics.appendCalls).toBeGreaterThan(0);
|
||||
expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.viewBoxWrites).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.resizeObserverCreates).toBe(1);
|
||||
expect(metrics.resizeObserverObserveCalls).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.geometrySequence.lastIndexOf('read')).toBeLessThan(metrics.geometrySequence.indexOf('write'));
|
||||
|
||||
const viewport = mounted.host.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]');
|
||||
if (!viewport || !notifyResize) throw new Error('Expected initialized Mermaid viewport and shared observer');
|
||||
const readsBeforeResize = mounted.operations.getBoundingClientRectCalls;
|
||||
const writesBeforeResize = mounted.operations.viewBoxWrites;
|
||||
notifyResize([{ target: viewport, contentRect: { width: 320, height: 180 } }]);
|
||||
expect(mounted.operations.getBoundingClientRectCalls).toBe(readsBeforeResize);
|
||||
expect(mounted.operations.viewBoxWrites).toBe(writesBeforeResize + 1);
|
||||
console.log(JSON.stringify({ fixture: fixtureWorkload, baseline: metrics }));
|
||||
await act(async () => mounted.root.unmount());
|
||||
});
|
||||
|
||||
test('cancels deferred Mermaid initialization when the renderer unmounts first', async () => {
|
||||
const mounted = await mountFixture(1);
|
||||
await act(async () => mounted.root.unmount());
|
||||
await flushDeferredMermaidInitialization();
|
||||
|
||||
expect(mounted.operations.getBoundingClientRectCalls).toBe(0);
|
||||
expect(mounted.operations.viewBoxWrites).toBe(0);
|
||||
expect(mounted.operations.resizeObserverCreates).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps DOM operation fanout linear when renderer count doubles', async () => {
|
||||
const three = await runFixture(3);
|
||||
const six = await runFixture(6);
|
||||
|
||||
expect(six.mermaidBlocks).toBe(three.mermaidBlocks * 2);
|
||||
expect(six.mermaidRenderedCount).toBe(three.mermaidRenderedCount * 2);
|
||||
expect(six.innerHTMLWrites).toBeLessThanOrEqual(three.innerHTMLWrites * 2 + 6);
|
||||
expect(six.querySelectorAllCalls).toBeLessThanOrEqual(three.querySelectorAllCalls * 2 + 12);
|
||||
expect(six.appendCalls).toBeLessThanOrEqual(three.appendCalls * 2 + 12);
|
||||
expect(six.getBoundingClientRectCalls).toBe(three.getBoundingClientRectCalls * 2);
|
||||
expect(six.viewBoxWrites).toBe(three.viewBoxWrites * 2);
|
||||
expect(three.resizeObserverCreates).toBe(1);
|
||||
expect(six.resizeObserverCreates).toBe(1);
|
||||
expect(six.resizeObserverObserveCalls).toBe(three.resizeObserverObserveCalls * 2);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,377 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser';
|
||||
|
||||
const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
|
||||
|
||||
type FakeElement = {
|
||||
childNodes: FakeElement[];
|
||||
children: FakeElement[];
|
||||
parentNode: FakeElement | null;
|
||||
attributes: Map<string, string>;
|
||||
style: { display: string; setProperty: () => void };
|
||||
innerHTML: string;
|
||||
setAttribute: (name: string, value: string) => void;
|
||||
getAttribute: (name: string) => string | null;
|
||||
appendChild: (child: FakeElement) => FakeElement;
|
||||
replaceWith: (replacement: FakeElement) => void;
|
||||
remove: () => void;
|
||||
querySelector: (selector: string) => FakeElement | null;
|
||||
querySelectorAll: <T>(selector: string) => T[];
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
contains: (child: FakeElement) => boolean;
|
||||
isEqualNode: () => boolean;
|
||||
};
|
||||
|
||||
type FakeDocument = { createElement: () => FakeElement };
|
||||
type FakeJsxProps = {
|
||||
ref?: { current: FakeElement | null };
|
||||
children?: FakeElement | FakeElement[];
|
||||
className?: string;
|
||||
'data-markdown-content'?: boolean;
|
||||
};
|
||||
|
||||
let syncRenderCalls = 0;
|
||||
let morphCalls = 0;
|
||||
let decorateCalls = 0;
|
||||
let mermaidRegistryCreates = 0;
|
||||
let mermaidRegistryCleanups = 0;
|
||||
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
|
||||
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
|
||||
let renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
|
||||
let currentContextVersion = 0;
|
||||
const layoutEffects: Array<() => void> = [];
|
||||
const passiveEffects: Array<() => void | (() => void)> = [];
|
||||
let hookCursor = 0;
|
||||
let hookStates: Array<{ current: null } | undefined> = [];
|
||||
let activeFakeDocument: FakeDocument | null = null;
|
||||
|
||||
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
|
||||
void ownerDocument;
|
||||
let html = '';
|
||||
const element: FakeElement = {
|
||||
childNodes: [],
|
||||
children: [],
|
||||
parentNode: null,
|
||||
attributes: new Map(),
|
||||
style: { display: '', setProperty: () => undefined },
|
||||
get innerHTML() {
|
||||
return html;
|
||||
},
|
||||
set innerHTML(value: string) {
|
||||
html = value;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, value);
|
||||
},
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
},
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.childNodes.push(child);
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
replaceWith(replacement) {
|
||||
if (!this.parentNode) return;
|
||||
const parent = this.parentNode;
|
||||
const index = parent.children.indexOf(this);
|
||||
if (index < 0) return;
|
||||
replacement.parentNode = parent;
|
||||
parent.children[index] = replacement;
|
||||
parent.childNodes[index] = replacement;
|
||||
this.parentNode = null;
|
||||
},
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
const parent = this.parentNode;
|
||||
parent.children = parent.children.filter((child) => child !== this);
|
||||
parent.childNodes = parent.childNodes.filter((child) => child !== this);
|
||||
this.parentNode = null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-markdown-content]') {
|
||||
return this.children.find((child) => child.getAttribute('data-markdown-content') === '') ?? null;
|
||||
}
|
||||
if (selector === '[data-markdown="mermaid-block"]' && html.includes('data-markdown="mermaid-block"')) {
|
||||
return this;
|
||||
}
|
||||
for (const child of this.children) {
|
||||
const match = child.querySelector(selector);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
contains(child) {
|
||||
return child === this || this.children.some((candidate) => candidate.contains(child));
|
||||
},
|
||||
isEqualNode: () => false,
|
||||
};
|
||||
return element;
|
||||
};
|
||||
|
||||
const installRendererDom = () => {
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousMutationObserver = Object.getOwnPropertyDescriptor(globalThis, 'MutationObserver');
|
||||
const documentStub: FakeDocument = { createElement: () => makeFakeElement(documentStub) };
|
||||
activeFakeDocument = documentStub;
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: documentStub });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
matchMedia: () => ({ matches: false }),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousMutationObserver) Object.defineProperty(globalThis, 'MutationObserver', previousMutationObserver);
|
||||
else Reflect.deleteProperty(globalThis, 'MutationObserver');
|
||||
activeFakeDocument = null;
|
||||
};
|
||||
};
|
||||
|
||||
const rendererThemes = [{
|
||||
metadata: { id: 'renderer-test' },
|
||||
colors: {
|
||||
surface: { elevated: '#fff', foreground: '#000', mutedForeground: '#666', muted: '#eee' },
|
||||
interactive: { border: '#ccc' },
|
||||
primary: { base: '#00f' },
|
||||
},
|
||||
}, {
|
||||
metadata: { id: 'renderer-test-next' },
|
||||
colors: {
|
||||
surface: { elevated: '#eee', foreground: '#111', mutedForeground: '#555', muted: '#ddd' },
|
||||
interactive: { border: '#bbb' },
|
||||
primary: { base: '#f00' },
|
||||
},
|
||||
}];
|
||||
let rendererThemeIndex = 0;
|
||||
const rendererTheme = () => rendererThemes[rendererThemeIndex] ?? rendererThemes[0];
|
||||
const rendererUiState = {
|
||||
codeBlockLineWrap: false,
|
||||
mermaidRenderingMode: 'svg',
|
||||
setCodeBlockLineWrap: () => undefined,
|
||||
openContextPreview: () => undefined,
|
||||
};
|
||||
|
||||
const fakeReact = {
|
||||
useCallback: <T>(callback: T): T => {
|
||||
hookCursor += 1;
|
||||
return callback;
|
||||
},
|
||||
useEffect: (effect: () => void | (() => void)) => { passiveEffects.push(effect); },
|
||||
useLayoutEffect: (effect: () => void) => { layoutEffects.push(effect); },
|
||||
useMemo: <T>(factory: () => T): T => {
|
||||
hookCursor += 1;
|
||||
return factory();
|
||||
},
|
||||
useRef: <T>(current: T) => {
|
||||
void current;
|
||||
const index = hookCursor;
|
||||
hookCursor += 1;
|
||||
if (!hookStates[index]) hookStates[index] = { current: null };
|
||||
// SAFETY: this test hook preserves one mutable ref slot per hook index.
|
||||
return hookStates[index] as { current: T };
|
||||
},
|
||||
memo: <T>(component: T): T => component,
|
||||
};
|
||||
|
||||
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
|
||||
const ref = props?.ref;
|
||||
// SAFETY: the renderer test installs the typed fake document before JSX is
|
||||
// evaluated; this branch only supplies its fake element factory.
|
||||
const fakeDocument = activeFakeDocument;
|
||||
if (!fakeDocument) throw new Error('Renderer fake document is not installed');
|
||||
const element = ref?.current ?? makeFakeElement(fakeDocument);
|
||||
if (!ref?.current) {
|
||||
element.childNodes.length = 0;
|
||||
element.children.length = 0;
|
||||
}
|
||||
if (props) {
|
||||
if (ref) ref.current = element;
|
||||
if (props.className) element.setAttribute('class', props.className);
|
||||
if (props['data-markdown-content']) element.setAttribute('data-markdown-content', '');
|
||||
}
|
||||
const jsxChildren = props?.children;
|
||||
const allChildren = jsxChildren === undefined ? children : Array.isArray(jsxChildren) ? jsxChildren : [jsxChildren];
|
||||
for (const child of allChildren) {
|
||||
if (child) element.appendChild(child);
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
mock.module('react', () => ({ default: fakeReact }));
|
||||
mock.module('react/jsx-runtime', () => ({ jsx: fakeJsx, jsxs: fakeJsx, Fragment: 'fragment' }));
|
||||
mock.module('react/jsx-dev-runtime', () => ({ jsxDEV: fakeJsx, Fragment: 'fragment' }));
|
||||
mock.module('beautiful-mermaid', () => ({
|
||||
renderMermaidASCII: () => '',
|
||||
renderMermaidSVG: (_source: string, colors: { bg: string }) => colors.bg,
|
||||
}));
|
||||
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
|
||||
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => `${key}:${currentContextVersion}` }) }));
|
||||
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
|
||||
mock.module('@/lib/url', () => ({
|
||||
getUrlScheme: () => null,
|
||||
isAppLinkUrl: () => false,
|
||||
isExternalHttpUrl: () => false,
|
||||
openConfirmedAppLinkUrl: async () => false,
|
||||
openExternalUrl: async () => undefined,
|
||||
}));
|
||||
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => ({ currentTheme: rendererTheme() }) }));
|
||||
mock.module('@/lib/theme/themes', () => ({ getDefaultTheme: () => rendererTheme() }));
|
||||
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: FakeElement | FakeElement[] }) => children }));
|
||||
type RendererUiSelectorResult = boolean | string | (() => void);
|
||||
const fakeUseUIStore = Object.assign(
|
||||
(selector: (state: typeof rendererUiState) => RendererUiSelectorResult) => selector(rendererUiState),
|
||||
{ getState: () => rendererUiState },
|
||||
);
|
||||
mock.module('@/stores/useUIStore', () => ({ useUIStore: fakeUseUIStore }));
|
||||
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
||||
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
|
||||
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
||||
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
||||
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' }));
|
||||
mock.module('./markdown/markdownCore', () => ({
|
||||
getCachedMarkdownBlocks: () => cachedRendererBlocks,
|
||||
renderMarkdownBlocks: () => renderMarkdownBlocksForTest(),
|
||||
renderMarkdownSync: () => {
|
||||
syncRenderCalls += 1;
|
||||
return '<p>cold</p>';
|
||||
},
|
||||
}));
|
||||
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
|
||||
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
|
||||
mock.module('./markdown/detachedMarkdownDomCache', () => ({
|
||||
detachedMarkdownDomCache: {
|
||||
take: () => null,
|
||||
store: () => undefined,
|
||||
},
|
||||
}));
|
||||
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
|
||||
type TestDecorateContext = {
|
||||
labels: { copy: string };
|
||||
codeBlockLineWrap: boolean;
|
||||
renderMermaid: (source: string) => { svg?: string };
|
||||
};
|
||||
mock.module('./markdown/decorate', () => ({
|
||||
attachMarkdownInteractions: () => () => undefined,
|
||||
applyMarkdownCodeBlockWrapState: () => undefined,
|
||||
decorateMarkdown: (root: FakeElement, ctx: TestDecorateContext) => {
|
||||
decorateCalls += 1;
|
||||
if (root.getAttribute('data-test-decoration-marker') === 'true') return;
|
||||
root.setAttribute('data-test-decoration-marker', 'true');
|
||||
root.setAttribute(
|
||||
'data-test-decoration',
|
||||
`${ctx.labels.copy}|${ctx.codeBlockLineWrap}|${ctx.renderMermaid('test').svg ?? ''}`,
|
||||
);
|
||||
},
|
||||
getMarkdownCodeText: () => '',
|
||||
}));
|
||||
mock.module('./markdown/textPosition', () => ({ findTextPosition: () => null }));
|
||||
mock.module('./markdown/mermaidViewer', () => ({
|
||||
createMermaidViewerRegistry: () => {
|
||||
mermaidRegistryCreates += 1;
|
||||
return {
|
||||
refresh: () => undefined,
|
||||
cleanup: () => { mermaidRegistryCleanups += 1; },
|
||||
};
|
||||
},
|
||||
MERMAID_BLOCK_SELECTOR: '[data-markdown="mermaid-block"]',
|
||||
shouldRefreshMermaidViewers: (container: Pick<FakeElement, 'querySelector'>) => container.querySelector('[data-markdown="mermaid-block"]') !== null,
|
||||
}));
|
||||
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
|
||||
mock.module('morphdom', () => ({ default: () => { morphCalls += 1; } }));
|
||||
|
||||
const { MarkdownRenderer } = await import('./MarkdownRendererImpl');
|
||||
|
||||
const resetRendererTestState = () => {
|
||||
cachedRendererBlocks = null;
|
||||
renderedRendererBlocks = [];
|
||||
renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
|
||||
syncRenderCalls = 0;
|
||||
morphCalls = 0;
|
||||
decorateCalls = 0;
|
||||
mermaidRegistryCreates = 0;
|
||||
mermaidRegistryCleanups = 0;
|
||||
hookCursor = 0;
|
||||
hookStates = [];
|
||||
layoutEffects.length = 0;
|
||||
passiveEffects.length = 0;
|
||||
currentContextVersion = 0;
|
||||
rendererThemeIndex = 0;
|
||||
rendererUiState.codeBlockLineWrap = false;
|
||||
};
|
||||
|
||||
const beginRendererRender = () => {
|
||||
hookCursor = 0;
|
||||
return renderMarkdownForTest();
|
||||
};
|
||||
|
||||
const rendererRoot = (value: ReturnType<typeof renderMarkdownForTest>): FakeElement => {
|
||||
if (!(value instanceof Object) || !('childNodes' in value) || !('getAttribute' in value)) {
|
||||
throw new Error('Renderer test did not return its fake JSX root');
|
||||
}
|
||||
// SAFETY: the structural check confirms this ReactNode is the object
|
||||
// returned by the mocked JSX runtime.
|
||||
const candidate = value as object;
|
||||
// SAFETY: the mocked JSX runtime creates the complete FakeElement shape.
|
||||
return candidate as FakeElement;
|
||||
};
|
||||
|
||||
const runRendererLayoutEffects = () => {
|
||||
const pending = layoutEffects.splice(0);
|
||||
for (const effect of pending) effect();
|
||||
};
|
||||
|
||||
const runRendererPassiveEffects = () => passiveEffects.splice(0).map((effect) => effect());
|
||||
|
||||
const findBlock = (root: FakeElement, id: string): FakeElement | null => {
|
||||
if (root.getAttribute('data-md-id') === id) return root;
|
||||
for (const child of root.children) {
|
||||
const match = findBlock(child, id);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderMarkdownForTest = () => MarkdownRenderer({
|
||||
content: 'cached markdown',
|
||||
messageId: 'message-1',
|
||||
isAnimated: false,
|
||||
isStreaming: false,
|
||||
});
|
||||
|
||||
const withRendererDom = async (run: () => void | Promise<void>): Promise<void> => {
|
||||
const restoreDom = installRendererDom();
|
||||
const previousThemeIndex = rendererThemeIndex;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
rendererThemeIndex = previousThemeIndex;
|
||||
restoreDom();
|
||||
}
|
||||
};
|
||||
|
||||
describe('parseFileReference', () => {
|
||||
test('returns null for empty or whitespace input', () => {
|
||||
expect(parse('')).toBeNull();
|
||||
@@ -72,11 +440,7 @@ describe('parseFileReference', () => {
|
||||
});
|
||||
|
||||
test('preserves line:col form (does not interpret as range)', () => {
|
||||
expect(parse('src/foo.ts:42:8')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: 8,
|
||||
});
|
||||
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
|
||||
});
|
||||
|
||||
test('preserves hash form', () => {
|
||||
@@ -110,3 +474,120 @@ describe('localPathFromFileUrl', () => {
|
||||
expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownRenderer warm settled path', () => {
|
||||
test('installs cached blocks without sync fallback and skips same-ID morph', async () => {
|
||||
await withRendererDom(async () => {
|
||||
resetRendererTestState();
|
||||
cachedRendererBlocks = [{ id: 'full:cached', html: '<p>cached</p>' }];
|
||||
renderedRendererBlocks = cachedRendererBlocks;
|
||||
syncRenderCalls = 0;
|
||||
morphCalls = 0;
|
||||
decorateCalls = 0;
|
||||
|
||||
// SAFETY: the test JSX adapter returns the fake element assigned to the
|
||||
// renderer container ref and exposes the DOM members used below.
|
||||
const root = rendererRoot(beginRendererRender());
|
||||
runRendererLayoutEffects();
|
||||
expect(syncRenderCalls).toBe(0);
|
||||
const block = findBlock(root, 'full:cached');
|
||||
expect(block).not.toBeNull();
|
||||
expect(block?.innerHTML).toBe('<p>cached</p>');
|
||||
expect(block?.getAttribute('data-md-block')).toBe('');
|
||||
expect(block?.getAttribute('data-md-id')).toBe('full:cached');
|
||||
expect(block?.style.display).toBe('contents');
|
||||
expect(decorateCalls).toBe(1);
|
||||
|
||||
runRendererPassiveEffects();
|
||||
await Promise.resolve();
|
||||
expect(morphCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('recreates the Mermaid registry after StrictMode-like cleanup without remounting blocks', () => {
|
||||
return withRendererDom(() => {
|
||||
resetRendererTestState();
|
||||
const mermaidHtml = '<div data-markdown="mermaid-block"><svg></svg></div>';
|
||||
cachedRendererBlocks = [{ id: 'full:mermaid', html: mermaidHtml }];
|
||||
renderedRendererBlocks = cachedRendererBlocks;
|
||||
mermaidRegistryCreates = 0;
|
||||
mermaidRegistryCleanups = 0;
|
||||
morphCalls = 0;
|
||||
|
||||
const root = rendererRoot(beginRendererRender());
|
||||
runRendererLayoutEffects();
|
||||
expect(mermaidRegistryCreates).toBe(1);
|
||||
const cleanups = runRendererPassiveEffects();
|
||||
for (const cleanup of cleanups) cleanup?.();
|
||||
expect(mermaidRegistryCleanups).toBe(1);
|
||||
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
expect(mermaidRegistryCreates).toBe(2);
|
||||
expect(findBlock(root, 'full:mermaid')).not.toBeNull();
|
||||
expect(morphCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('redecorates a same-ID block when decoration context changes before async completion', async () => {
|
||||
await withRendererDom(async () => {
|
||||
resetRendererTestState();
|
||||
cachedRendererBlocks = [{
|
||||
id: 'full:context',
|
||||
html: '<div data-markdown="mermaid-block"><p>cached</p></div>',
|
||||
}];
|
||||
renderedRendererBlocks = cachedRendererBlocks;
|
||||
|
||||
const root = rendererRoot(beginRendererRender());
|
||||
runRendererLayoutEffects();
|
||||
const block = findBlock(root, 'full:context');
|
||||
const firstDecorationId = block?.getAttribute('data-md-decoration-id');
|
||||
expect(firstDecorationId).not.toBeNull();
|
||||
const firstDecorateCalls = decorateCalls;
|
||||
|
||||
rendererThemeIndex = 1;
|
||||
currentContextVersion = 1;
|
||||
rendererUiState.codeBlockLineWrap = true;
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
runRendererPassiveEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(decorateCalls).toBeGreaterThan(firstDecorateCalls);
|
||||
expect(syncRenderCalls).toBe(0);
|
||||
expect(morphCalls).toBe(0);
|
||||
const updatedBlock = findBlock(root, 'full:context');
|
||||
expect(updatedBlock?.getAttribute('data-md-decoration-id')).not.toBe(firstDecorationId);
|
||||
expect(updatedBlock?.getAttribute('data-test-decoration')).toContain(':1|true|#eee');
|
||||
expect(updatedBlock?.getAttribute('data-test-decoration-marker')).toBe('true');
|
||||
expect(mermaidRegistryCleanups).toBeGreaterThan(0);
|
||||
expect(mermaidRegistryCreates).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects an older async render after a newer layout commit', async () => {
|
||||
await withRendererDom(async () => {
|
||||
resetRendererTestState();
|
||||
cachedRendererBlocks = [{ id: 'full:initial', html: '<p>initial</p>' }];
|
||||
let resolveOldRender: ((blocks: Array<{ id: string; html: string }>) => void) | undefined;
|
||||
const oldRender = new Promise<Array<{ id: string; html: string }>>((resolve) => {
|
||||
resolveOldRender = resolve;
|
||||
});
|
||||
renderMarkdownBlocksForTest = () => oldRender;
|
||||
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
runRendererPassiveEffects();
|
||||
|
||||
cachedRendererBlocks = [{ id: 'full:new', html: '<p>new</p>' }];
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
expect(resolveOldRender).toBeDefined();
|
||||
resolveOldRender?.([{ id: 'full:old-late', html: '<p>old late</p>' }]);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(morphCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -4,11 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { openAppLinkWithConfirmation } from './appLinkConfirmation';
|
||||
import { attachAppLinkInteractions } from './appLinkInteractions';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -19,7 +20,12 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { renderMarkdownBlocks, renderMarkdownSync, type MarkdownImageMode } from './markdown/markdownCore';
|
||||
import {
|
||||
getCachedMarkdownBlocks,
|
||||
renderMarkdownBlocks,
|
||||
renderMarkdownSync,
|
||||
type MarkdownImageMode,
|
||||
} from './markdown/markdownCore';
|
||||
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
||||
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
||||
import {
|
||||
@@ -42,7 +48,10 @@ import {
|
||||
parseFileReference,
|
||||
type ParsedFileReference,
|
||||
} from './fileReferenceParser';
|
||||
import { fileReferenceExists } from './fileReferenceStat';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
@@ -55,7 +64,7 @@ const useCurrentMermaidTheme = () => {
|
||||
: fallbackLight);
|
||||
};
|
||||
|
||||
const useExternalLinkInteractions = ({
|
||||
const useLinkInteractions = ({
|
||||
containerRef,
|
||||
enabled,
|
||||
}: {
|
||||
@@ -63,48 +72,16 @@ const useExternalLinkInteractions = ({
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = target.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute('href') ?? '';
|
||||
if (!isExternalHttpUrl(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void openExternalUrl(href);
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
return attachAppLinkInteractions(container, {
|
||||
allowExternalHttp: enabled !== false,
|
||||
openAppLink: (href) => void openAppLinkWithConfirmation(href),
|
||||
openExternalHttp: (href) => void openExternalUrl(href),
|
||||
});
|
||||
}, [containerRef, enabled]);
|
||||
};
|
||||
|
||||
@@ -151,19 +128,9 @@ const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
|
||||
// output. The regex is defined in `./fileReferenceParser`; the inline-code
|
||||
// pipeline reads full text content rather than using this regex.
|
||||
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
|
||||
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
||||
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
||||
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
|
||||
const FILE_REFERENCE_LINK_LIMIT = 80;
|
||||
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
|
||||
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
|
||||
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
|
||||
let activeFileReferenceStatCount = 0;
|
||||
const pendingFileReferenceStats: Array<() => void> = [];
|
||||
|
||||
const getFileReferenceStatCacheMax = (): number => (
|
||||
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
|
||||
);
|
||||
|
||||
const getFileReferenceLinkLimit = (): number => (
|
||||
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
|
||||
@@ -361,61 +328,6 @@ const getResolvedReference = (rawValue: string, effectiveDirectory: string): (Pa
|
||||
};
|
||||
};
|
||||
|
||||
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
|
||||
const normalizedPath = normalizePath(resolvedPath);
|
||||
if (!normalizedPath) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
|
||||
if (cached) {
|
||||
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
|
||||
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const request = new Promise<boolean>((resolve) => {
|
||||
const run = () => {
|
||||
activeFileReferenceStatCount += 1;
|
||||
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
|
||||
resolve(payload?.exists !== false);
|
||||
})
|
||||
.catch(() => resolve(false))
|
||||
.finally(() => {
|
||||
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
|
||||
pendingFileReferenceStats.shift()?.();
|
||||
});
|
||||
};
|
||||
|
||||
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingFileReferenceStats.push(run);
|
||||
});
|
||||
|
||||
const maxCacheEntries = getFileReferenceStatCacheMax();
|
||||
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
|
||||
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
|
||||
if (typeof oldest !== 'string') {
|
||||
break;
|
||||
}
|
||||
FILE_REFERENCE_STAT_CACHE.delete(oldest);
|
||||
}
|
||||
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
||||
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
|
||||
};
|
||||
@@ -440,6 +352,13 @@ const useFileReferenceInteractions = ({
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
// Wait for the real directory: annotating against an empty/fallback
|
||||
// directory issues stat probes under the wrong cache key (and the wrong
|
||||
// server directory), and the pass reruns anyway once the directory
|
||||
// resolves — every link ended up verified twice.
|
||||
if (enabled && !effectiveDirectory) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
|
||||
// On mobile surfaces, file-reference highlighting is disabled entirely — not
|
||||
@@ -493,6 +412,19 @@ const useFileReferenceInteractions = ({
|
||||
};
|
||||
|
||||
const annotateFileLinks = () => {
|
||||
annotationWriteDepth += 1;
|
||||
try {
|
||||
annotateFileLinksInner();
|
||||
} finally {
|
||||
// Let the mutation events from our own writes flush before the
|
||||
// observer starts listening for real content changes again.
|
||||
queueMicrotask(() => {
|
||||
annotationWriteDepth -= 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const annotateFileLinksInner = () => {
|
||||
if (fileReferencesEnabled) {
|
||||
wrapBlockCodePathTokens(container);
|
||||
}
|
||||
@@ -521,7 +453,7 @@ const useFileReferenceInteractions = ({
|
||||
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
|
||||
const existsPromise = canGrantOutsideFile
|
||||
? Promise.resolve(true)
|
||||
: fileReferenceExists(resolved.resolvedPath);
|
||||
: fileReferenceExists(resolved.resolvedPath, effectiveDirectory);
|
||||
|
||||
void existsPromise.then((exists) => {
|
||||
if (cancelled || !exists || !container.contains(candidate)) {
|
||||
@@ -621,7 +553,12 @@ const useFileReferenceInteractions = ({
|
||||
|
||||
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
|
||||
|
||||
// Our own annotation writes (path-token wrapping, attribute updates) fire
|
||||
// childList mutations too; observing them re-ran the whole pass — every
|
||||
// link was scanned and verified twice per render.
|
||||
let annotationWriteDepth = 0;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (annotationWriteDepth > 0) return;
|
||||
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
|
||||
});
|
||||
observer.observe(container, {
|
||||
@@ -754,6 +691,19 @@ const useMermaidInlineInteractions = ({
|
||||
// so a stable diagram is laid out once and served from cache thereafter.
|
||||
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
|
||||
const MERMAID_RENDER_CACHE_MAX = 100;
|
||||
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
|
||||
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
|
||||
let nextMarkdownDecorationId = 0;
|
||||
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
|
||||
|
||||
const getMarkdownDecorationId = (ctx: DecorateContext): string => {
|
||||
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
|
||||
if (existing) return existing;
|
||||
const id = `decoration-${nextMarkdownDecorationId}`;
|
||||
nextMarkdownDecorationId += 1;
|
||||
MARKDOWN_DECORATION_IDS.set(ctx, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => {
|
||||
const existing = MERMAID_RENDER_CACHE.get(key);
|
||||
@@ -835,24 +785,31 @@ const useMorphdomMarkdown = ({
|
||||
containerRef,
|
||||
text,
|
||||
streaming,
|
||||
cacheKey,
|
||||
imageMode = 'inline',
|
||||
syntaxVars,
|
||||
ctx,
|
||||
domCacheKey,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
cacheKey: string;
|
||||
imageMode?: MarkdownImageMode;
|
||||
syntaxVars: Record<string, string>;
|
||||
ctx: DecorateContext;
|
||||
domCacheKey?: DetachedMarkdownDomKey | null;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
ensureMarkdownShikiTheme();
|
||||
}, []);
|
||||
|
||||
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
|
||||
const renderRevisionRef = React.useRef(0);
|
||||
// Only DOM that was actually restored or completed by the async pipeline is
|
||||
// eligible for capture. A fallback from an earlier content revision is not.
|
||||
const mountedDomRef = React.useRef<{
|
||||
key: DetachedMarkdownDomKey;
|
||||
copiedLabel: string;
|
||||
} | null>(null);
|
||||
const refreshMermaidViewers = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
@@ -868,6 +825,63 @@ const useMorphdomMarkdown = ({
|
||||
mermaidViewerRef.current.refresh();
|
||||
}, [containerRef]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
renderRevisionRef.current += 1;
|
||||
mountedDomRef.current = null;
|
||||
}, [ctx, imageMode, streaming, text]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!domCacheKey) return;
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target || target.childNodes.length > 0) return;
|
||||
|
||||
const cached = detachedMarkdownDomCache.take(domCacheKey);
|
||||
if (cached) {
|
||||
target.appendChild(cached);
|
||||
const decorationId = getMarkdownDecorationId(ctx);
|
||||
for (const block of Array.from(target.children)) {
|
||||
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
}
|
||||
for (const [key, value] of Object.entries(syntaxVars)) target.style.setProperty(key, value);
|
||||
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
|
||||
mountedDomRef.current = {
|
||||
key: domCacheKey,
|
||||
copiedLabel: ctx.labels.copied,
|
||||
};
|
||||
streamPerfCount('ui.markdown_renderer.dom_cache.hit');
|
||||
}
|
||||
}, [containerRef, ctx, domCacheKey, syntaxVars, text.length]);
|
||||
|
||||
// Restoration follows the cache identity above, but capture must only happen
|
||||
// when this renderer lifecycle ends. Combining both in one keyed effect would
|
||||
// detach the live DOM on ordinary content, theme, or locale updates.
|
||||
React.useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target) return;
|
||||
return () => {
|
||||
const mountedDom = mountedDomRef.current;
|
||||
if (!mountedDom) return;
|
||||
// Viewer controllers and transient interaction state belong to the
|
||||
// current renderer instance and must not cross the cache boundary.
|
||||
if (target.childNodes.length === 0 || shouldRefreshMermaidViewers(target)) return;
|
||||
if (Array.from(target.children).some((block) => !block.hasAttribute('data-md-id'))) return;
|
||||
if (target.querySelector('[data-md-copy-pending]')) return;
|
||||
const selection = window.getSelection();
|
||||
if (selection?.rangeCount && !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(target)) return;
|
||||
const openMenu = target.querySelector<HTMLElement>('[data-md-menu]:not(.hidden)');
|
||||
const copiedButton = Array.from(target.querySelectorAll<HTMLButtonElement>('[data-md-action]'))
|
||||
.some((button) => button.getAttribute('title') === mountedDom.copiedLabel);
|
||||
if (openMenu || copiedButton) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(...Array.from(target.childNodes));
|
||||
detachedMarkdownDomCache.store({ ...mountedDom.key, fragment });
|
||||
streamPerfCount('ui.markdown_renderer.dom_cache.capture');
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
// Synchronous first paint: while the async parse is in-flight, show escaped
|
||||
// plain text immediately so there is no blank frame on initial mount. Only
|
||||
// runs when the target is empty — subsequent updates keep the prior rich DOM
|
||||
@@ -877,25 +891,40 @@ const useMorphdomMarkdown = ({
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target) return;
|
||||
const decorationId = getMarkdownDecorationId(ctx);
|
||||
if (text && target.childNodes.length === 0) {
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-md-block', '');
|
||||
// `display:contents` keeps margin-collapsing/spacing identical to a flat
|
||||
// HTML body — the wrapper exists only for per-block reconciliation.
|
||||
block.style.display = 'contents';
|
||||
block.innerHTML = renderMarkdownSync(text, imageMode);
|
||||
// Decorate synchronously too: wrap code blocks in their framed card,
|
||||
// mark inline code, build table controls, etc. The async pass re-decorates
|
||||
// its own DOM before morphing, so without this the first paint shows bare
|
||||
// <pre>/tables that "snap" into their decorated form a tick later. Matching
|
||||
// the structure here keeps the async morph to syntax colors only.
|
||||
decorateMarkdown(block, ctx);
|
||||
target.appendChild(block);
|
||||
if (shouldRefreshMermaidViewers(block)) {
|
||||
refreshMermaidViewers();
|
||||
const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
|
||||
if (cachedBlocks) {
|
||||
let hasMermaidBlock = false;
|
||||
for (const cachedBlock of cachedBlocks) {
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-md-block', '');
|
||||
block.style.display = 'contents';
|
||||
block.innerHTML = cachedBlock.html;
|
||||
decorateMarkdown(block, ctx);
|
||||
block.setAttribute('data-md-id', cachedBlock.id);
|
||||
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
|
||||
target.appendChild(block);
|
||||
}
|
||||
if (hasMermaidBlock) refreshMermaidViewers();
|
||||
} else {
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-md-block', '');
|
||||
block.style.display = 'contents';
|
||||
block.innerHTML = renderMarkdownSync(text, imageMode);
|
||||
decorateMarkdown(block, ctx);
|
||||
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
target.appendChild(block);
|
||||
if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
|
||||
}
|
||||
} else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
|
||||
// StrictMode re-runs this setup after the cleanup probe. The DOM remains,
|
||||
// but the viewer registry does not, so recreate it without reinstalling
|
||||
// or re-decorating ordinary blocks.
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
}, [containerRef, text, imageMode, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
@@ -907,27 +936,70 @@ const useMorphdomMarkdown = ({
|
||||
if (!container) return;
|
||||
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
let active = true;
|
||||
const renderRevision = renderRevisionRef.current;
|
||||
const decorationId = getMarkdownDecorationId(ctx);
|
||||
|
||||
void renderMarkdownBlocks(text, streaming, cacheKey, imageMode).then((blocks) => {
|
||||
if (!active) return;
|
||||
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
||||
if (!active || renderRevisionRef.current !== renderRevision) return;
|
||||
const existing = Array.from(target.children) as HTMLElement[];
|
||||
|
||||
// Reconcile per block: only re-morph blocks whose content changed, leaving
|
||||
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
|
||||
// to the trailing (growing) block instead of the whole message.
|
||||
let enteredThisPass = 0;
|
||||
blocks.forEach((block, index) => {
|
||||
let el = existing[index];
|
||||
let isNewBlock = false;
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.setAttribute('data-md-block', '');
|
||||
el.style.display = 'contents';
|
||||
target.appendChild(el);
|
||||
isNewBlock = true;
|
||||
}
|
||||
if (el.getAttribute('data-md-id') === block.id) {
|
||||
if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
|
||||
const hasMermaidBlock = shouldRefreshMermaidViewers(el);
|
||||
if (hasMermaidBlock) {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
mermaidViewerRef.current = null;
|
||||
}
|
||||
const replacement = document.createElement('div');
|
||||
replacement.setAttribute('data-md-block', '');
|
||||
replacement.style.display = 'contents';
|
||||
replacement.innerHTML = block.html;
|
||||
decorateMarkdown(replacement, ctx);
|
||||
replacement.setAttribute('data-md-id', block.id);
|
||||
replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
el.replaceWith(replacement);
|
||||
if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
|
||||
}
|
||||
if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (el.getAttribute('data-md-id') === block.id) return;
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = block.html;
|
||||
decorateMarkdown(temp, ctx);
|
||||
if (isNewBlock && streaming && index > 0) {
|
||||
// A freshly committed block enters with a short reveal. The class
|
||||
// goes on the block's children — the wrapper is display:contents
|
||||
// and cannot animate — and the transform never changes layout, so
|
||||
// row measurement stays exact. Skipped for the first block so a
|
||||
// full initial render does not shimmer. Several blocks committed
|
||||
// in one tick cascade with a small stagger instead of popping in
|
||||
// together.
|
||||
const delayMs = Math.min(enteredThisPass, 4) * 55;
|
||||
enteredThisPass += 1;
|
||||
for (const child of Array.from(temp.children)) {
|
||||
child.classList.add('oc-md-block-enter');
|
||||
if (delayMs > 0 && child instanceof HTMLElement) {
|
||||
child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
|
||||
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
|
||||
morphdom(el, temp, {
|
||||
@@ -935,12 +1007,12 @@ const useMorphdomMarkdown = ({
|
||||
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
|
||||
});
|
||||
el.setAttribute('data-md-id', block.id);
|
||||
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
});
|
||||
|
||||
// Remove any trailing block elements no longer present.
|
||||
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
|
||||
let removedMermaidBlock = false;
|
||||
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
|
||||
@@ -953,13 +1025,15 @@ const useMorphdomMarkdown = ({
|
||||
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
|
||||
mountedDomRef.current = domCacheKey
|
||||
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
|
||||
: null;
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [containerRef, text, streaming, cacheKey, imageMode, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1036,20 +1110,48 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
enabled: enableFileReferences && !isStreaming,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef });
|
||||
useLinkInteractions({ containerRef });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
const { locale } = useI18n();
|
||||
const imageMode: MarkdownImageMode = variant === 'assistant' ? 'label' : 'inline';
|
||||
const settledPart = part
|
||||
&& (part.type === 'text' || part.type === 'reasoning')
|
||||
&& part.time?.end !== undefined
|
||||
? part
|
||||
: null;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
// Memoized on scalar identities, not the part object: sync-store reducers
|
||||
// recreate part objects on unrelated updates, and an object-identity dep
|
||||
// re-ran the async render pipeline for identical content.
|
||||
const settledSessionID = settledPart?.sessionID;
|
||||
const settledMessageID = settledPart?.messageID;
|
||||
const settledPartID = settledPart?.id;
|
||||
const domCacheKey = React.useMemo<DetachedMarkdownDomKey | null>(() => {
|
||||
// Streaming, unfinished, oversized, and identity-less Markdown continues
|
||||
// through the normal rendering pipeline and never retains detached DOM.
|
||||
if (isStreaming || !settledSessionID || !settledMessageID || !settledPartID || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null;
|
||||
// content.length is a cheap fingerprint: an edited or reverted part that
|
||||
// re-materializes under the same id must not restore the old DOM.
|
||||
return {
|
||||
scope: `${runtimeKey}\0${settledSessionID}`,
|
||||
id: `${settledMessageID}\0${settledPartID}\0${imageMode}\0${content.length}`,
|
||||
locale,
|
||||
directory: effectiveDirectory,
|
||||
};
|
||||
}, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledSessionID, settledMessageID, settledPartID]);
|
||||
// Identity for the fade-in wrapper: a new part/message restarts the animation.
|
||||
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
useMorphdomMarkdown({
|
||||
containerRef,
|
||||
text: content,
|
||||
streaming: live,
|
||||
cacheKey,
|
||||
imageMode: variant === 'assistant' ? 'label' : 'inline',
|
||||
imageMode,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
domCacheKey,
|
||||
});
|
||||
|
||||
const markdownContent = (
|
||||
@@ -1060,7 +1162,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
|
||||
if (isAnimated) {
|
||||
return (
|
||||
<FadeInOnReveal key={cacheKey} skipAnimation={skipFadeIn}>
|
||||
<FadeInOnReveal key={fadeKey} skipAnimation={skipFadeIn}>
|
||||
{markdownContent}
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
@@ -1087,6 +1189,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
content: string;
|
||||
className?: string;
|
||||
variant?: MarkdownVariant;
|
||||
// App links remain confirmed even where ordinary HTTP link handling is off.
|
||||
disableLinkSafety?: boolean;
|
||||
stripFrontmatter?: boolean;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
@@ -1128,7 +1231,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
enabled: enableFileReferences,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||
useLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
|
||||
@@ -1137,7 +1240,6 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
containerRef,
|
||||
text: renderedContent,
|
||||
streaming: false,
|
||||
cacheKey: `simple:${variant}`,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,8 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
|
||||
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
@@ -57,6 +58,28 @@ type MobileVariantTarget = { providerId: string; modelId: string };
|
||||
const buildModelRefKey = (providerID: string, modelID: string) => `${providerID}:${modelID}`;
|
||||
const MAX_INLINE_MOBILE_VARIANT_OPTIONS = 6;
|
||||
|
||||
const AgentDescriptionTooltip: React.FC<{
|
||||
description?: string;
|
||||
children: React.ReactElement;
|
||||
}> = ({ description, children }) => {
|
||||
if (!description) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={450}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground">{description}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
@@ -506,13 +529,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
const sortedAndFilteredAgents = React.useMemo(() => {
|
||||
const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (!agentSearchQuery.trim()) {
|
||||
return sorted;
|
||||
}
|
||||
return sorted.filter((agent) =>
|
||||
fuzzyMatch(agent.name, agentSearchQuery) ||
|
||||
(agent.description && fuzzyMatch(agent.description, agentSearchQuery))
|
||||
);
|
||||
return rankByQuery(sorted, agentSearchQuery, (agent) => [agent.name, agent.description]);
|
||||
}, [selectableDesktopAgents, agentSearchQuery]);
|
||||
|
||||
const defaultAgentName = React.useMemo(() => {
|
||||
@@ -558,38 +575,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return result;
|
||||
}, [providers, hiddenModels]);
|
||||
|
||||
const normalizeModelSearchValue = React.useCallback((value: string) => {
|
||||
const lower = value.toLowerCase().trim();
|
||||
const compact = lower.replace(/[^a-z0-9]/g, '');
|
||||
const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean);
|
||||
return { lower, compact, tokens };
|
||||
}, []);
|
||||
|
||||
const matchesModelSearch = React.useCallback((candidate: string, query: string) => {
|
||||
const normalizedQuery = normalizeModelSearchValue(query);
|
||||
if (!normalizedQuery.lower) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedCandidate = normalizeModelSearchValue(candidate);
|
||||
if (normalizedCandidate.lower.includes(normalizedQuery.lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedQuery.tokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedQuery.tokens.every((queryToken) =>
|
||||
normalizedCandidate.tokens.some((candidateToken) =>
|
||||
candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken)
|
||||
)
|
||||
);
|
||||
}, [normalizeModelSearchValue]);
|
||||
const matchesModelSearch = React.useCallback(
|
||||
(candidate: string, query: string) => matchesRankQuery([candidate], query),
|
||||
[],
|
||||
);
|
||||
|
||||
const currentModelForMetadata = currentModelId
|
||||
? models.find((model: ProviderModel) => model.id === currentModelId)
|
||||
@@ -893,25 +882,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
? useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
|
||||
: null;
|
||||
if (savedAgentName) {
|
||||
if (currentAgentName !== savedAgentName) {
|
||||
setAgent(savedAgentName);
|
||||
}
|
||||
|
||||
const savedModel = getAgentModelForSession(currentSessionId, savedAgentName);
|
||||
if (savedModel) {
|
||||
const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName);
|
||||
if (result === 'applied') {
|
||||
if (currentAgentName !== savedAgentName) {
|
||||
setAgent(savedAgentName);
|
||||
}
|
||||
return 'resolved';
|
||||
}
|
||||
if (result === 'provider-missing') {
|
||||
return 'waiting';
|
||||
}
|
||||
} else if (currentAgentName !== savedAgentName) {
|
||||
setAgent(savedAgentName);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedSessionModel) {
|
||||
const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined);
|
||||
if (result === 'applied') {
|
||||
if (savedAgentName && currentAgentName !== savedAgentName) {
|
||||
setAgent(savedAgentName);
|
||||
}
|
||||
return 'resolved';
|
||||
}
|
||||
if (result === 'provider-missing') {
|
||||
@@ -925,16 +918,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentAgentName !== agent.name) {
|
||||
setAgent(agent.name);
|
||||
}
|
||||
|
||||
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
|
||||
if (!existingSelection) {
|
||||
saveSessionAgentSelection(currentSessionId, agent.name);
|
||||
}
|
||||
const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
|
||||
if (result === 'applied') {
|
||||
if (currentAgentName !== agent.name) {
|
||||
setAgent(agent.name);
|
||||
}
|
||||
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
|
||||
if (!existingSelection) {
|
||||
saveSessionAgentSelection(currentSessionId, agent.name);
|
||||
}
|
||||
return 'resolved';
|
||||
}
|
||||
if (result === 'provider-missing') {
|
||||
@@ -2316,9 +2308,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col"
|
||||
side="top"
|
||||
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden"
|
||||
align="end"
|
||||
alignOffset={-40}
|
||||
constrainToMain
|
||||
collisionAvoidance={{ side: 'none', align: 'shift' }}
|
||||
onKeyDownCapture={handleModelShortcutKeyDownCapture}
|
||||
>
|
||||
<div className="p-1 border-b border-border/40">
|
||||
@@ -2375,6 +2370,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
maxHeightClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1"
|
||||
tooltipsEnabled={agentMenuOpen}
|
||||
onEscape={() => setAgentMenuOpen(false)}
|
||||
/>
|
||||
@@ -2618,7 +2614,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
|
||||
<DropdownMenuContent side="top" align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
|
||||
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">{t('chat.modelControls.thinking')}</DropdownMenuLabel>
|
||||
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
|
||||
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||
@@ -2708,7 +2704,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col">
|
||||
<DropdownMenuContent side="top" align="end" alignOffset={-40} constrainToMain collisionAvoidance={{ side: 'none', align: 'shift' }} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden">
|
||||
<div className="p-2 border-b border-border/40">
|
||||
<div className="relative">
|
||||
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
@@ -2724,7 +2720,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
|
||||
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1">
|
||||
<div className="p-1">
|
||||
{!agentSearchQuery.trim() && defaultAgentName && (
|
||||
<>
|
||||
@@ -2746,12 +2742,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
sortedAndFilteredAgents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.name}
|
||||
className="typography-meta"
|
||||
onSelect={() => handleAgentChange(agent.name)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<AgentDescriptionTooltip key={agent.name} description={agent.description}>
|
||||
<DropdownMenuItem
|
||||
className="typography-meta"
|
||||
onSelect={() => handleAgentChange(agent.name)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn(
|
||||
'h-1 w-1 rounded-full agent-dot',
|
||||
@@ -2759,13 +2754,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
)} />
|
||||
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>
|
||||
</AgentDescriptionTooltip>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
|
||||
>
|
||||
<Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
|
||||
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
|
||||
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
|
||||
<span className="composer-status-bar__changed-label min-w-0 typography-ui-label text-foreground truncate">
|
||||
{t('chat.pendingChanges.changedInWorkspace')}
|
||||
</span>
|
||||
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
|
||||
|
||||
@@ -66,6 +66,14 @@ const getToolIcon = (toolName: string) => {
|
||||
return <Icon name="global" className={iconClass} />;
|
||||
}
|
||||
|
||||
if (tool === 'linear' || tool.startsWith('linear_')) {
|
||||
return <Icon name="linear" className={iconClass} />;
|
||||
}
|
||||
|
||||
if (tool === 'cloudflare' || tool.startsWith('cloudflare_') || tool === 'claudflare' || tool.startsWith('claudflare_')) {
|
||||
return <Icon name="cloudflare" className={iconClass} />;
|
||||
}
|
||||
|
||||
return <Icon name="tools" className={iconClass} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
||||
|
||||
interface SkillInfo {
|
||||
name: string;
|
||||
@@ -31,7 +32,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
}, ref) => {
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const selectedIndexRef = React.useRef(0);
|
||||
const keyboardNavigationRef = React.useRef(false);
|
||||
@@ -126,6 +127,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
const isProject = skill.scope === 'project';
|
||||
const source = skill.source || 'opencode';
|
||||
return (
|
||||
<AutocompleteRowTooltip description={skill.description} active={!isMobile && index === selectedIndex}>
|
||||
<div
|
||||
key={`${skill.name}-${skill.scope}`}
|
||||
ref={(el) => {
|
||||
@@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
{source}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description && !isMobile && (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AutocompleteRowTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
|
||||
const { t } = useI18n();
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const selectedIndexRef = React.useRef(0);
|
||||
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
|
||||
|
||||
@@ -1,123 +1,18 @@
|
||||
import React from "react";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDirectorySync } from "@/sync/sync-context";
|
||||
import type { Todo } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
// Compat aliases for old TodoItem shape
|
||||
type TodoItem = Todo & { id?: string };
|
||||
type TodoStatus = string;
|
||||
type TodoPriority = string;
|
||||
import { useUIStore } from "@/stores/useUIStore";
|
||||
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
|
||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
// The floating assistant-status chip that hovers above the composer while the
|
||||
// agent works ("Claude is working…", abort notice). ONLY that. The composer's
|
||||
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
|
||||
// to share this component, and every restyle of this chip (glass, placement)
|
||||
// silently dragged the composer bar and its dropdown along with it.
|
||||
|
||||
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
|
||||
|
||||
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
|
||||
in_progress: {
|
||||
textClassName: "text-foreground",
|
||||
},
|
||||
pending: {
|
||||
textClassName: "text-foreground",
|
||||
},
|
||||
completed: {
|
||||
textClassName: "text-muted-foreground line-through",
|
||||
},
|
||||
cancelled: {
|
||||
textClassName: "text-muted-foreground line-through",
|
||||
},
|
||||
};
|
||||
|
||||
const priorityClassName: Record<TodoPriority, string> = {
|
||||
high: "text-[var(--status-warning)]",
|
||||
medium: "text-muted-foreground",
|
||||
low: "text-muted-foreground/70",
|
||||
};
|
||||
|
||||
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
|
||||
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true"/>,
|
||||
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
|
||||
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
|
||||
};
|
||||
|
||||
const statusLabelKey: Record<TodoStatus, string> = {
|
||||
in_progress: "chat.statusRow.todo.status.inProgress",
|
||||
pending: "chat.statusRow.todo.status.pending",
|
||||
completed: "chat.statusRow.todo.status.completed",
|
||||
cancelled: "chat.statusRow.todo.status.cancelled",
|
||||
};
|
||||
|
||||
const priorityLabelKey: Record<TodoPriority, string> = {
|
||||
high: "chat.statusRow.todo.priority.high",
|
||||
medium: "chat.statusRow.todo.priority.medium",
|
||||
low: "chat.statusRow.todo.priority.low",
|
||||
};
|
||||
|
||||
interface TodoItemRowProps {
|
||||
todo: TodoItem;
|
||||
}
|
||||
|
||||
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
||||
const { t } = useI18n();
|
||||
const config = statusConfig[todo.status] || statusConfig.pending;
|
||||
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
|
||||
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
|
||||
|
||||
const statusIcon =
|
||||
todo.status === "in_progress" ? (
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true"/>
|
||||
) : todo.status === "completed" ? (
|
||||
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true"/>
|
||||
) : (
|
||||
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-0 py-0.5 gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-shrink-0">{statusIcon}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{t(statusKey as never)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 typography-ui-label",
|
||||
config.textClassName
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
|
||||
priorityClassName[todo.priority] ?? priorityClassName.medium
|
||||
)}
|
||||
>
|
||||
{priorityIcon[todo.priority] ?? priorityIcon.medium}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{t(priorityKey as never)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EMPTY_TODOS: TodoItem[] = [];
|
||||
|
||||
interface StatusRowProps {
|
||||
// Working state
|
||||
isWorking?: boolean;
|
||||
statusText?: string | null;
|
||||
isGenericStatus?: boolean;
|
||||
@@ -125,17 +20,10 @@ interface StatusRowProps {
|
||||
wasAborted?: boolean;
|
||||
abortActive?: boolean;
|
||||
retryInfo?: { attempt?: number; next?: number } | null;
|
||||
// Abort state (for mobile/vscode)
|
||||
showAbort?: boolean;
|
||||
onAbort?: () => void;
|
||||
// Abort status display
|
||||
showAbortStatus?: boolean;
|
||||
showAssistantStatus?: boolean;
|
||||
showTodos?: boolean;
|
||||
agentName?: string;
|
||||
modelName?: string | null;
|
||||
providerId?: string | null;
|
||||
leftAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
@@ -146,183 +34,43 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
wasAborted,
|
||||
abortActive,
|
||||
retryInfo,
|
||||
showAbort,
|
||||
onAbort,
|
||||
showAbortStatus,
|
||||
showAssistantStatus = true,
|
||||
showTodos = true,
|
||||
agentName,
|
||||
modelName,
|
||||
providerId,
|
||||
leftAccessory,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const liveTodos = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
|
||||
return state.todo[currentSessionId] ?? EMPTY_TODOS;
|
||||
},
|
||||
[currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const persistedSessionTodos = useTodosPersistStore(
|
||||
React.useCallback(
|
||||
(state) => (showTodos && currentSessionId && currentSessionDirectory
|
||||
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
|
||||
: undefined),
|
||||
[currentSessionDirectory, currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const todos: TodoItem[] = React.useMemo(() => {
|
||||
if (!currentSessionId) return EMPTY_TODOS;
|
||||
if (liveTodos.length > 0) return liveTodos;
|
||||
return persistedSessionTodos ?? EMPTY_TODOS;
|
||||
}, [liveTodos, persistedSessionTodos, currentSessionId]);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const isCompact = isMobile || isVSCodeRuntime();
|
||||
|
||||
// Filter out cancelled todos for display and keep original order.
|
||||
// This prevents items from jumping around when status changes.
|
||||
const visibleTodos = React.useMemo(() => {
|
||||
return todos.filter((todo) => todo.status !== "cancelled");
|
||||
}, [todos]);
|
||||
|
||||
// Find the current active todo (first in_progress, or first pending)
|
||||
const activeTodo = React.useMemo(() => {
|
||||
return (
|
||||
visibleTodos.find((t) => t.status === "in_progress") ||
|
||||
visibleTodos.find((t) => t.status === "pending") ||
|
||||
null
|
||||
);
|
||||
}, [visibleTodos]);
|
||||
|
||||
// Calculate progress
|
||||
const progress = React.useMemo(() => {
|
||||
const total = todos.filter((t) => t.status !== "cancelled").length;
|
||||
const completed = todos.filter((t) => t.status === "completed").length;
|
||||
return { completed, total };
|
||||
}, [todos]);
|
||||
|
||||
const statusSummary = React.useMemo(() => {
|
||||
const active = visibleTodos.filter((t) => t.status === "in_progress").length;
|
||||
const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
|
||||
return { active, left };
|
||||
}, [visibleTodos]);
|
||||
|
||||
const hasTodoContent = showTodos && statusSummary.left > 0;
|
||||
const hasAssistantContent = showAssistantStatus && (
|
||||
isWorking ||
|
||||
Boolean(wasAborted) ||
|
||||
Boolean(showAbortStatus)
|
||||
);
|
||||
const hasLeftAccessory = Boolean(leftAccessory);
|
||||
// Original logic from ChatInput
|
||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
||||
const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus);
|
||||
|
||||
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
|
||||
|
||||
// Close popover when clicking outside
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isExpanded]);
|
||||
|
||||
const toggleExpanded = () => setIsExpanded((prev) => !prev);
|
||||
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
|
||||
active: statusSummary.active,
|
||||
left: statusSummary.left,
|
||||
});
|
||||
|
||||
// Abort button for mobile/vscode
|
||||
const abortButton = showAbort && onAbort ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
|
||||
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
|
||||
>
|
||||
<Icon name="close-circle" aria-hidden="true"/>
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
// Todo trigger button
|
||||
const todoTrigger = hasTodoContent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
|
||||
aria-label={todoSummaryLabel}
|
||||
title={todoSummaryLabel}
|
||||
>
|
||||
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
|
||||
{!isCompact && activeTodo ? (
|
||||
<span className="status-row__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
|
||||
{activeTodo.content}
|
||||
</span>
|
||||
) : (
|
||||
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
|
||||
)}
|
||||
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
|
||||
{statusSummary.active}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
{statusSummary.left}
|
||||
</span>
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
// Don't render if nothing to show
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
// Mobile: breathing room between the last message and the agent status
|
||||
// line — without it the "<model> is running…" row sits flush against
|
||||
// the message above.
|
||||
className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")}
|
||||
// The row renders inside the composer-anchored overlay, which owns the
|
||||
// distance to the input and the horizontal column (the same ones the
|
||||
// scroll-to-bottom pill uses).
|
||||
style={STATUS_ROW_CONTAINER_STYLE}
|
||||
>
|
||||
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
|
||||
{/* Left: Abort status | Working placeholder | leftAccessory */}
|
||||
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
||||
{showAssistantStatus && showAbortStatus ? (
|
||||
{/* h-8 matches the turn footer's real row height: its h-8 action
|
||||
buttons define the footer line, with the meta text centered in it. */}
|
||||
{/* The glass chip lives here, not on the container: the root above is
|
||||
an inline-size query container, whose width ignores its children —
|
||||
a shrink-to-fit wrapper around it always collapsed to zero. */}
|
||||
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
|
||||
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
|
||||
{showAbortStatus ? (
|
||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
||||
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||
<Icon name="close-circle" aria-hidden="true"/>
|
||||
{t('chat.statusRow.aborted')}
|
||||
</span>
|
||||
</div>
|
||||
) : showAssistantStatus && shouldRenderPlaceholder ? (
|
||||
) : shouldRenderPlaceholder ? (
|
||||
<WorkingPlaceholder
|
||||
key={currentSessionId ?? "no-session"}
|
||||
isWorking={isWorking}
|
||||
@@ -334,50 +82,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
modelName={modelName}
|
||||
providerId={providerId}
|
||||
/>
|
||||
) : leftAccessory ? (
|
||||
leftAccessory
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: Abort (mobile only) + Todo */}
|
||||
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
|
||||
{abortButton}
|
||||
{todoTrigger}
|
||||
|
||||
{/* Popover dropdown */}
|
||||
{isExpanded && hasTodoContent && (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "min(28rem, calc(100cqw - 4ch))",
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
color: "var(--surface-elevated-foreground)",
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-0 bottom-full mb-1 z-50",
|
||||
"w-max min-w-[200px] rounded-xl p-1",
|
||||
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
|
||||
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
|
||||
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
||||
"duration-150"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
||||
<span>{t('chat.statusRow.tasksTitle')}</span>
|
||||
<span className="typography-meta tabular-nums">
|
||||
{progress.completed}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Todo list */}
|
||||
<div className="px-1 max-h-[200px] overflow-y-auto">
|
||||
{visibleTodos.map((todo, index) => (
|
||||
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,8 +46,6 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
||||
wasAborted={wasAborted || working.wasAborted}
|
||||
abortActive={wasAborted || working.abortActive}
|
||||
retryInfo={working.retryInfo}
|
||||
showAssistantStatus
|
||||
showTodos={false}
|
||||
agentName={currentAgentName}
|
||||
modelName={modelDisplayName}
|
||||
providerId={activeModel?.providerId ?? null}
|
||||
|
||||
@@ -223,20 +223,48 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
|
||||
if (!currentSessionId) return null;
|
||||
|
||||
const turnActions = (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
|
||||
onClick={() => {
|
||||
void onScrollByTurnOffset?.(-1);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t('chat.timeline.actions.previousTurn')}
|
||||
</button>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
|
||||
onClick={() => {
|
||||
onResumeToLatest?.();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t('chat.timeline.actions.latest')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] max-md:max-h-[85dvh] flex flex-col overflow-y-auto">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icon name="time" className="h-5 w-5" />
|
||||
{t('chat.timeline.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('chat.timeline.description')}
|
||||
</DialogDescription>
|
||||
{!isMobile && (
|
||||
<DialogDescription>
|
||||
{t('chat.timeline.description')}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative mt-2">
|
||||
<div className="relative mt-2 shrink-0">
|
||||
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -249,7 +277,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
</div>
|
||||
|
||||
{canLoadEarlier && onLoadEarlier && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="flex shrink-0 justify-center py-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
@@ -266,7 +294,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto">
|
||||
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
|
||||
@@ -312,7 +340,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className={cn(
|
||||
"typography-meta w-16 flex-shrink-0 text-right tabular-nums",
|
||||
"typography-meta min-w-16 flex-shrink-0 text-right tabular-nums whitespace-nowrap",
|
||||
isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground"
|
||||
)}>
|
||||
{messageTime}
|
||||
@@ -373,45 +401,31 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
||||
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
|
||||
onClick={() => {
|
||||
void onScrollByTurnOffset?.(-1);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t('chat.timeline.actions.previousTurn')}
|
||||
</button>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
|
||||
onClick={() => {
|
||||
onResumeToLatest?.();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t('chat.timeline.actions.latest')}
|
||||
</button>
|
||||
{isMobile ? (
|
||||
<div className="mt-2 flex shrink-0 items-center justify-center gap-2 border-t border-border/60 pt-2">
|
||||
{turnActions}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t('chat.timeline.help.clickMessage')}</span>
|
||||
) : (
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-lg shrink-0">
|
||||
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{turnActions}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="arrow-go-back" className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t('chat.timeline.help.undoToPoint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="git-branch" className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
|
||||
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t('chat.timeline.help.clickMessage')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="arrow-go-back" className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t('chat.timeline.help.undoToPoint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="git-branch" className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
+13
-10
@@ -226,20 +226,23 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
|
||||
expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);');
|
||||
});
|
||||
|
||||
test('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => {
|
||||
test('the empty and idle branch leaves the status row to the busy path', () => {
|
||||
// A busy session with no messages yet must fall through to the viewport so
|
||||
// StatusRowContainer is the only thing on screen. The idle branch returns
|
||||
// before it and must not render one of its own. The empty state itself no
|
||||
// longer lives here: the draft surface owns it since the draft transition
|
||||
// animation landed.
|
||||
expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)');
|
||||
expect(chatContainerSource).toContain('<ChatEmptyState');
|
||||
expect(chatContainerSource).toContain('<StatusRowContainer />');
|
||||
|
||||
const emptyBusyGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
|
||||
const emptyStateReturn = chatContainerSource.indexOf(emptyBusyGuard);
|
||||
expect(emptyStateReturn).toBeGreaterThan(-1);
|
||||
const emptyStateBlock = chatContainerSource.slice(
|
||||
emptyStateReturn,
|
||||
emptyStateReturn + 1600,
|
||||
const emptyIdleGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
|
||||
const emptyIdleReturn = chatContainerSource.indexOf(emptyIdleGuard);
|
||||
expect(emptyIdleReturn).toBeGreaterThan(-1);
|
||||
const emptyIdleBlock = chatContainerSource.slice(
|
||||
emptyIdleReturn,
|
||||
emptyIdleReturn + 1600,
|
||||
);
|
||||
expect(emptyStateBlock).toContain('<ChatEmptyState');
|
||||
expect(emptyStateBlock).not.toContain('<StatusRowContainer />');
|
||||
expect(emptyIdleBlock).not.toContain('<StatusRowContainer />');
|
||||
});
|
||||
|
||||
test('visibility handshake remains as defense-in-depth for background work', () => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Regression coverage for https://github.com/openchamber/openchamber/issues/3036.
|
||||
*
|
||||
* Restoring persisted agent/model pairs used to switch agents before checking
|
||||
* whether each model still existed. Several stale pairs could therefore keep
|
||||
* changing the active agent on every effect pass until React hit its nested
|
||||
* update limit. The API error belongs in the assistant message; an invalid
|
||||
* persisted pair must not mutate the current selection while it is rendered.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const modelControlsSource = readFileSync(join(__dirname, '..', 'ModelControls.tsx'), 'utf-8');
|
||||
|
||||
describe('issue #3036 stale persisted models', () => {
|
||||
test('changes the agent only after its persisted model is accepted', () => {
|
||||
const candidateLoop = modelControlsSource.slice(
|
||||
modelControlsSource.indexOf('for (const agent of agents)'),
|
||||
modelControlsSource.indexOf("return 'continue';"),
|
||||
);
|
||||
|
||||
const applyIndex = candidateLoop.indexOf('const result = tryApplyModelSelection');
|
||||
const acceptedIndex = candidateLoop.indexOf("if (result === 'applied')");
|
||||
const setAgentIndex = candidateLoop.indexOf('setAgent(agent.name)');
|
||||
|
||||
expect(applyIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(acceptedIndex).toBeGreaterThan(applyIndex);
|
||||
expect(setAgentIndex).toBeGreaterThan(acceptedIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
|
||||
|
||||
import {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
openAppLinkWithConfirmation,
|
||||
settleAppLinkConfirmation,
|
||||
} from './appLinkConfirmation';
|
||||
|
||||
describe('app link confirmation', () => {
|
||||
beforeEach(() => {
|
||||
useAppLinkTrustStore.setState({ trustedSchemes: [] });
|
||||
const pending = getAppLinkConfirmationSnapshot();
|
||||
if (pending) {
|
||||
settleAppLinkConfirmation('cancel');
|
||||
}
|
||||
});
|
||||
|
||||
test('opens trusted schemes without asking', async () => {
|
||||
useAppLinkTrustStore.getState().trustScheme('obsidian');
|
||||
|
||||
await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes');
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true);
|
||||
});
|
||||
|
||||
test('asks once and trusts the scheme when the user chooses trust', async () => {
|
||||
const pending = openAppLinkWithConfirmation('linear://issue/ABC-1');
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1');
|
||||
|
||||
settleAppLinkConfirmation('trust');
|
||||
await pending;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true);
|
||||
});
|
||||
|
||||
test('cancel opens nothing and keeps the scheme untrusted', async () => {
|
||||
const pending = openAppLinkWithConfirmation('notion://note/xyz');
|
||||
|
||||
settleAppLinkConfirmation('cancel');
|
||||
await pending;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false);
|
||||
});
|
||||
|
||||
test('a newer request cancels the pending one', async () => {
|
||||
const first = openAppLinkWithConfirmation('obsidian://open?vault=a');
|
||||
const firstChoice = first.then(
|
||||
() => 'settled',
|
||||
() => 'settled',
|
||||
);
|
||||
const second = openAppLinkWithConfirmation('linear://open/1');
|
||||
|
||||
expect(await firstChoice).toBe('settled');
|
||||
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1');
|
||||
|
||||
settleAppLinkConfirmation('open');
|
||||
await second;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
|
||||
import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url';
|
||||
|
||||
export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel';
|
||||
|
||||
type PendingAppLinkRequest = {
|
||||
url: string;
|
||||
resolve: (choice: AppLinkConfirmationChoice) => void;
|
||||
};
|
||||
|
||||
let pendingRequest: PendingAppLinkRequest | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emitChange = (): void => {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest;
|
||||
|
||||
const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask the user (via the app-level confirmation dialog) whether an application
|
||||
* deep link may be opened. Resolves immediately when the scheme was trusted
|
||||
* earlier. Only one request is active at a time; a new request cancels the
|
||||
* pending one.
|
||||
*/
|
||||
export const openAppLinkWithConfirmation = (url: string): Promise<void> => {
|
||||
const scheme = getUrlScheme(url);
|
||||
if (!scheme) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const trustStore = useAppLinkTrustStore.getState();
|
||||
if (trustStore.isSchemeTrusted(scheme)) {
|
||||
return openConfirmedAppLinkUrl(url).then(() => undefined);
|
||||
}
|
||||
|
||||
if (pendingRequest) {
|
||||
pendingRequest.resolve('cancel');
|
||||
}
|
||||
|
||||
return new Promise<AppLinkConfirmationChoice>((resolve) => {
|
||||
pendingRequest = { url, resolve };
|
||||
emitChange();
|
||||
}).then((choice) => {
|
||||
if (choice === 'trust') {
|
||||
useAppLinkTrustStore.getState().trustScheme(scheme);
|
||||
}
|
||||
if (choice === 'open' || choice === 'trust') {
|
||||
return openConfirmedAppLinkUrl(url).then(() => undefined);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => {
|
||||
const request = pendingRequest;
|
||||
pendingRequest = null;
|
||||
emitChange();
|
||||
request?.resolve(choice);
|
||||
};
|
||||
|
||||
export const subscribeAppLinkConfirmation = subscribe;
|
||||
export const getAppLinkConfirmationSnapshot = getSnapshot;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { attachAppLinkInteractions } from './appLinkInteractions';
|
||||
|
||||
const TestElement = class Element {};
|
||||
const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
|
||||
Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
|
||||
|
||||
class TestAnchor extends HTMLAnchorElement {
|
||||
constructor(private readonly rawHref: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return name === 'href' ? this.rawHref : null;
|
||||
}
|
||||
|
||||
closest(): TestAnchor {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class TestContainer {
|
||||
listeners = new Map<string, EventListener>();
|
||||
|
||||
addEventListener(name: string, listener: (event: MouseEvent) => void): void {
|
||||
// SAFETY: dispatch constructs every mouse field read by the production listener.
|
||||
this.listeners.set(name, (event) => listener(event as MouseEvent));
|
||||
}
|
||||
|
||||
removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
|
||||
void listener;
|
||||
this.listeners.delete(name);
|
||||
}
|
||||
|
||||
dispatch(name: string, href: string, init: Partial<MouseEvent> = {}): Event {
|
||||
const event = new Event(name, { cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
target: { value: new TestAnchor(href) },
|
||||
button: { value: init.button ?? 0 },
|
||||
metaKey: { value: init.metaKey ?? false },
|
||||
ctrlKey: { value: init.ctrlKey ?? false },
|
||||
altKey: { value: init.altKey ?? false },
|
||||
shiftKey: { value: init.shiftKey ?? false },
|
||||
});
|
||||
this.listeners.get(name)?.(event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
const setup = (allowExternalHttp = true) => {
|
||||
const container = new TestContainer();
|
||||
const appLinks: string[] = [];
|
||||
const httpLinks: string[] = [];
|
||||
const cleanup = attachAppLinkInteractions(container, {
|
||||
allowExternalHttp,
|
||||
openAppLink: (url) => appLinks.push(url),
|
||||
openExternalHttp: (url) => httpLinks.push(url),
|
||||
});
|
||||
return { container, appLinks, httpLinks, cleanup };
|
||||
};
|
||||
|
||||
describe('app link interactions', () => {
|
||||
test('confirms plain, modifier, and middle-click activations', () => {
|
||||
const { container, appLinks } = setup();
|
||||
const href = 'obsidian://open?vault=Notes';
|
||||
|
||||
expect(container.dispatch('click', href).defaultPrevented).toBe(true);
|
||||
expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
|
||||
expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
|
||||
expect(appLinks).toEqual([href, href, href]);
|
||||
});
|
||||
|
||||
test('blocks drag activation without opening immediately', () => {
|
||||
const { container, appLinks } = setup();
|
||||
const href = 'obsidian://open?vault=Notes';
|
||||
|
||||
expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
|
||||
expect(appLinks).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
|
||||
const enabled = setup();
|
||||
const disabled = setup(false);
|
||||
const href = 'https://example.com';
|
||||
|
||||
expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
|
||||
expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
|
||||
expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
|
||||
expect(enabled.httpLinks).toEqual([href]);
|
||||
expect(disabled.httpLinks).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url';
|
||||
|
||||
type AppLinkInteractionOptions = {
|
||||
allowExternalHttp: boolean;
|
||||
openAppLink: (url: string) => void;
|
||||
openExternalHttp: (url: string) => void;
|
||||
};
|
||||
|
||||
type LinkInteractionContainer = {
|
||||
addEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
|
||||
removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
|
||||
};
|
||||
|
||||
const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return null;
|
||||
const anchor = target.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return null;
|
||||
if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null;
|
||||
return anchor;
|
||||
};
|
||||
|
||||
const interceptAppLink = (
|
||||
event: MouseEvent | DragEvent,
|
||||
openAppLink?: (url: string) => void,
|
||||
): boolean => {
|
||||
if (event.defaultPrevented) return false;
|
||||
const anchor = findLink(event);
|
||||
const href = anchor?.getAttribute('href') ?? '';
|
||||
if (!isAppLinkUrl(href)) return false;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openAppLink?.(href);
|
||||
return true;
|
||||
};
|
||||
|
||||
const isPlainPrimaryClick = (event: MouseEvent): boolean => (
|
||||
event.button === 0
|
||||
&& !event.metaKey
|
||||
&& !event.ctrlKey
|
||||
&& !event.altKey
|
||||
&& !event.shiftKey
|
||||
);
|
||||
|
||||
export const attachAppLinkInteractions = (
|
||||
container: LinkInteractionContainer,
|
||||
options: AppLinkInteractionOptions,
|
||||
): (() => void) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (interceptAppLink(event, options.openAppLink)) return;
|
||||
if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return;
|
||||
|
||||
const href = findLink(event)?.getAttribute('href') ?? '';
|
||||
if (!isExternalHttpUrl(href)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
options.openExternalHttp(href);
|
||||
};
|
||||
const handleAuxClick = (event: MouseEvent) => {
|
||||
if (event.button === 1) interceptAppLink(event, options.openAppLink);
|
||||
};
|
||||
const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => {
|
||||
interceptAppLink(event);
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
container.addEventListener('auxclick', handleAuxClick);
|
||||
container.addEventListener('dragstart', blockAlternateAppLinkActivation);
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick);
|
||||
container.removeEventListener('auxclick', handleAuxClick);
|
||||
container.removeEventListener('dragstart', blockAlternateAppLinkActivation);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,477 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useBtwStore } from '@/stores/useBtwStore';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import {
|
||||
useSessionMessageRecords,
|
||||
useSessionRenderable,
|
||||
useSessionStatus,
|
||||
useScopedBlockingPermissions,
|
||||
useScopedBlockingQuestions,
|
||||
} from '@/sync/sync-context';
|
||||
import { useStreamingStore } from '@/sync/streaming';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { destroyBtwSession, filterBtwTailMessages, promoteBtwSession, type BtwSessionRef } from '@/lib/btw';
|
||||
import type { BtwPanelState } from './useBtwPanelState';
|
||||
import { ChatSurfaceProvider } from '../ChatSurfaceContext';
|
||||
import { useMobileAutocompleteMaxHeight } from '../useMobileAutocompleteMaxHeight';
|
||||
import ChatMessage from '../ChatMessage';
|
||||
import { PermissionCard } from '../PermissionCard';
|
||||
import { QuestionCard } from '../QuestionCard';
|
||||
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
|
||||
/**
|
||||
* The `/btw` peek panel.
|
||||
*
|
||||
* Rendered from inside the composer form, so the sheet docks exactly above
|
||||
* the main composer (`absolute bottom-full` on the composer column) on both
|
||||
* desktop and mobile — the main composer IS the btw input, so nothing may
|
||||
* cover it. Identity is derived from the parent session's metadata (see
|
||||
* `useBtwPanelState`), so the panel belongs to one parent session only.
|
||||
*
|
||||
* Three exits: collapse (panel minimizes to the composer chip, the composer
|
||||
* returns to the main session), promote (the fork becomes a normal session
|
||||
* and the app navigates to it), destroy (the fork is deleted; the main
|
||||
* conversation is never touched).
|
||||
*/
|
||||
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
|
||||
parentSessionId,
|
||||
panel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (panel.btwSessionId && panel.btwDirectory) {
|
||||
return (
|
||||
<BtwSheet
|
||||
sessionRef={{
|
||||
parentSessionId,
|
||||
btwSessionId: panel.btwSessionId,
|
||||
directory: panel.btwDirectory,
|
||||
}}
|
||||
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
|
||||
boundaryMessageID={panel.boundaryMessageID}
|
||||
collapsed={panel.collapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (panel.creating) {
|
||||
return (
|
||||
<BtwFrame title={t('chat.btw.titleFallback')}>
|
||||
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
<span>{t('chat.btw.loading')}</span>
|
||||
</div>
|
||||
</BtwFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const useBtwDestroy = (sessionRef: BtwSessionRef | null): (() => void) => {
|
||||
const { t } = useI18n();
|
||||
return React.useCallback(() => {
|
||||
if (!sessionRef) return;
|
||||
void destroyBtwSession(sessionRef).then((ok) => {
|
||||
if (!ok) toast.error(t('chat.btw.toast.destroyFailed'));
|
||||
});
|
||||
}, [sessionRef, t]);
|
||||
};
|
||||
|
||||
type BtwSessionData = {
|
||||
messageRecords: Array<{ info: Message; parts: Part[] }>;
|
||||
sessionIsWorking: boolean;
|
||||
streamingMessageId: string | null;
|
||||
activeStreamingPhase: 'streaming' | 'cooldown' | 'completed' | null;
|
||||
sessionPermissions: ReturnType<typeof useScopedBlockingPermissions>;
|
||||
sessionQuestions: ReturnType<typeof useScopedBlockingQuestions>;
|
||||
isEmpty: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Live session data for the fork, all keyed by the fork's own ids. Only the
|
||||
* fork's tail (messages after the inherited-history boundary) is shown.
|
||||
*/
|
||||
const useBtwSessionData = (
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
boundaryMessageID: string | null,
|
||||
): BtwSessionData => {
|
||||
const sync = useSync();
|
||||
const renderable = useSessionRenderable(sessionId, directory);
|
||||
React.useEffect(() => {
|
||||
if (!renderable) {
|
||||
void sync.ensureSessionRenderable(sessionId, false, directory);
|
||||
}
|
||||
}, [directory, renderable, sessionId, sync]);
|
||||
|
||||
const messageRecords = useSessionMessageRecords(sessionId, directory);
|
||||
const status = useSessionStatus(sessionId, directory) ?? IDLE_SESSION_STATUS;
|
||||
const streamingMessageId = useStreamingStore(
|
||||
React.useCallback((s) => s.streamingMessageIds.get(sessionId) ?? null, [sessionId]),
|
||||
);
|
||||
const activeStreamingPhase = useStreamingStore(
|
||||
React.useCallback(
|
||||
(s) => (streamingMessageId ? s.messageStreamStates.get(streamingMessageId)?.phase ?? null : null),
|
||||
[streamingMessageId],
|
||||
),
|
||||
);
|
||||
const sessionPermissions = useScopedBlockingPermissions(sessionId, directory);
|
||||
const sessionQuestions = useScopedBlockingQuestions(sessionId, directory);
|
||||
|
||||
const tailRecords = React.useMemo(
|
||||
() => filterBtwTailMessages(messageRecords, boundaryMessageID),
|
||||
[boundaryMessageID, messageRecords],
|
||||
);
|
||||
|
||||
const sessionIsWorking = React.useMemo(() => {
|
||||
if (sessionPermissions.length > 0 || sessionQuestions.length > 0) {
|
||||
return false;
|
||||
}
|
||||
const statusType = status.type ?? 'idle';
|
||||
if (statusType === 'busy' || statusType === 'retry') {
|
||||
return true;
|
||||
}
|
||||
// SAFETY: reads only the optional `time.completed` field, which the
|
||||
// SDK Message union does not expose uniformly; a missing value means
|
||||
// the assistant turn has not completed.
|
||||
const lastMessage = tailRecords[tailRecords.length - 1]?.info as (Message & { time?: { completed?: number } }) | undefined;
|
||||
return Boolean(
|
||||
lastMessage
|
||||
&& lastMessage.role === 'assistant'
|
||||
&& typeof lastMessage.time?.completed !== 'number',
|
||||
);
|
||||
}, [sessionPermissions.length, sessionQuestions.length, status.type, tailRecords]);
|
||||
|
||||
return {
|
||||
messageRecords: tailRecords,
|
||||
sessionIsWorking,
|
||||
streamingMessageId,
|
||||
activeStreamingPhase,
|
||||
sessionPermissions,
|
||||
sessionQuestions,
|
||||
isEmpty: tailRecords.length === 0,
|
||||
};
|
||||
};
|
||||
|
||||
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
|
||||
const useEscapeToCollapse = (onCollapse: () => void): void => {
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
// SAFETY: keydown targets are DOM elements (or null on window).
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
onCollapse();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onCollapse]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stick-to-bottom auto-scroll. Streaming grows content inside one message
|
||||
* without changing the record count, so following the tail needs a
|
||||
* ResizeObserver on the content wrapper — data-driven effects alone would
|
||||
* stop following mid-stream.
|
||||
*/
|
||||
const useAutoScroll = (
|
||||
bodyRef: React.RefObject<HTMLDivElement | null>,
|
||||
contentRef: React.RefObject<HTMLDivElement | null>,
|
||||
contentReady: boolean,
|
||||
): ((event: React.UIEvent<HTMLDivElement>) => void) => {
|
||||
const stickToBottomRef = React.useRef(true);
|
||||
// `contentReady` is a dependency because the refs are only attached once
|
||||
// the empty state gives way to the message list; an effect keyed on the
|
||||
// refs alone would run against `null` and never re-attach the observer.
|
||||
React.useEffect(() => {
|
||||
if (!contentReady) return;
|
||||
const content = contentRef.current;
|
||||
const element = bodyRef.current;
|
||||
if (element && stickToBottomRef.current) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
if (!content || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const body = bodyRef.current;
|
||||
if (body && stickToBottomRef.current) {
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
});
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [bodyRef, contentReady, contentRef]);
|
||||
return React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const element = event.currentTarget;
|
||||
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const BtwFrame: React.FC<{
|
||||
title: string;
|
||||
actions?: React.ReactNode;
|
||||
onTitleClick?: () => void;
|
||||
titleClickLabel?: string;
|
||||
collapsed?: boolean;
|
||||
headerSpinner?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
|
||||
<div
|
||||
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
|
||||
role="dialog"
|
||||
aria-label="btw"
|
||||
>
|
||||
<div className="oc-glass-popover w-full overflow-hidden rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTitleClick}
|
||||
aria-label={titleClickLabel}
|
||||
title={titleClickLabel}
|
||||
className="flex min-w-0 items-center gap-2 text-left text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{headerSpinner ? (
|
||||
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="typography-ui-label min-w-0 truncate font-semibold">
|
||||
{title}
|
||||
</span>
|
||||
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
|
||||
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1" />
|
||||
{actions}
|
||||
</div>
|
||||
{children ? (
|
||||
<>
|
||||
{children}
|
||||
<div className="h-2" />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BtwSheet: React.FC<{
|
||||
sessionRef: BtwSessionRef;
|
||||
title: string;
|
||||
boundaryMessageID: string | null;
|
||||
collapsed: boolean;
|
||||
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
|
||||
const { t } = useI18n();
|
||||
const handleDestroy = useBtwDestroy(sessionRef);
|
||||
const setCollapsed = React.useCallback((next: boolean) => {
|
||||
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
|
||||
}, [sessionRef.parentSessionId]);
|
||||
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
|
||||
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
|
||||
const handlePromote = React.useCallback(() => {
|
||||
void promoteBtwSession(sessionRef).catch(() => {
|
||||
toast.error(t('chat.btw.toast.promoteFailed'));
|
||||
});
|
||||
}, [sessionRef, t]);
|
||||
useEscapeToCollapse(handleCollapse);
|
||||
|
||||
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
|
||||
const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent';
|
||||
const actions = (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={headerButtonClass}
|
||||
onClick={handlePromote}
|
||||
aria-label={t('chat.btw.promoteAria')}
|
||||
title={t('chat.btw.promoteAria')}
|
||||
>
|
||||
<Icon name="external-link" className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={headerButtonClass}
|
||||
onClick={handleDestroy}
|
||||
aria-label={t('chat.btw.destroyAria')}
|
||||
title={t('chat.btw.destroyAria')}
|
||||
>
|
||||
<Icon name="close" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<BtwCollapsedStrip
|
||||
sessionRef={sessionRef}
|
||||
title={title}
|
||||
actions={actions}
|
||||
onExpand={handleToggleCollapsed}
|
||||
expandLabel={toggleLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BtwExpandedSheet
|
||||
sessionRef={sessionRef}
|
||||
title={title}
|
||||
boundaryMessageID={boundaryMessageID}
|
||||
actions={actions}
|
||||
onTitleClick={handleToggleCollapsed}
|
||||
titleClickLabel={toggleLabel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapsed mode: only the header strip stays docked above the composer. The
|
||||
* fork keeps running in the background; a spinner replaces the header icon
|
||||
* while it is busy so activity stays visible without the message list.
|
||||
*/
|
||||
const BtwCollapsedStrip: React.FC<{
|
||||
sessionRef: BtwSessionRef;
|
||||
title: string;
|
||||
actions: React.ReactNode;
|
||||
onExpand: () => void;
|
||||
expandLabel: string;
|
||||
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
|
||||
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
|
||||
const isBusy = status.type === 'busy' || status.type === 'retry';
|
||||
return (
|
||||
<BtwFrame
|
||||
title={title}
|
||||
actions={actions}
|
||||
onTitleClick={onExpand}
|
||||
titleClickLabel={expandLabel}
|
||||
collapsed
|
||||
headerSpinner={isBusy}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BtwExpandedSheet: React.FC<{
|
||||
sessionRef: BtwSessionRef;
|
||||
title: string;
|
||||
boundaryMessageID: string | null;
|
||||
actions: React.ReactNode;
|
||||
onTitleClick: () => void;
|
||||
titleClickLabel: string;
|
||||
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
|
||||
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
|
||||
const bodyRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const handleBodyScroll = useAutoScroll(bodyRef, contentRef, !data.isEmpty);
|
||||
// With the on-screen keyboard open the composer (this panel's anchor)
|
||||
// rises, and a vh-based cap would push the panel under the app header.
|
||||
// Same protection as the composer autocomplete popups: clamp the scroll
|
||||
// body to the space actually available above the anchor. The hook measures
|
||||
// room for the scroll body itself, but the panel header and bottom spacer
|
||||
// sit inside the same frame above/below it — reserve their height too.
|
||||
const BTW_FRAME_CHROME_PX = 48;
|
||||
const availableMaxHeight = useMobileAutocompleteMaxHeight(bodyRef, true, 520 + BTW_FRAME_CHROME_PX);
|
||||
const mobileMaxHeight = availableMaxHeight !== undefined
|
||||
? Math.max(120, availableMaxHeight - BTW_FRAME_CHROME_PX)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
|
||||
<ChatSurfaceProvider mode="peek">
|
||||
<BtwMessages
|
||||
data={data}
|
||||
bodyRef={bodyRef}
|
||||
contentRef={contentRef}
|
||||
onBodyScroll={handleBodyScroll}
|
||||
maxHeight={mobileMaxHeight}
|
||||
/>
|
||||
</ChatSurfaceProvider>
|
||||
</BtwFrame>
|
||||
);
|
||||
};
|
||||
|
||||
const BtwMessages: React.FC<{
|
||||
data: BtwSessionData;
|
||||
bodyRef: React.RefObject<HTMLDivElement | null>;
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
onBodyScroll: (event: React.UIEvent<HTMLDivElement>) => void;
|
||||
maxHeight?: number;
|
||||
}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (data.isEmpty) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
<span>{t('chat.btw.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollShadow
|
||||
ref={bodyRef}
|
||||
onScroll={onBodyScroll}
|
||||
size={32}
|
||||
data-scroll-shadow="true"
|
||||
className="max-h-[min(55vh,520px)] min-h-0 overflow-y-auto px-3 py-1"
|
||||
style={maxHeight !== undefined ? { maxHeight } : undefined}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{data.messageRecords.map((record, index) => (
|
||||
<ChatMessage
|
||||
key={record.info.id}
|
||||
message={record}
|
||||
previousMessage={data.messageRecords[index - 1]}
|
||||
nextMessage={data.messageRecords[index + 1]}
|
||||
isInActiveTurn={index === data.messageRecords.length - 1}
|
||||
activeStreamingPhase={
|
||||
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? (
|
||||
<div>
|
||||
{data.sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{data.sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Always reserve this row so the content does not shift down
|
||||
by a line when the indicator disappears. */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-1 py-2 text-xs text-muted-foreground',
|
||||
!data.sessionIsWorking && 'invisible',
|
||||
)}
|
||||
aria-hidden={!data.sessionIsWorking}
|
||||
>
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
<span>{t('chat.btw.working')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetadata';
|
||||
import { useBtwStore } from '@/stores/useBtwStore';
|
||||
|
||||
export type BtwPanelState = {
|
||||
/** The active fork for this parent, or null when no panel should exist. */
|
||||
btwSessionId: string | null;
|
||||
btwSession: Session | null;
|
||||
/** The fork's directory identity (may be canonicalized by the server). */
|
||||
btwDirectory: string | null;
|
||||
/** Last message id inherited from the parent; the panel shows what's after it. */
|
||||
boundaryMessageID: string | null;
|
||||
collapsed: boolean;
|
||||
creating: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the `/btw` panel identity for one parent session from authoritative
|
||||
* session metadata (`openchamber.btwSessionID`), plus the transient UI state
|
||||
* kept in `useBtwStore`. The panel exists only while the parent's link AND the
|
||||
* fork itself are present in the live stores, so a fork deleted anywhere
|
||||
* (sidebar, another client) makes the panel disappear without extra tracking.
|
||||
*/
|
||||
export function useBtwPanelState(
|
||||
parentSessionId: string | null | undefined,
|
||||
directory: string | undefined,
|
||||
): BtwPanelState {
|
||||
const parentSession = useSession(parentSessionId, directory);
|
||||
const linkedBtwSessionId = getBtwSessionID(parentSession);
|
||||
const btwSession = useSession(linkedBtwSessionId, directory) ?? null;
|
||||
const uiState = useBtwStore(
|
||||
React.useCallback(
|
||||
(s) => (parentSessionId ? s.byParent[parentSessionId] : undefined),
|
||||
[parentSessionId],
|
||||
),
|
||||
);
|
||||
|
||||
const destroying = Boolean(uiState?.destroying);
|
||||
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
|
||||
return {
|
||||
btwSessionId,
|
||||
btwSession: btwSessionId ? btwSession : null,
|
||||
// SAFETY: the SDK Session type omits the server's `directory` field; this
|
||||
// widening only reads it, with the parent's directory as the fallback.
|
||||
btwDirectory: btwSessionId
|
||||
? ((btwSession as (Session & { directory?: string | null }) | null)?.directory ?? directory ?? null)
|
||||
: null,
|
||||
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
|
||||
collapsed: Boolean(uiState?.collapsed),
|
||||
creating: Boolean(uiState?.creating),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user