Refactor application architecture and shared functionality
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
---
|
||||
description: Create an anti-slop lint cleanup PR from the next generated batch
|
||||
agent: build
|
||||
---
|
||||
|
||||
You are working in the OpenChamber repository.
|
||||
|
||||
Goal: reduce anti-slop Oxlint findings in a small, reviewable maintenance PR.
|
||||
|
||||
This task can run unattended on a schedule, so it must be safe to start at any moment and must stop cleanly when there is nothing to do.
|
||||
|
||||
First, verify the worktree is safe to use:
|
||||
|
||||
`git status --porcelain`
|
||||
|
||||
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:
|
||||
|
||||
`bun run deslop -- next-batch --min-issues 60 --max-issues 120`
|
||||
|
||||
Use the command output as the source of truth for this task scope.
|
||||
|
||||
If the output contains `NO BATCH AVAILABLE`, stop immediately and report the printed reason. Do not create a branch, do not create a pull request, and do not look for other work. Concurrency is already handled: the command excludes files claimed by other active batches and refuses to exceed the active-batch limit.
|
||||
|
||||
Background: anti-slop is a vendored Oxlint plugin at `tools/oxlint/anti-slop/`, configured in `oxlint.config.ts`. It rejects low-evidence typing: unjustified type assertions, `unknown`/`object`/`Record<string, unknown>` contracts, ad hoc `typeof` narrowing, conditional `{}` spreads, and module mocking. Fixing a finding means giving the code real type evidence, never hiding the symptom.
|
||||
|
||||
Workflow:
|
||||
- Before generating the batch, switch to `main` and pull the latest remote changes.
|
||||
- Read the `next-batch` output carefully.
|
||||
- Use the exact `Run ID`, `Batch name`, `Branch name`, and `PR title` printed by the command.
|
||||
- Create the branch using the printed `Branch name`.
|
||||
- Work only on the selected files listed in the batch output.
|
||||
- Treat the selected files as complete-file scope. Do not cherry-pick only the first N findings.
|
||||
- Read each selected file fully before editing it. These findings sit on type contracts, so a local edit can change behavior at a distant call site.
|
||||
- Fix as many findings as practical in the selected files. Your default should be to fix selected findings, not to skip them.
|
||||
|
||||
## What a good fix looks like
|
||||
|
||||
Every finding is the same underlying complaint: the code claims less about a value than it actually knows. A good fix restores the missing knowledge. A bad fix hides the complaint while the knowledge stays missing. The rule cannot tell the difference, so you must.
|
||||
|
||||
Before editing, answer one question for the value in question: where does it actually come from? There are only three answers, and each has one correct fix.
|
||||
|
||||
1. It comes from code in this repository. The real type already exists somewhere upstream. Find it and use it. No parsing, no assertion.
|
||||
2. It crosses an I/O boundary: HTTP response, `postMessage`, file contents, `localStorage`, a child process, the OpenCode SDK edge. Parse it once at that boundary, then let the parsed type flow onward untouched.
|
||||
|
||||
On parsing style, follow local precedent and do not introduce a new one. `zod` is declared as a dependency but is not currently used in the source, so a maintenance PR is the wrong place to start spreading it. Unless the file or package you are editing already parses with a schema library, write a small local parse function that takes the raw input, returns the domain type or `undefined`, and lives next to the boundary it guards. If you believe a schema library is genuinely warranted, skip the finding and say so in the PR body instead of introducing the pattern yourself.
|
||||
3. It is genuinely dynamic, such as a plugin registry keyed by arbitrary strings. Then keep the open key but make the value type precise, and say so in the contract's name.
|
||||
|
||||
### `no-unsafe-dictionary-type`
|
||||
|
||||
Bad, and the most common lazy fix. The shape is known; the annotation throws it away.
|
||||
|
||||
```ts
|
||||
type QuotaSnapshot = Record<string, unknown>;
|
||||
|
||||
function readLimit(snapshot: QuotaSnapshot) {
|
||||
return snapshot.limit;
|
||||
}
|
||||
```
|
||||
|
||||
Good. Name the contract and state the fields the code actually reads.
|
||||
|
||||
```ts
|
||||
type QuotaSnapshot = {
|
||||
limit: number;
|
||||
used: number;
|
||||
resetsAt: string;
|
||||
};
|
||||
|
||||
function readLimit(snapshot: QuotaSnapshot) {
|
||||
return snapshot.limit;
|
||||
}
|
||||
```
|
||||
|
||||
Also good, when keys really are open but values are not.
|
||||
|
||||
```ts
|
||||
type ProviderQuotas = Record<string, QuotaSnapshot>;
|
||||
```
|
||||
|
||||
Still bad, and does not count as a fix:
|
||||
|
||||
```ts
|
||||
type QuotaSnapshot = Record<string, any>;
|
||||
type QuotaSnapshot = { [key: string]: object };
|
||||
type QuotaSnapshot = Record<string, string | number | boolean | null>;
|
||||
```
|
||||
|
||||
The third one is the sneaky one. Widening to a union of primitives satisfies the rule without describing anything. If you cannot name the fields, that is a signal the value is unparsed I/O; go to the boundary and parse it.
|
||||
|
||||
### `no-unknown-parameters`, `no-unknown-returns`, `no-unknown-type-aliases`
|
||||
|
||||
Bad. The function accepts anything and immediately guesses.
|
||||
|
||||
```ts
|
||||
function applyThemeMessage(message: unknown) {
|
||||
const theme = message as { themeId: string };
|
||||
setTheme(theme.themeId);
|
||||
}
|
||||
```
|
||||
|
||||
Good. Parse at the boundary; the domain function receives a real type.
|
||||
|
||||
```ts
|
||||
type ThemeMessage = { themeId: string };
|
||||
|
||||
function parseThemeMessage(data: MessageEvent["data"]): ThemeMessage | undefined {
|
||||
if (data === null || typeof data !== "object") return undefined;
|
||||
const themeId = Reflect.get(data, "themeId");
|
||||
return typeof themeId === "string" ? { themeId } : undefined;
|
||||
}
|
||||
|
||||
function applyThemeMessage(message: ThemeMessage) {
|
||||
setTheme(message.themeId);
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
const message = parseThemeMessage(event.data);
|
||||
if (message === undefined) return;
|
||||
applyThemeMessage(message);
|
||||
});
|
||||
```
|
||||
|
||||
The parse function itself will still report `no-runtime-typeof` and `no-reflect-get`, because it is doing exactly what those rules describe. That is expected and acceptable: the checks are now concentrated in one named boundary function instead of scattered through domain logic, and the domain function above is genuinely typed. Report these remaining findings in the PR body rather than hiding them. Do not silence them with inline suppressions.
|
||||
|
||||
Note what changed at runtime: a malformed message is now ignored instead of silently producing `undefined` deeper in the call stack. That is a deliberate behavior decision and it belongs in the PR body. Never introduce a throw on a path that previously degraded quietly.
|
||||
|
||||
The `cause` convention is the single allowed exception: `unknown` is correct for an error cause.
|
||||
|
||||
### `no-known-value-widening`
|
||||
|
||||
Bad. The annotation erases the known keys, so callers lose autocomplete and typo safety.
|
||||
|
||||
```ts
|
||||
const settingsBySlug: Record<string, SettingsSection> = {
|
||||
appearance: appearanceSection,
|
||||
keybindings: keybindingsSection,
|
||||
};
|
||||
```
|
||||
|
||||
Good. Keep inference and validate the shape.
|
||||
|
||||
```ts
|
||||
const settingsBySlug = {
|
||||
appearance: appearanceSection,
|
||||
keybindings: keybindingsSection,
|
||||
} satisfies Record<string, SettingsSection>;
|
||||
```
|
||||
|
||||
`satisfies` checks every value against the contract while preserving the literal keys. Reach for it before anything else here.
|
||||
|
||||
### `no-chained-type-assertions` and `no-widen-then-assert`
|
||||
|
||||
Bad. The precise type existed and was thrown away, then guessed back.
|
||||
|
||||
```ts
|
||||
const raw = loadSession() as unknown as SessionSnapshot;
|
||||
```
|
||||
|
||||
Good. Fix the upstream contract so the round trip is unnecessary.
|
||||
|
||||
```ts
|
||||
const snapshot = loadSession();
|
||||
```
|
||||
|
||||
If `loadSession` genuinely returns something imprecise, that function is the real defect. Fix it there when it is inside the batch scope; if it is outside, make the minimal supporting change and say so in the PR body.
|
||||
|
||||
### `require-safety-comment-for-type-assertion`
|
||||
|
||||
The first move is always to delete the assertion, not to document it. Only a small minority of these findings deserve a comment.
|
||||
|
||||
Bad, and an automatic rejection at review:
|
||||
|
||||
```ts
|
||||
// SAFETY: this is safe.
|
||||
const session = value as Session;
|
||||
|
||||
// SAFETY: value is a Session.
|
||||
const session = value as Session;
|
||||
|
||||
// SAFETY: required by TypeScript.
|
||||
const session = value as Session;
|
||||
```
|
||||
|
||||
These say nothing. A valid comment names the check that already ran and the line or function that ran it, so a reviewer can verify the claim without trusting you.
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const parsed = sessionSchema.safeParse(payload);
|
||||
if (!parsed.success) return undefined;
|
||||
// SAFETY: sessionSchema.safeParse above confirmed every field of Session.
|
||||
const session = parsed.data as Session;
|
||||
```
|
||||
|
||||
If you cannot write such a sentence truthfully, you do not have an assertion problem, you have a missing check. Add the check.
|
||||
|
||||
### `no-conditional-empty-object-spread`
|
||||
|
||||
This one changes behavior more often than it looks, so read the consumer before editing.
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
const body = {
|
||||
sessionId,
|
||||
...(title !== undefined ? { title } : {}),
|
||||
};
|
||||
```
|
||||
|
||||
Good, when the consumer distinguishes a missing key from an explicit `undefined`, which is true for anything serialized to JSON or merged over defaults:
|
||||
|
||||
```ts
|
||||
const body: CreateSessionBody = { sessionId };
|
||||
if (title !== undefined) body.title = title;
|
||||
```
|
||||
|
||||
Good, when the consumer treats both the same:
|
||||
|
||||
```ts
|
||||
const body = { sessionId, title };
|
||||
```
|
||||
|
||||
Choosing wrongly here sends `"title": null` or drops a field on a real API call. If you cannot determine which behavior the consumer needs by reading it, skip the finding and say why.
|
||||
|
||||
### `no-runtime-typeof`
|
||||
|
||||
Bad. An ad hoc check in the middle of domain logic.
|
||||
|
||||
```ts
|
||||
function resolveHost(stored: unknown) {
|
||||
if (typeof stored === "string") return stored;
|
||||
return DEFAULT_HOST;
|
||||
}
|
||||
```
|
||||
|
||||
Good. Read and validate where the value enters the program, then branch on real domain values.
|
||||
|
||||
```ts
|
||||
function readStoredHost(): string {
|
||||
const stored = localStorage.getItem(STORED_HOST_KEY);
|
||||
return stored !== null && stored.length > 0 ? stored : DEFAULT_HOST;
|
||||
}
|
||||
```
|
||||
|
||||
Here the fix removed the check entirely, because `localStorage.getItem` already has a precise contract: `string | null`. The original `unknown` was self-inflicted. Look for this case first; it is more common than it seems.
|
||||
|
||||
When a real check is unavoidable, keep it inside one named boundary function as shown above, and accept that the boundary function keeps its finding. What is not acceptable is spreading the same check across domain code, or renaming it into a type predicate so it reads as intentional while nothing was actually established.
|
||||
|
||||
### `no-module-mocking`
|
||||
|
||||
Bad. The test mocks a module and therefore tests the mock.
|
||||
|
||||
```ts
|
||||
mock.module("../lib/runtimeFetch", () => ({ runtimeFetch: async () => ({ ok: true }) }));
|
||||
```
|
||||
|
||||
Good. Pass the dependency in, and let the test supply a real function.
|
||||
|
||||
```ts
|
||||
async function loadStatus(fetchStatus: () => Promise<StatusResponse>) {
|
||||
return fetchStatus();
|
||||
}
|
||||
|
||||
test("returns the fetched status", async () => {
|
||||
const status = await loadStatus(async () => ({ ok: true }));
|
||||
expect(status.ok).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
If introducing the seam would restructure production code well beyond the batch, skip the finding and say so. Do not fake a seam you do not believe in.
|
||||
|
||||
## How to know your fix is real
|
||||
|
||||
Before moving to the next finding, check all four:
|
||||
|
||||
- The code now knows something it did not know before. If you only rearranged syntax, it is not a fix.
|
||||
- No new `any`, no new assertion, no new broad union invented to satisfy the checker.
|
||||
- If you added parsing, you decided explicitly what happens on invalid input, and that decision is written in the PR body.
|
||||
- If you changed a type used elsewhere, you searched for its call sites and updated them, rather than casting at the call site.
|
||||
|
||||
Handle findings deliberately instead of skipping them: for parsing work, add the smallest schema that covers the fields actually used; for contract changes, follow call sites with search and update them; for tests, prefer real seams over widened fixtures.
|
||||
|
||||
## Finish the file
|
||||
|
||||
A selected file is finished when it has zero anti-slop findings for the enabled rules, or when every remaining finding has an individual, specific reason to stay.
|
||||
|
||||
This matters beyond tidiness. A file left half-fixed will be selected again by a later batch, producing a second pull request over the same file, with its own template, its own review, and its own merge. Every finding you defer costs the repository owner a future review cycle. Treat "I fixed the easy half" as an incomplete task, not a delivery.
|
||||
|
||||
So, before you consider a selected file done:
|
||||
|
||||
- Re-run `bun run deslop -- file <path>` and read what is left.
|
||||
- If findings remain, they must be the genuinely hard ones, and you must be able to explain each one specifically. "Requires a broader refactor" is only acceptable when you name the refactor, the module boundary it crosses, and why doing it here would make the change unreviewable.
|
||||
- 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 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.
|
||||
- Do not add `any`, widen a type, or add an assertion in order to satisfy a rule.
|
||||
- Do not write a generic or placeholder `// SAFETY:` comment. A comment that does not name a real, already-performed check is worse than the original finding.
|
||||
- Do not invent a union of primitives to escape a dictionary rule.
|
||||
- Do not move a rejected `typeof` check into a hand-written type predicate to get it out of the linter's way.
|
||||
- Do not delete code, tests, or fields to make a finding disappear.
|
||||
- Do not rename a symbol solely to dodge `no-shape-in-symbol-names`; rename it to what it actually is.
|
||||
- Do not introduce a throw where the previous code degraded quietly. A parse failure on a path that used to fall back must keep falling back.
|
||||
- Do not introduce a schema library, a new utility module, or a new architectural pattern as part of a lint cleanup.
|
||||
- Do not edit `oxlint.config.ts` or `tools/oxlint/anti-slop/`.
|
||||
- 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>`
|
||||
|
||||
Then validate the packages you actually touched, not the whole workspace. For each affected package run its own checks, for example:
|
||||
|
||||
`bun run --cwd packages/ui type-check`
|
||||
|
||||
`bun run --cwd packages/ui lint`
|
||||
|
||||
`bun run --cwd packages/ui test`
|
||||
|
||||
Workspace-wide `bun run type-check` and `bun run lint` are CI's job. Run them locally only when a change crosses package boundaries or touches shared contracts.
|
||||
|
||||
For files that TypeScript does not cover, such as server or CLI JavaScript, run the focused tests for that surface instead, for example `bun run --cwd packages/web test`.
|
||||
|
||||
Validation and delivery:
|
||||
- Confirm selected files have fewer findings than before.
|
||||
- Confirm `Findings outside selected files delta` is not positive. If it is, you introduced new findings elsewhere; fix them before continuing.
|
||||
- If validation fails, fix failures only if the fixes stay within the task scope. Otherwise stop and report the blocker.
|
||||
- Commit the changes with a concise message.
|
||||
- Push the branch.
|
||||
- Create exactly one PR with `gh pr create` using the exact printed `PR title`.
|
||||
- After the PR is created, switch back to `main` and pull the latest remote changes again.
|
||||
|
||||
PR requirements. The repository has a mandatory pull request template at `.github/PULL_REQUEST_TEMPLATE.md`, and `AGENTS.md` requires it to be completed with concrete evidence for the final PR HEAD. Read the template and `CONTRIBUTING.md` before writing the description. Use every template heading, in the template's order, and do not invent replacement headings. Fill each section as follows.
|
||||
|
||||
- Use the exact printed `PR title`.
|
||||
- `## Intent`: state that this is an unattended maintenance batch, name the `Run ID`, `Batch name`, and `Branch name`, and say what behavior changes. When nothing observable changes, say so explicitly rather than leaving it implied.
|
||||
- `## Non-goals`: the findings left unfixed in the selected files, findings elsewhere in the repository, and any refactor you deliberately did not start. Give the reason for each, not just the count.
|
||||
- `## Affected surfaces`: the packages, runtimes, user-visible states, and persisted or external contracts the diff reaches. Name every runtime the changed code runs in, and explain why an apparently applicable runtime is unaffected.
|
||||
- `## Repository guidance`: fill the table. List the `AGENTS.md` rules you followed, every project skill that matched the change, required skill references you read, and the nearest `README.md` or `DOCUMENTATION.md` for the touched modules. For each row explain why it applies and how the change complies. Do not list filenames without explanation.
|
||||
- `## Validation`: fill the table with the exact commands you ran and their results, including `check-batch` and every package-scoped type-check, lint, and test command, naming the packages. Record failures honestly, including pre-existing failures unrelated to this PR, and say which checks you did not run. Do not claim runtime behavior from type-check or lint alone.
|
||||
- `## Visual evidence`: these PRs usually have no visible change, so explain concretely why the diff cannot affect rendered behavior. If anything user-visible did change, attach before/after evidence for the affected states.
|
||||
- `## Risks and failure behavior`: cover what breaks if a change is wrong, how to roll it back, and any compatibility, data, performance, or cross-runtime concern. This is where every behavior-affecting decision belongs: each parsing decision you introduced and what now happens on invalid input, each `// SAFETY:` comment you added with the invariant it documents, and any change to whether an object key is present. State "None identified" only with a concrete reason.
|
||||
|
||||
Add a `## Manual testing recommendations` section after the template sections, with focused checks for the changed behavior, based on the selected files and actual edits. Type-contract changes can alter runtime behavior at call sites, so name the affected surfaces concretely.
|
||||
|
||||
Also state, inside `## Intent`, the selected files and how many findings `check-batch` reports as fixed and remaining.
|
||||
|
||||
Constraints:
|
||||
- Keep the PR small and reviewable.
|
||||
- Do not auto-merge.
|
||||
- Do not modify unrelated files except minimal supporting changes required by selected-file fixes.
|
||||
- Do not run broad formatting.
|
||||
- Leave the batch's run directory intact after creating the PR. `next-batch` prints its location. That directory is both the handoff for the review follow-up task and the claim that stops another batch, including the React Doctor pipeline, from touching the same files. Deleting it early lets a parallel batch collide with this PR. Never delete it by hand; use `bun run deslop -- release --run <run-id>`.
|
||||
- If you stop before creating a PR for any reason, release the claim with `bun run deslop -- release --run <run-id>` so the files return to the pool.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
description: Follow up on an anti-slop PR by addressing review feedback
|
||||
agent: build
|
||||
---
|
||||
|
||||
You are working in the OpenChamber repository.
|
||||
|
||||
Goal: follow up on an existing anti-slop maintenance PR, address Greptile/review bot feedback, and clean up the local batch handoff files when done.
|
||||
|
||||
This task can run unattended on a schedule, so it must be safe to start at any moment and must stop cleanly when there is nothing to do.
|
||||
|
||||
First, verify the worktree is safe to use:
|
||||
|
||||
`git status --porcelain`
|
||||
|
||||
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:
|
||||
|
||||
`bun run deslop -- active`
|
||||
|
||||
The listing may include batches owned by the React Doctor pipeline; those are shown as `[pipeline rd]`. Never touch them.
|
||||
|
||||
Workflow:
|
||||
- If there are no active batches, stop and report that there is nothing to follow up.
|
||||
- Each active batch corresponds to one open PR. Read its `batch.json` for `runId`, `branchName`, `batchName`, `prTitle`, and selected files.
|
||||
- Use `gh` to find the open PR for each batch branch.
|
||||
- Work on the oldest batch that has an open PR with unaddressed feedback. If several qualify, handle exactly one and leave the rest.
|
||||
- If a batch's PR was already merged or closed, do not treat it as follow-up work. Release its claim with `bun run deslop -- release --run <run-id>` so its files return to the pool, then continue looking.
|
||||
- If no batch has an open PR with actionable feedback, stop and report that.
|
||||
- Switch to the batch branch using the exact `branchName`.
|
||||
- Pull or update the branch from remote if needed.
|
||||
- Use `gh` to inspect PR review comments, PR issue comments, review threads if available, and check run summaries if relevant.
|
||||
- Focus specifically on Greptile/review bot feedback and actionable reviewer comments.
|
||||
- Pay particular attention to comments questioning whether a type contract is now wrong, whether a `// SAFETY:` comment is accurate, or whether a call site was missed. These are the likely real defects in this kind of PR.
|
||||
- Address actionable comments with minimal follow-up fixes.
|
||||
- Keep changes within the original selected files whenever possible.
|
||||
- If a review comment requires changes outside the selected files, make only the minimal required supporting change.
|
||||
- Do not perform unrelated cleanup.
|
||||
- Do not rewrite the original PR.
|
||||
- Do not force-push.
|
||||
- Do not disable, downgrade, or ignore anti-slop rules, and do not add `any`, widen a type, or add an assertion to satisfy a reviewer comment.
|
||||
- Follow the same fix standards as the original batch task, described in `.opencode/commands/as-fixes.md` under "What a good fix looks like" and "Hard prohibitions". Read that section before editing. Review pressure is exactly when a laundered fix is most tempting.
|
||||
|
||||
After fixes, run:
|
||||
|
||||
`bun run deslop -- check-batch --run <run-id>`
|
||||
|
||||
Then re-run the package-scoped checks for the packages you touched, for example `bun run --cwd packages/ui type-check`, `bun run --cwd packages/ui lint`, and `bun run --cwd packages/ui test`. Workspace-wide checks are CI's job.
|
||||
|
||||
Delivery:
|
||||
- Commit follow-up fixes with a concise message.
|
||||
- Push the branch.
|
||||
- Reply to addressed review comments using `gh`.
|
||||
- For each specific review comment you addressed, reply with what was changed and the follow-up commit hash.
|
||||
- If the feedback was a general PR comment, add one general PR comment summarizing what was addressed, commit hashes, and validation results.
|
||||
- Update the PR description so it stays true for the final HEAD: refresh `## Validation` with the checks you re-ran, and move any new behavior change into `## Risks and failure behavior`. Keep every heading of `.github/PULL_REQUEST_TEMPLATE.md` intact, and preserve content the repository owner added by hand, including screenshots. Read the live description before editing and merge into it rather than overwriting.
|
||||
- If a comment is intentionally not addressed, reply with a concise reason.
|
||||
- Do not release the batch while its PR is still open and awaiting review. The claim is what keeps parallel batches off these files.
|
||||
- Release the batch only once its PR has been merged or closed: `bun run deslop -- release --run <run-id>`.
|
||||
- After the follow-up is complete, switch back to `main` and pull the latest remote changes.
|
||||
|
||||
Constraints:
|
||||
- Work on exactly one anti-slop batch PR.
|
||||
- Prefer the oldest batch with an open PR.
|
||||
- Do not auto-merge.
|
||||
- Do not close the PR.
|
||||
- Do not edit `CHANGELOG.md`, package versions, or release metadata.
|
||||
- Do not release or delete handoff directories for batches you did not handle.
|
||||
- If validation fails and cannot be fixed safely within scope, leave the batch claimed and report the blocker.
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
description: Deeply review every open maintenance PR and fix it to completion, not by commenting
|
||||
agent: build
|
||||
---
|
||||
|
||||
You are working in the OpenChamber repository.
|
||||
|
||||
Goal: take every open automated maintenance pull request and bring it to a state where a human reviewer would merge it without a single objection. You are the intelligence layer between unattended batch tasks and the repository owner. The batch tasks optimize for a metric; you optimize for the code being right.
|
||||
|
||||
You do not leave review comments. You do the work. A finding you notice and do not fix is a failure of this task.
|
||||
|
||||
## Scope of the run
|
||||
|
||||
In scope: every open pull request whose head branch starts with `anti-slop/` or `react-doctor/`.
|
||||
|
||||
Find them:
|
||||
|
||||
`gh pr list --state open --search "head:anti-slop/" --json number,title,headRefName,url`
|
||||
|
||||
`gh pr list --state open --search "head:react-doctor/" --json number,title,headRefName,url`
|
||||
|
||||
Work through them one at a time, oldest first. Finish a PR completely before starting the next. Do not interleave.
|
||||
|
||||
This task ends only when every PR in the list has been reviewed, fixed, validated, pushed, and its description updated. Do not stop at the first one. Do not stop because a PR looks acceptable at a glance; that judgement comes after reading the diff, not before.
|
||||
|
||||
## Before you start
|
||||
|
||||
Verify the worktree is clean:
|
||||
|
||||
`git status --porcelain`
|
||||
|
||||
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.
|
||||
|
||||
Load every project skill matching the code you end up touching, exactly as `AGENTS.md` requires. These PRs reach into sync, stores, UI, runtime, and CLI code, and the applicable skill is determined by what you change, not by the fact that this is maintenance work.
|
||||
|
||||
## Working on one PR
|
||||
|
||||
Check out the branch and bring it up to date with `main`:
|
||||
|
||||
`gh pr checkout <number>`
|
||||
|
||||
`git merge origin/main`
|
||||
|
||||
If the merge conflicts, resolve it correctly by reading both sides. Never resolve a conflict by taking one side wholesale to save time.
|
||||
|
||||
Then read the entire diff against `main`, not just the changed lines:
|
||||
|
||||
`git diff origin/main...HEAD`
|
||||
|
||||
For every file in the diff, open the file itself and read the surrounding code. These PRs change type contracts and component structure, so a line that looks correct in isolation is frequently wrong in context.
|
||||
|
||||
## What you are looking for
|
||||
|
||||
Treat the PR body's claims as unverified. Re-run the checks yourself; do not trust reported results.
|
||||
|
||||
Correctness of the change itself:
|
||||
- Did the change alter runtime behavior? Effect cleanup, hook dependencies, component extraction, conditional object spreads, and added parsing all can. Decide whether the new behavior is right, not merely whether it is different.
|
||||
- Does a removed or reordered object key change what gets serialized to an API, persisted to disk, or merged over defaults? A key that used to be absent and is now present as `undefined` is a real change.
|
||||
- Was dead code removed that is actually referenced somewhere the batch task did not search, including dynamic imports, string-keyed lookups, generated assets, and other packages?
|
||||
- Did a type contract change without every call site being updated? Search for each changed symbol across the workspace.
|
||||
- Did an extracted component lose state, memoization, ref forwarding, or a stable identity that the original had?
|
||||
|
||||
Honesty of the change:
|
||||
- Is any `// SAFETY:` comment vague, generic, or untrue? A comment must name the check that already ran. If it does not, either delete the assertion by fixing the contract, or write the truthful comment.
|
||||
- Was a type laundered rather than fixed? Look for invented primitive unions, `any`, new assertions, hand-written type predicates that merely relocate a rejected `typeof` check, or deleted fields and tests.
|
||||
- Was a lint rule disabled, downgraded, ignored, or suppressed inline anywhere in the diff? Revert that and fix the underlying code.
|
||||
- Was a new dependency, schema library, utility module, or architectural pattern introduced under the cover of cleanup? Remove it and solve the problem within existing precedent.
|
||||
|
||||
Quality of the result:
|
||||
- Does the new code read like the code around it, in naming, structure, and comment density?
|
||||
- Are the new names accurate, or do they describe the refactor instead of the domain?
|
||||
- Is the change complete, or did the batch task fix eight of eleven findings in a file and leave three arbitrary ones behind?
|
||||
|
||||
## Fixing
|
||||
|
||||
Fix everything you find, on the PR branch, as additional commits. You are explicitly permitted to go beyond the batch's original file scope when correctness requires it: update call sites, correct an upstream contract, add a missing test, or finish an incomplete refactor.
|
||||
|
||||
Two boundaries on that freedom:
|
||||
|
||||
1. Do not touch files that another open maintenance PR modifies. Check with `gh pr diff <other-number> --name-only` for the other open PRs in this run. If a correct fix genuinely requires such a file, make the change in whichever PR already owns that file, and note the cross-PR dependency in both descriptions.
|
||||
2. Do not turn a maintenance PR into a feature or a redesign. If you conclude the batch's approach was wrong at the root, revert that part of the diff rather than building on it, and explain the revert in the PR body. A smaller correct PR beats a larger clever one.
|
||||
|
||||
Do not disable, downgrade, or ignore lint rules. Do not add `any`, widen a type, or add an assertion to make a check pass. Do not edit `oxlint.config.ts`, `tools/oxlint/anti-slop/`, `CHANGELOG.md`, package versions, or release metadata.
|
||||
|
||||
If a batch left findings unfixed and the PR body called them skipped, evaluate each one yourself. Fix the ones that are fixable within a correct, reviewable change. Keep a skip only when you can articulate why fixing it would be wrong here, not merely hard.
|
||||
|
||||
## Validating each PR
|
||||
|
||||
Re-run the pipeline's own check for the batch, using the run id from the PR body when it is present:
|
||||
|
||||
`bun run deslop -- check-batch --run <run-id>` for anti-slop PRs
|
||||
|
||||
`bun run doctor -- check-batch --run <run-id>` for React Doctor PRs
|
||||
|
||||
If the run directory no longer exists, skip that command and say so; it is a convenience, not the source of truth.
|
||||
|
||||
Then, for every package the final diff touches, run its own checks:
|
||||
|
||||
`bun run --cwd packages/<name> type-check`
|
||||
|
||||
`bun run --cwd packages/<name> lint`
|
||||
|
||||
`bun run --cwd packages/<name> test`
|
||||
|
||||
Run `bunx oxlint <changed-paths>` on the files in the diff and confirm you have not increased anti-slop findings anywhere.
|
||||
|
||||
For surfaces TypeScript does not cover, such as server JavaScript, CLI JavaScript, or Electron main-process helpers, run the focused tests for that surface. Static checks do not prove those correct.
|
||||
|
||||
If a check fails for a reason unrelated to this PR, verify that claim by checking the same command on `main` before dismissing it, and report the result either way.
|
||||
|
||||
## Delivering each PR
|
||||
|
||||
- Commit your fixes with concise messages describing what was actually wrong.
|
||||
- Push to the PR branch. Never force-push.
|
||||
- Update the PR description so it describes the final state, using every heading of `.github/PULL_REQUEST_TEMPLATE.md` in the template's order. `## Intent` covers what the batch did and what you corrected; `## Non-goals` covers what you deliberately left alone; `## Affected surfaces` must reflect the final diff, including files you added beyond the batch scope; `## Repository guidance` must list the rules, skills, and module documentation that applied to your own edits, not only the batch's; `## Validation` must contain the exact commands you re-ran and their results; `## Risks and failure behavior` must carry every behavior change you accepted or introduced. A description that still describes only the batch's original work is incomplete.
|
||||
- Preserve any content the repository owner added to the description by hand, including screenshots. Read the live description before editing it and merge your changes into it rather than overwriting.
|
||||
- Add one PR comment summarizing your review pass, so the history shows what was examined and what was changed.
|
||||
- Do not merge, do not close, do not approve, and do not request review.
|
||||
- Do not release the batch claim. The batch stays claimed until its PR is merged or closed.
|
||||
|
||||
Then move to the next PR.
|
||||
|
||||
## Finishing the run
|
||||
|
||||
When every PR has been handled, return to `main` and pull:
|
||||
|
||||
`git checkout main && git pull`
|
||||
|
||||
Report, per PR: number, title, what was wrong, what you fixed, what you deliberately left alone and why, validation results, and your assessment of whether it is now ready to merge. State plainly if any PR is not ready and what blocks it.
|
||||
|
||||
If you found nothing wrong in a PR, say that explicitly and describe what you checked to reach that conclusion. That is a valid outcome, but only after real inspection.
|
||||
@@ -7,12 +7,35 @@ You are working in the OpenChamber repository.
|
||||
|
||||
Goal: reduce React Doctor diagnostics in a small, reviewable maintenance PR.
|
||||
|
||||
Start by running:
|
||||
This task can run unattended on a schedule, so it must be safe to start at any moment and must stop cleanly when there is nothing to do.
|
||||
|
||||
First, verify the worktree is safe to use:
|
||||
|
||||
`git status --porcelain`
|
||||
|
||||
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:
|
||||
|
||||
`bun run doctor -- next-batch --min-issues 75 --max-issues 120`
|
||||
|
||||
Use the command output as the source of truth for this task scope.
|
||||
|
||||
If the output contains `NO BATCH AVAILABLE`, stop immediately and report the printed reason. Do not create a branch, do not create a pull request, and do not look for other work. Concurrency is already handled: the command excludes files claimed by other active batches and refuses to exceed the active-batch limit.
|
||||
|
||||
Workflow:
|
||||
- Before generating the batch, switch to `main` and pull the latest remote changes.
|
||||
- Read the `next-batch` output carefully.
|
||||
@@ -23,19 +46,42 @@ Workflow:
|
||||
- Fix as many diagnostics as practical in the selected files. Your default should be to fix selected diagnostics, not to skip them.
|
||||
- Prefer direct, behavior-preserving fixes: missing effect cleanup, mutable effect dependencies, accessibility issues with semantic fixes, local performance improvements, Tailwind shorthand replacements, component extraction when the boundary is clear, dead-code removal after verifying no references, and reducer or derived-state cleanup when the state relationship is local and clear.
|
||||
- Handle larger diagnostics deliberately instead of skipping them: for component splits, extract the smallest coherent subcomponent that reduces the diagnostic while preserving props/state flow; for dead code, verify references with search before deleting exports, types, or files; for state architecture issues, prefer the smallest local reducer or derived-state simplification that preserves behavior; for render-function extraction, extract only stable render helpers that do not depend on large implicit closure state, or pass explicit props; for behavior-sensitive diagnostics, read the surrounding code first and preserve existing runtime behavior.
|
||||
- Skip a diagnostic only when the fix would require broad architectural changes, unclear behavior changes, or changes outside the selected batch scope. If skipped, mention it in the PR body.
|
||||
- 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 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>`
|
||||
|
||||
Then run:
|
||||
Then validate the packages you actually touched, not the whole workspace. For each affected package run its own checks, for example:
|
||||
|
||||
`bun run type-check`
|
||||
`bun run --cwd packages/ui type-check`
|
||||
|
||||
`bun run lint`
|
||||
`bun run --cwd packages/ui lint`
|
||||
|
||||
`bun run --cwd packages/ui test`
|
||||
|
||||
Workspace-wide `bun run type-check` and `bun run lint` are CI's job. Run them locally only when a change crosses package boundaries or touches shared contracts. For files that TypeScript does not cover, such as server or CLI JavaScript, run the focused tests for that surface instead.
|
||||
|
||||
Validation and delivery:
|
||||
- Confirm selected files have fewer diagnostics than before.
|
||||
@@ -45,15 +91,20 @@ Validation and delivery:
|
||||
- Create exactly one PR with `gh pr create` using the exact printed `PR title`.
|
||||
- After the PR is created, switch back to `main` and pull the latest remote changes again.
|
||||
|
||||
PR requirements:
|
||||
PR requirements. The repository has a mandatory pull request template at `.github/PULL_REQUEST_TEMPLATE.md`, and `AGENTS.md` requires it to be completed with concrete evidence for the final PR HEAD. Read the template and `CONTRIBUTING.md` before writing the description. Use every template heading, in the template's order, and do not invent replacement headings. Fill each section as follows.
|
||||
|
||||
- Use the exact printed `PR title`.
|
||||
- Include the `Run ID`, `Batch name`, and `Branch name`.
|
||||
- Include selected files.
|
||||
- Include diagnostics fixed according to `check-batch`.
|
||||
- Include remaining diagnostics in selected files.
|
||||
- Include validation results for `bun run type-check` and `bun run lint`.
|
||||
- Include a `Manual testing recommendations` section with focused checks for the changed behavior. Base it on the selected files and actual edits, for example checking affected dropdowns, keyboard navigation, model/agent selection, settings controls, or mobile/desktop variants.
|
||||
- Include any skipped diagnostics and why.
|
||||
- `## Intent`: state that this is an unattended maintenance batch, name the `Run ID`, `Batch name`, and `Branch name`, and say what behavior changes. When nothing observable changes, say so explicitly rather than leaving it implied.
|
||||
- `## Non-goals`: the diagnostics left unfixed in the selected files, diagnostics elsewhere in the repository, and any refactor you deliberately did not start. Give the reason for each, not just the count.
|
||||
- `## Affected surfaces`: the packages, runtimes, user-visible states, and persisted or external contracts the diff reaches. Name every runtime the changed code runs in, and explain why an apparently applicable runtime is unaffected.
|
||||
- `## Repository guidance`: fill the table. List the `AGENTS.md` rules you followed, every project skill that matched the change, required skill references you read, and the nearest `README.md` or `DOCUMENTATION.md` for the touched modules. For each row explain why it applies and how the change complies. Do not list filenames without explanation.
|
||||
- `## Validation`: fill the table with the exact commands you ran and their results, including `check-batch` and every package-scoped type-check, lint, and test command, naming the packages. Record failures honestly, including pre-existing failures unrelated to this PR, and say which checks you did not run. Do not claim runtime behavior from type-check or lint alone.
|
||||
- `## Visual evidence`: these PRs usually have no visible change, so explain concretely why the diff cannot affect rendered behavior. If anything user-visible did change, attach before/after evidence for the affected states.
|
||||
- `## Risks and failure behavior`: cover what breaks if a change is wrong, how to roll it back, and any compatibility, data, performance, or cross-runtime concern. State "None identified" only with a concrete reason.
|
||||
|
||||
Add a `## Manual testing recommendations` section after the template sections, with focused checks for the changed behavior. Base it on the selected files and actual edits, for example checking affected dropdowns, keyboard navigation, model or agent selection, settings controls, and mobile or desktop variants.
|
||||
|
||||
Also state, inside `## Intent`, the selected files and how many diagnostics `check-batch` reports as fixed and remaining.
|
||||
|
||||
Constraints:
|
||||
- Keep the PR small and reviewable.
|
||||
@@ -61,4 +112,6 @@ Constraints:
|
||||
- Do not modify unrelated files except minimal supporting changes required by selected-file fixes.
|
||||
- Do not run broad formatting.
|
||||
- Do not fix diagnostics outside the selected files.
|
||||
- Leave `.tmp/react-doctor/runs/<run-id>/` intact after creating the PR. These files are the handoff for the review follow-up task.
|
||||
- Do not edit `CHANGELOG.md`, package versions, or release metadata. This is internal maintenance with no user-facing change.
|
||||
- Leave the batch's run directory intact after creating the PR. `next-batch` prints its location. That directory is both the handoff for the review follow-up task and the claim that stops another batch, including the anti-slop pipeline, from touching the same files. Deleting it early lets a parallel batch collide with this PR. Never delete it by hand; use `bun run doctor -- release --run <run-id>`.
|
||||
- If you stop before creating a PR for any reason, release the claim with `bun run doctor -- release --run <run-id>` so the files return to the pool.
|
||||
|
||||
@@ -7,16 +7,40 @@ You are working in the OpenChamber repository.
|
||||
|
||||
Goal: follow up on an existing React Doctor maintenance PR, address Greptile/review bot feedback, and clean up the local batch handoff files when done.
|
||||
|
||||
Inspect local React Doctor batch handoff files:
|
||||
This task can run unattended on a schedule, so it must be safe to start at any moment and must stop cleanly when there is nothing to do.
|
||||
|
||||
`find .tmp/react-doctor/runs -maxdepth 2 -name batch.json -print 2>/dev/null || true`
|
||||
First, verify the worktree is safe to use:
|
||||
|
||||
`git status --porcelain`
|
||||
|
||||
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:
|
||||
|
||||
`bun run doctor -- active`
|
||||
|
||||
The listing may include batches owned by the anti-slop pipeline; those are shown as `[pipeline as]`. Never touch them.
|
||||
|
||||
Workflow:
|
||||
- Read the available `.tmp/react-doctor/runs/*/batch.json` files.
|
||||
- Find the most recent batch that has `branchName`, `batchName`, and `prTitle`.
|
||||
- Read its `Run ID`, `Batch name`, `Branch name`, `PR title`, and selected files.
|
||||
- Use `gh` to find the open PR for that branch or title.
|
||||
- If no open PR exists for the batch, stop and report that there is no PR to follow up.
|
||||
- If there are no active batches, stop and report that there is nothing to follow up.
|
||||
- Each active batch corresponds to one open PR. Read its `batch.json` for `runId`, `branchName`, `batchName`, `prTitle`, and selected files.
|
||||
- Use `gh` to find the open PR for each batch branch.
|
||||
- Work on the oldest batch that has an open PR with unaddressed feedback. If several qualify, handle exactly one and leave the rest.
|
||||
- If a batch's PR was already merged or closed, do not treat it as follow-up work. Release its claim with `bun run doctor -- release --run <run-id>` so its files return to the pool, then continue looking.
|
||||
- If no batch has an open PR with actionable feedback, stop and report that.
|
||||
- Switch to the batch branch using the exact `branchName`.
|
||||
- Pull or update the branch from remote if needed.
|
||||
- Use `gh` to inspect PR review comments, PR issue comments, review threads if available, and check run summaries if relevant.
|
||||
@@ -32,9 +56,7 @@ After fixes, run:
|
||||
|
||||
`bun run doctor -- check-batch --run <run-id>`
|
||||
|
||||
`bun run type-check`
|
||||
|
||||
`bun run lint`
|
||||
Then re-run the package-scoped checks for the packages you touched, for example `bun run --cwd packages/ui type-check`, `bun run --cwd packages/ui lint`, and `bun run --cwd packages/ui test`. Workspace-wide checks are CI's job.
|
||||
|
||||
Delivery:
|
||||
- Commit follow-up fixes with a concise message.
|
||||
@@ -42,15 +64,17 @@ Delivery:
|
||||
- Reply to addressed review comments using `gh`.
|
||||
- For each specific review comment you addressed, reply with what was changed and the follow-up commit hash.
|
||||
- If the feedback was a general PR comment, add one general PR comment summarizing what was addressed, commit hashes, and validation results.
|
||||
- Update the PR description so it stays true for the final HEAD: refresh `## Validation` with the checks you re-ran, and move any new behavior change into `## Risks and failure behavior`. Keep every heading of `.github/PULL_REQUEST_TEMPLATE.md` intact, and preserve content the repository owner added by hand, including screenshots. Read the live description before editing and merge into it rather than overwriting.
|
||||
- If a comment is intentionally not addressed, reply with a concise reason.
|
||||
- After successful push and replies, delete only the completed batch handoff directory: `.tmp/react-doctor/runs/<run-id>/`.
|
||||
- Do not release the batch while its PR is still open and awaiting review. The claim is what keeps parallel batches off these files.
|
||||
- Release the batch only once its PR has been merged or closed: `bun run doctor -- release --run <run-id>`.
|
||||
- After the follow-up is complete, switch back to `main` and pull the latest remote changes.
|
||||
|
||||
Constraints:
|
||||
- Work on exactly one React Doctor batch PR.
|
||||
- Prefer the most recent batch with an open PR.
|
||||
- Prefer the oldest batch with an open PR.
|
||||
- Do not auto-merge.
|
||||
- Do not close the PR.
|
||||
- Do not delete handoff files until comments are addressed, validation passes, and follow-up commits are pushed.
|
||||
- Do not delete unrelated `.tmp/react-doctor/runs/*` directories.
|
||||
- If validation fails and cannot be fixed safely within scope, do not delete the handoff directory.
|
||||
- Do not edit `CHANGELOG.md`, package versions, or release metadata.
|
||||
- Do not release or delete handoff directories for batches you did not handle.
|
||||
- If validation fails and cannot be fixed safely within scope, leave the batch claimed and report the blocker.
|
||||
|
||||
@@ -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` |
|
||||
@@ -123,6 +127,7 @@ Before adding guidance to a skill, identify its canonical owner. If another skil
|
||||
- Prefer focused tests and package-scoped type-check/lint for executable source changes.
|
||||
- Use workspace-wide checks for cross-workspace contracts, root tooling, dependencies, or shared generated assets.
|
||||
- Run `bun run dead-code` when source files are added/deleted/renamed or exports, types, entrypoints, or import shape change; inspect its report because it is non-blocking.
|
||||
- Run `bunx oxlint <changed-paths>` on TypeScript/JavaScript files you created or substantially rewrote. This runs the vendored `anti-slop` plugin, which rejects low-evidence typing: unjustified type assertions, `unknown`/`object`/`Record<string, unknown>` contracts, ad hoc `typeof` narrowing, and module mocking. Fix findings in code you authored. Pre-existing findings elsewhere are a known backlog: do not mass-fix them, and never silence a rule, weaken severity, or launder types to make the check pass.
|
||||
- Do not assume TypeScript/lint covers server JS, CLI JS, Electron helpers, or native behavior; run focused tests, syntax checks, builds, or runtime validation for the touched surface.
|
||||
- For docs-only or isolated config changes, run the narrowest relevant validation.
|
||||
- Report exactly what was and was not validated. Static checks alone do not prove runtime, relay, performance, or platform correctness.
|
||||
|
||||
@@ -4,8 +4,40 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- 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).
|
||||
|
||||
## [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).
|
||||
|
||||
## [1.18.4] - 2026-08-14
|
||||
|
||||
- **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order.
|
||||
- **Stability:** a single internal error no longer shuts down the local server, which made the instance unreachable until it was restarted; the error is logged and the server keeps running.
|
||||
- Mobile: connecting to a server that has authentication disabled now survives closing and reopening the app — auto-reconnect and the return-to-app check no longer treat the missing password token as a lost connection and kick back to the connect screen.
|
||||
- Browser: restoring or opening a dev server preview while connected to an instance over a relay or other non-standard address no longer crashes the app; the preview reports the tunnel as unavailable instead.
|
||||
|
||||
## [1.18.3] - 2026-08-14
|
||||
|
||||
@@ -6,25 +6,25 @@
|
||||
"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",
|
||||
@@ -65,6 +65,7 @@
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@oxlint/plugins": "1.78.0",
|
||||
"@remixicon/react": "^4.7.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/dom-speech-recognition": "^0.0.12",
|
||||
@@ -83,6 +84,7 @@
|
||||
"globals": "^16.3.0",
|
||||
"node-addon-api": "7.1.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"oxlint": "1.78.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"sharp": "^0.35.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
@@ -95,7 +97,7 @@
|
||||
},
|
||||
"packages/electron": {
|
||||
"name": "@openchamber/electron",
|
||||
"version": "1.18.2",
|
||||
"version": "1.19.0",
|
||||
"dependencies": {
|
||||
"@openchamber/web": "workspace:*",
|
||||
"electron-context-menu": "^4.1.2",
|
||||
@@ -132,7 +134,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.18.2",
|
||||
"version": "1.19.0",
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
"@base-ui/react": "^1.4.0",
|
||||
@@ -141,34 +143,34 @@
|
||||
"@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",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@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",
|
||||
@@ -236,7 +238,7 @@
|
||||
},
|
||||
"packages/vscode": {
|
||||
"name": "openchamber",
|
||||
"version": "1.18.2",
|
||||
"version": "1.19.0",
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
@@ -259,7 +261,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@openchamber/web",
|
||||
"version": "1.18.2",
|
||||
"version": "1.19.0",
|
||||
"bin": {
|
||||
"openchamber": "./bin/cli.js",
|
||||
},
|
||||
@@ -268,7 +270,6 @@
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"adm-zip": "^0.6.0",
|
||||
"bun-pty": "^0.4.5",
|
||||
"compression": "^1.8.1",
|
||||
"cron-parser": "^4.9.0",
|
||||
@@ -304,7 +305,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",
|
||||
@@ -353,8 +353,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=="],
|
||||
@@ -601,9 +611,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=="],
|
||||
|
||||
@@ -613,11 +623,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=="],
|
||||
|
||||
@@ -627,7 +637,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=="],
|
||||
|
||||
@@ -645,21 +655,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=="],
|
||||
|
||||
@@ -995,6 +1005,46 @@
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.18", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.78.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.78.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="],
|
||||
|
||||
"@oxlint/plugins": ["@oxlint/plugins@1.78.0", "", {}, "sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug=="],
|
||||
|
||||
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
||||
|
||||
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
|
||||
@@ -1149,7 +1199,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=="],
|
||||
|
||||
@@ -2663,6 +2715,8 @@
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
||||
"oxlint": ["oxlint@1.78.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.78.0", "@oxlint/binding-android-arm64": "1.78.0", "@oxlint/binding-darwin-arm64": "1.78.0", "@oxlint/binding-darwin-x64": "1.78.0", "@oxlint/binding-freebsd-x64": "1.78.0", "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", "@oxlint/binding-linux-arm-musleabihf": "1.78.0", "@oxlint/binding-linux-arm64-gnu": "1.78.0", "@oxlint/binding-linux-arm64-musl": "1.78.0", "@oxlint/binding-linux-ppc64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-musl": "1.78.0", "@oxlint/binding-linux-s390x-gnu": "1.78.0", "@oxlint/binding-linux-x64-gnu": "1.78.0", "@oxlint/binding-linux-x64-musl": "1.78.0", "@oxlint/binding-openharmony-arm64": "1.78.0", "@oxlint/binding-win32-arm64-msvc": "1.78.0", "@oxlint/binding-win32-ia32-msvc": "1.78.0", "@oxlint/binding-win32-x64-msvc": "1.78.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA=="],
|
||||
|
||||
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig } from "oxlint";
|
||||
|
||||
// Oxlint here runs only the vendored anti-slop plugin; ESLint remains the
|
||||
// general-purpose linter for this repository.
|
||||
export default defineConfig({
|
||||
categories: {
|
||||
correctness: "off",
|
||||
},
|
||||
ignorePatterns: [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/out/**",
|
||||
"**/.next/**",
|
||||
"**/ios/**",
|
||||
"**/android/**",
|
||||
".agents/**",
|
||||
".claude/**",
|
||||
".conductor/**",
|
||||
".opencode/**",
|
||||
".openchamber/**",
|
||||
".tmp/**",
|
||||
"patches/**",
|
||||
"bun-patches/**",
|
||||
"tools/oxlint/anti-slop/**",
|
||||
],
|
||||
jsPlugins: [
|
||||
{ name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" },
|
||||
],
|
||||
rules: {
|
||||
"anti-slop/no-chained-type-assertions": "error",
|
||||
"anti-slop/no-conditional-empty-object-spread": "error",
|
||||
"anti-slop/no-known-value-widening": "error",
|
||||
"anti-slop/no-module-mocking": "error",
|
||||
"anti-slop/no-object-parameters": "error",
|
||||
"anti-slop/no-reflect-apply": "error",
|
||||
"anti-slop/no-reflect-get": "error",
|
||||
"anti-slop/no-runtime-typeof": "error",
|
||||
"anti-slop/no-shape-in-symbol-names": "error",
|
||||
"anti-slop/no-unknown-parameters": "error",
|
||||
"anti-slop/no-unknown-returns": "error",
|
||||
"anti-slop/no-unknown-type-aliases": "error",
|
||||
"anti-slop/no-unsafe-dictionary-type": "error",
|
||||
"anti-slop/no-widen-then-assert": "error",
|
||||
"anti-slop/require-safety-comment-for-type-assertion": "error",
|
||||
},
|
||||
});
|
||||
+29
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openchamber-monorepo",
|
||||
"version": "1.18.3",
|
||||
"version": "1.19.0",
|
||||
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -38,6 +38,7 @@
|
||||
"lint:ui": "bun run --cwd packages/ui lint",
|
||||
"lint:electron": "bun run --cwd packages/electron lint",
|
||||
"lint:mobile": "bun run --cwd packages/mobile lint",
|
||||
"lint:anti-slop": "oxlint",
|
||||
"test": "node scripts/run-isolated-tests.mjs scripts && bun run --cwd packages/ui test && bun run --cwd packages/vscode test && bun run --cwd packages/electron test && bun run --cwd packages/web test",
|
||||
"clean": "bun run --filter '*' clean",
|
||||
"changelog-card": "node scripts/changelog-card/generate.mjs",
|
||||
@@ -74,6 +75,7 @@
|
||||
"docs:validate": "node scripts/docs/validate-docs.mjs",
|
||||
"dead-code": "bunx knip@5.80.0 --no-exit-code --include files,exports,nsExports,types,nsTypes,enumMembers,duplicates",
|
||||
"doctor": "node scripts/react-doctor.mjs",
|
||||
"deslop": "node scripts/anti-slop.mjs",
|
||||
"profile:browser": "node scripts/profile-browser.mjs",
|
||||
"icons:sprite": "node scripts/generate-file-type-sprite.mjs",
|
||||
"icons:generate": "bun run scripts/generate-icon-sprite.mjs",
|
||||
@@ -89,25 +91,25 @@
|
||||
},
|
||||
"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",
|
||||
@@ -146,12 +148,24 @@
|
||||
"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",
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@oxlint/plugins": "1.78.0",
|
||||
"@remixicon/react": "^4.7.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/dom-speech-recognition": "^0.0.12",
|
||||
"@types/node": "^24.3.1",
|
||||
@@ -169,8 +183,8 @@
|
||||
"globals": "^16.3.0",
|
||||
"node-addon-api": "7.1.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"oxlint": "1.78.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"@remixicon/react": "^4.7.0",
|
||||
"sharp": "^0.35.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Integrationen
|
||||
description: Nutze dein Claude-, Command-Code- 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**.
|
||||
|
||||
Verfügbare Integrationen:
|
||||
|
||||
- **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys
|
||||
- **Command Code** — dein Command-Code-Plan
|
||||
- **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. Command Code und Cursor brauchen ihre CLIs 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.
|
||||
|
||||
## Command Code
|
||||
|
||||
Command Code nutzt deinen Command-Code-Plan.
|
||||
|
||||
1. Installiere die Integration (siehe oben).
|
||||
2. Wähle **Set up** — es öffnet sich eine Browserseite. Genehmige den Zugriff und kehre zu OpenChamber zurück.
|
||||
|
||||
Auf einem Headless-Server oder in CI setze stattdessen die Umgebungsvariable `COMMAND_CODE_API_KEY`, anstatt dich im Browser anzumelden.
|
||||
|
||||
## 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 ClawdHub-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,62 @@
|
||||
---
|
||||
title: Integraciones
|
||||
description: Usa tu suscripción de Claude, Command Code 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**.
|
||||
|
||||
Integraciones disponibles:
|
||||
|
||||
- **Claude Code** — tu plan Claude Pro o Max, sin claves de API
|
||||
- **Command Code** — tu plan de Command Code
|
||||
- **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. Command Code y Cursor no requieren sus CLIs.
|
||||
|
||||
**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.
|
||||
|
||||
## Command Code
|
||||
|
||||
Command Code usa tu plan de Command Code.
|
||||
|
||||
1. Instala la integración (arriba).
|
||||
2. Elige **Set up** — se abre una página en el navegador. Autoriza el acceso y vuelve a OpenChamber.
|
||||
|
||||
En una máquina sin interfaz gráfica o en CI, define la variable de entorno `COMMAND_CODE_API_KEY` en lugar de iniciar sesión en el navegador.
|
||||
|
||||
## 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, Command Code 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 ClawdHub— 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,62 @@
|
||||
---
|
||||
title: Intégrations
|
||||
description: Utilise ton abonnement Claude, Command Code 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**.
|
||||
|
||||
Intégrations disponibles :
|
||||
|
||||
- **Claude Code** — ton plan Claude Pro ou Max, sans clés API
|
||||
- **Command Code** — ton plan Command Code
|
||||
- **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. Command Code et Cursor n'exigent pas leurs CLIs.
|
||||
|
||||
**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.
|
||||
|
||||
## Command Code
|
||||
|
||||
Command Code utilise ton plan Command Code.
|
||||
|
||||
1. Installe l'intégration (ci-dessus).
|
||||
2. Choisis **Set up** — une page s'ouvre dans le navigateur. Autorise l'accès, puis reviens dans OpenChamber.
|
||||
|
||||
Sur une machine sans interface graphique ou en CI, définis la variable d'environnement `COMMAND_CODE_API_KEY` au lieu de te connecter via le navigateur.
|
||||
|
||||
## 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, Command Code 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 ClawdHub — 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,62 @@
|
||||
---
|
||||
title: Integrations
|
||||
description: Use your Claude, Command Code, 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**.
|
||||
|
||||
Available integrations:
|
||||
|
||||
- **Claude Code** — your Claude Pro or Max plan, no API keys
|
||||
- **Command Code** — your Command Code plan
|
||||
- **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. Command Code and Cursor do not require their CLIs.
|
||||
|
||||
**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.
|
||||
|
||||
## Command Code
|
||||
|
||||
Command Code uses your Command Code plan.
|
||||
|
||||
1. Install the integration (above).
|
||||
2. Choose **Set up** — a browser page opens. Approve access, then return to OpenChamber.
|
||||
|
||||
On a headless machine or in CI, set the `COMMAND_CODE_API_KEY` environment variable instead of signing in in the browser.
|
||||
|
||||
## 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,62 @@
|
||||
---
|
||||
title: 統合機能
|
||||
description: Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う。
|
||||
---
|
||||
|
||||
# 統合機能
|
||||
|
||||
統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。
|
||||
|
||||
利用できる統合機能:
|
||||
|
||||
- **Claude Code** — Claude Pro または Max プラン、API キー不要
|
||||
- **Command Code** — Command Code のプラン
|
||||
- **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 のインストールとサインインを必要とする統合機能です。Command Code と Cursor は CLI を必要としません。
|
||||
|
||||
**Claude アカウントが守られる仕組み:** この統合機能は Anthropic の公式 Claude Agent SDK と、インストール済みの Claude Code CLI を使用します。OAuth の乗っ取り、ブラウザートークンの抽出や再生、未対応クライアントへの偽装、Anthropic の認証フローの回避は一切行いません。Anthropic がサポートする正規のアクセス経路を使うため、トークン乗っ取りや不正な認証の回避につきもののアカウント停止リスクはありません。
|
||||
|
||||
## Command Code
|
||||
|
||||
Command Code は Command Code のプランを使います。
|
||||
|
||||
1. 統合機能をインストールします(上記)。
|
||||
2. **Set up** を選ぶとブラウザーでページが開きます。アクセスを許可して OpenChamber に戻ります。
|
||||
|
||||
画面のないサーバーや CI では、ブラウザーでサインインする代わりに環境変数 `COMMAND_CODE_API_KEY` を設定してください。
|
||||
|
||||
## 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、Command Code、Cursor のサブスクリプションをプロバイダーとして使う
|
||||
- [MCP サーバー](/mcp/) — エージェントに追加ツールを加える
|
||||
- [使用量とクォータ](/usage/) — 使った量を追跡する
|
||||
|
||||
@@ -12,7 +12,7 @@ Skills Catalog では、自分で書く代わりに、他の人が公開した
|
||||
## スキルをインストールする
|
||||
|
||||
1. カタログを開きます。
|
||||
2. 組み込みソース(Anthropic skills repo と ClawdHub community registry)を閲覧するか、検索します。
|
||||
2. 組み込みソース(Anthropic skills repo など)を閲覧するか、検索します。
|
||||
3. スキルを選び、インストールします。
|
||||
4. インストール先を選びます。すべての作業で使うか、現在のプロジェクトだけで使うかです。
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: 통합 기능
|
||||
description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하세요.
|
||||
---
|
||||
|
||||
# 통합 기능
|
||||
|
||||
통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다.
|
||||
|
||||
사용 가능한 통합 기능:
|
||||
|
||||
- **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요
|
||||
- **Command Code** — Command Code 플랜
|
||||
- **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 설치와 로그인을 필요로 하는 통합 기능입니다. Command Code와 Cursor는 CLI가 필요 없습니다.
|
||||
|
||||
**Claude 계정이 안전하게 유지되는 방식:** 이 통합 기능은 Anthropic의 공식 Claude Agent SDK와 설치된 Claude Code CLI를 사용합니다. OAuth 탈취, 브라우저 토큰 추출·재사용, 지원되지 않는 클라이언트로의 위장, Anthropic 인증 우회를 하지 않습니다. Anthropic이 지원하는 정상 경로를 사용하므로 토큰 탈취나 비인가 인증 우회에 따른 계정 정지 위험이 없습니다.
|
||||
|
||||
## Command Code
|
||||
|
||||
Command Code는 Command Code 플랜을 사용합니다.
|
||||
|
||||
1. 통합 기능을 설치합니다(위 참고).
|
||||
2. **Set up**를 선택하면 브라우저에서 페이지가 열립니다. 접근을 승인한 뒤 OpenChamber로 돌아옵니다.
|
||||
|
||||
화면이 없는 서버나 CI 환경에서는 브라우저 로그인 대신 `COMMAND_CODE_API_KEY` 환경 변수를 설정하세요.
|
||||
|
||||
## 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, Command Code, Cursor 구독을 공급자로 사용
|
||||
- [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다
|
||||
- [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다
|
||||
|
||||
@@ -12,7 +12,7 @@ Skills Catalog를 사용하면 직접 작성하는 대신 다른 사람이 게
|
||||
## 스킬 설치하기
|
||||
|
||||
1. 카탈로그를 엽니다.
|
||||
2. 내장된 소스(Anthropic 스킬 저장소와 ClawdHub 커뮤니티 레지스트리)를 둘러보거나 검색합니다.
|
||||
2. 내장된 소스(예: Anthropic 스킬 저장소)를 둘러보거나 검색합니다.
|
||||
3. 스킬을 선택하고 설치합니다.
|
||||
4. 설치 위치를 선택합니다. 모든 작업에 적용할지, 현재 프로젝트에만 적용할지 선택합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Integracje
|
||||
description: Używaj subskrypcji Claude, Command Code 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**.
|
||||
|
||||
Dostępne integracje:
|
||||
|
||||
- **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API
|
||||
- **Command Code** — Twój plan Command Code
|
||||
- **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. Command Code i Cursor nie wymagają swoich 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.
|
||||
|
||||
## Command Code
|
||||
|
||||
Command Code korzysta z Twojego planu Command Code.
|
||||
|
||||
1. Zainstaluj integrację (patrz wyżej).
|
||||
2. Wybierz **Set up** — w przeglądarce otworzy się strona. Zatwierdź dostęp i wróć do OpenChamber.
|
||||
|
||||
Na maszynie bez interfejsu graficznego lub w CI ustaw zmienną środowiskową `COMMAND_CODE_API_KEY` zamiast logowania w przeglądarce.
|
||||
|
||||
## 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, Command Code 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 ClawdHub — 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, Command Code, 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,62 @@
|
||||
---
|
||||
title: Integrações
|
||||
description: Use sua assinatura Claude, Command Code 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**.
|
||||
|
||||
Integrações disponíveis:
|
||||
|
||||
- **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API
|
||||
- **Command Code** — seu plano Command Code
|
||||
- **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. Command Code e Cursor não exigem suas CLIs.
|
||||
|
||||
**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.
|
||||
|
||||
## Command Code
|
||||
|
||||
O Command Code usa seu plano Command Code.
|
||||
|
||||
1. Instale a integração (acima).
|
||||
2. Escolha **Set up** — uma página abre no navegador. Autorize o acesso e volte ao OpenChamber.
|
||||
|
||||
Em uma máquina sem interface gráfica ou em CI, defina a variável de ambiente `COMMAND_CODE_API_KEY` em vez de fazer login pelo navegador.
|
||||
|
||||
## 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, Command Code 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 ClawdHub — 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 ClawdHub 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,62 @@
|
||||
---
|
||||
title: Інтеграції
|
||||
description: Використовуйте підписки Claude, Command Code або Cursor як провайдерів.
|
||||
---
|
||||
|
||||
# Інтеграції
|
||||
|
||||
Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**.
|
||||
|
||||
Доступні інтеграції:
|
||||
|
||||
- **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів
|
||||
- **Command Code** — ваша підписка Command Code
|
||||
- **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 свого провайдера. Для Command Code і Cursor їхні CLI не потрібні.
|
||||
|
||||
**Як захищається ваш обліковий запис Claude:** ця інтеграція використовує офіційний Claude Agent SDK від Anthropic і ваш встановлений Claude Code CLI. Вона не перехоплює OAuth, не витягує й не відтворює браузерні токени, не видає себе за непідтримуваний клієнт і не обходить процес автентифікації Anthropic. Усе працює через підтримуваний Anthropic шлях доступу, тож інтеграція не несе ризику блокування облікового запису, пов'язаного з перехопленням токенів або несанкціонованими способами автентифікації.
|
||||
|
||||
## Command Code
|
||||
|
||||
Command Code використовує вашу підписку Command Code.
|
||||
|
||||
1. Встановіть інтеграцію (вище).
|
||||
2. Натисніть **Set up** — відкриється сторінка в браузері. Підтвердьте доступ і поверніться до OpenChamber.
|
||||
|
||||
На сервері без графічного інтерфейсу або в CI замість входу через браузер задайте змінну середовища `COMMAND_CODE_API_KEY`.
|
||||
|
||||
## 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, Command Code або Cursor як провайдерів
|
||||
- [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти
|
||||
- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили
|
||||
|
||||
@@ -12,7 +12,7 @@ description: Переглядайте та встановлюйте готові
|
||||
## Встановлення навички
|
||||
|
||||
1. Відкрийте каталог.
|
||||
2. Перегляньте вбудовані джерела — репозиторій навичок Anthropic та спільнотний реєстр ClawdHub — або скористайтеся пошуком.
|
||||
2. Перегляньте вбудовані джерела — наприклад репозиторій навичок Anthropic — або скористайтеся пошуком.
|
||||
3. Оберіть навичку й установіть її.
|
||||
4. Виберіть, куди встановити: для всього, що ви робите, чи лише для поточного проєкту.
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: 集成
|
||||
description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。
|
||||
---
|
||||
|
||||
# 集成
|
||||
|
||||
集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。
|
||||
|
||||
可用的集成:
|
||||
|
||||
- **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥
|
||||
- **Command Code** — 你的 Command Code 套餐
|
||||
- **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 的集成。Command Code 和 Cursor 不需要它们的 CLI。
|
||||
|
||||
**你的 Claude 账户如何受到保护:** 此集成使用 Anthropic 官方的 Claude Agent SDK 和你已安装的 Claude Code CLI。它不会劫持 OAuth,不会提取或重放浏览器令牌,不会冒充不受支持的客户端,也不会绕过 Anthropic 的身份验证。它始终运行在 Anthropic 支持的访问路径上,因此不会带来与令牌劫持或未授权身份验证变通手段相关的封号风险。
|
||||
|
||||
## Command Code
|
||||
|
||||
Command Code 使用你的 Command Code 套餐。
|
||||
|
||||
1. 安装集成(见上文)。
|
||||
2. 选择 **Set up** — 浏览器中会打开一个页面。授权访问,然后返回 OpenChamber。
|
||||
|
||||
在没有图形界面的服务器或 CI 环境中,请设置环境变量 `COMMAND_CODE_API_KEY` 来代替浏览器登录。
|
||||
|
||||
## 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、Command Code 或 Cursor 订阅用作提供商
|
||||
- [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具
|
||||
- [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量
|
||||
|
||||
@@ -12,7 +12,7 @@ Skills 目录让你能够安装其他人发布的 skill,而不必自己编写
|
||||
## 安装 skill
|
||||
|
||||
1. 打开目录。
|
||||
2. 浏览内置来源 — Anthropic skills 仓库和 ClawdHub 社区注册表 — 或进行搜索。
|
||||
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/",
|
||||
|
||||
@@ -159,7 +159,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);
|
||||
});
|
||||
@@ -31,6 +31,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);
|
||||
@@ -1186,6 +1187,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,
|
||||
@@ -2455,6 +2465,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.3",
|
||||
"version": "1.19.0",
|
||||
"private": true,
|
||||
"description": "Electron desktop runtime for OpenChamber",
|
||||
"author": "OpenChamber",
|
||||
|
||||
+14
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.18.3",
|
||||
"version": "1.19.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/main.tsx",
|
||||
@@ -19,34 +19,34 @@
|
||||
"@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",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@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",
|
||||
|
||||
+19
-5
@@ -14,6 +14,7 @@ import { useTraySync } from '@/hooks/useTraySync';
|
||||
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';
|
||||
@@ -54,7 +55,11 @@ import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
import {
|
||||
EMBEDDED_VISIBILITY_UPDATE,
|
||||
isEmbeddedSessionChat,
|
||||
requestEmbeddedSessionVisibility,
|
||||
} from '@/components/layout/contextPanelEmbeddedChat';
|
||||
import { SyncAppEffects } from '@/apps/AppEffects';
|
||||
import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset';
|
||||
import { useAppFontEffects } from '@/apps/useAppFontEffects';
|
||||
@@ -205,6 +210,11 @@ const EmbeddedSessionChatContent: React.FC<{
|
||||
<OpenCodeUpdateToast />
|
||||
<ChatView
|
||||
active={embeddedBackgroundWorkEnabled}
|
||||
// Always subscribe to message history in the mounted session-chat
|
||||
// iframe. Visibility still gates composer focus and background work so
|
||||
// a boot-inactive / lost-handshake race cannot leave a busy subagent
|
||||
// showing only its status row (#2903 / #2892).
|
||||
messagesEnabled={true}
|
||||
readOnly={embeddedSessionChat.readOnly}
|
||||
initialAllowPromptingSubagentSessions={embeddedSessionChat.allowPromptingSubagentSessions}
|
||||
/>
|
||||
@@ -538,17 +548,16 @@ function App({ apis }: AppProps) {
|
||||
}
|
||||
|
||||
const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {
|
||||
const nextVisible = payload?.visible === true;
|
||||
setIsEmbeddedVisible(nextVisible);
|
||||
setIsEmbeddedVisible(payload?.visible === true);
|
||||
};
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) {
|
||||
if (event.origin !== window.location.origin || event.source !== window.parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload };
|
||||
if (data?.type !== 'openchamber:embedded-visibility') {
|
||||
if (data?.type !== EMBEDDED_VISIBILITY_UPDATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -561,6 +570,7 @@ function App({ apis }: AppProps) {
|
||||
|
||||
scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility;
|
||||
window.addEventListener('message', handleMessage);
|
||||
requestEmbeddedSessionVisibility();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
@@ -694,6 +704,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();
|
||||
|
||||
@@ -83,6 +83,7 @@ const MOBILE_SETTINGS_PAGES = [
|
||||
'providers',
|
||||
'usage',
|
||||
'voice',
|
||||
'integrations',
|
||||
'about',
|
||||
] as const;
|
||||
|
||||
@@ -108,7 +109,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);
|
||||
@@ -539,7 +540,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<PlanView
|
||||
targetPath={openPlan.path}
|
||||
projectPlanId={openPlan.id}
|
||||
onNavigatedToChat={() => {
|
||||
closeSurface();
|
||||
closeWorkspace();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
@@ -1015,20 +1015,20 @@ export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIf
|
||||
logConnect('auto-connect:start', { hasCandidate: Boolean(candidate), fast });
|
||||
if (!candidate) return { status: 'no-candidate' };
|
||||
|
||||
// The runtime transport needs a bearer token; only auto-connect when one is
|
||||
// already saved. A missing/expired token must go through the login UI.
|
||||
// The runtime transport authenticates with a bearer token when the server
|
||||
// issued one. A connection saved WITHOUT a token means its last successful
|
||||
// connect was tokenless (server auth disabled) — probe it the same way; the
|
||||
// probe itself reports needs-login if the server has since enabled auth. Only
|
||||
// an EXPECTED token that cannot be read must go through the login UI.
|
||||
let token: string | undefined;
|
||||
if (isCapacitorApp()) {
|
||||
if (!candidate.hasToken) {
|
||||
return { status: 'no-candidate' };
|
||||
}
|
||||
token = await readSecureToken(secureTokenKeyOf(candidate));
|
||||
if (!token) {
|
||||
return { status: 'no-candidate' };
|
||||
if (candidate.hasToken) {
|
||||
token = await readSecureToken(secureTokenKeyOf(candidate));
|
||||
if (!token) return { status: 'no-candidate' };
|
||||
}
|
||||
} else {
|
||||
token = candidate.clientToken;
|
||||
if (!token) return { status: 'no-candidate' };
|
||||
if (!token && candidate.hasToken) return { status: 'no-candidate' };
|
||||
}
|
||||
|
||||
// Fast probe by default: the cold-launch splash should decide in a couple of
|
||||
@@ -1050,7 +1050,7 @@ export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIf
|
||||
return { status: 'no-candidate' };
|
||||
}
|
||||
await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token)
|
||||
switchToTransport(result.transport, token, { runtimeKey: secureTokenKeyOf(candidate) });
|
||||
switchToTransport(result.transport, token ?? null, { runtimeKey: secureTokenKeyOf(candidate) });
|
||||
return { status: 'connected' };
|
||||
};
|
||||
|
||||
@@ -1209,11 +1209,15 @@ export const reprobeActiveConnection = async (options?: { fast?: boolean }): Pro
|
||||
} else {
|
||||
token = active.clientToken;
|
||||
}
|
||||
if (!token) {
|
||||
logConnect('reprobe:no-token', { hasToken: Boolean(active.hasToken) });
|
||||
// Tokenless is valid (server auth disabled — the probe reports needs-login if
|
||||
// that changed); bail only when an EXPECTED token cannot be read. 'unreachable'
|
||||
// (not needs-login) so the resume retry ladder re-reads the token — a transient
|
||||
// secure-storage failure must not force a re-login.
|
||||
if (!token && active.hasToken) {
|
||||
logConnect('reprobe:no-token', { hasToken: true });
|
||||
return 'unreachable';
|
||||
}
|
||||
logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast });
|
||||
logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast, hasToken: Boolean(token) });
|
||||
|
||||
const currentIndex = active.candidates.findIndex(
|
||||
(candidate) => transportMatchesCurrentRuntime(candidate.kind === 'relay' ? { kind: 'relay', relay: candidate.relay } : { kind: 'direct', url: candidate.url }),
|
||||
@@ -1225,7 +1229,7 @@ export const reprobeActiveConnection = async (options?: { fast?: boolean }): Pro
|
||||
logConnect('reprobe:better', { status: better.status, probed: higher.length });
|
||||
if (better.status === 'ok') {
|
||||
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
|
||||
switchToTransport(better.transport, token, { runtimeKey: secureTokenKeyOf(active) });
|
||||
switchToTransport(better.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) });
|
||||
return 'switched';
|
||||
}
|
||||
// The shared token was explicitly rejected — no transport will accept it.
|
||||
@@ -1251,7 +1255,7 @@ export const reprobeActiveConnection = async (options?: { fast?: boolean }): Pro
|
||||
logConnect('reprobe:fallback', { status: fallback.status, probed: lower.length });
|
||||
if (fallback.status === 'ok') {
|
||||
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
|
||||
switchToTransport(fallback.transport, token, { runtimeKey: secureTokenKeyOf(active) });
|
||||
switchToTransport(fallback.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) });
|
||||
return 'switched';
|
||||
}
|
||||
if (fallback.status === 'needs-login') return 'needs-login';
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -52,6 +53,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();
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<!-- Claude AI symbol (CC0, Wikimedia Commons File:Claude_AI_symbol.svg), monochrome for theme invert -->
|
||||
<path fill="currentColor" d="m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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 |
@@ -226,7 +226,7 @@ export const BrowserToolbar: React.FC<BrowserToolbarProps> = ({
|
||||
) : null}
|
||||
{onAnnotate ? (
|
||||
<ToolbarButton
|
||||
icon="cursor"
|
||||
icon="markup"
|
||||
label={t('contextPanel.browser.annotate.toggle')}
|
||||
onClick={onAnnotate}
|
||||
pressed={isAnnotating}
|
||||
|
||||
@@ -533,12 +533,27 @@ const DraftWelcome: React.FC = () => {
|
||||
|
||||
type ChatContainerProps = {
|
||||
active?: boolean;
|
||||
/**
|
||||
* When set, controls message-history reads and session-message loads
|
||||
* independently of `active`. Defaults to `active`. Embedded session-chat
|
||||
* panels pass `true` so a delayed/lost visibility handshake cannot hide
|
||||
* an already-materialized transcript (leaving only the working-status
|
||||
* row — issue #2903).
|
||||
*/
|
||||
messagesEnabled?: boolean;
|
||||
autoOpenDraft?: boolean;
|
||||
readOnly?: boolean;
|
||||
initialAllowPromptingSubagentSessions?: boolean;
|
||||
};
|
||||
|
||||
export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false, initialAllowPromptingSubagentSessions }) => {
|
||||
export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
active = true,
|
||||
messagesEnabled: messagesEnabledProp,
|
||||
autoOpenDraft = true,
|
||||
readOnly = false,
|
||||
initialAllowPromptingSubagentSessions,
|
||||
}) => {
|
||||
const messagesEnabled = messagesEnabledProp ?? active;
|
||||
const { t } = useI18n();
|
||||
// Session UI state
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
@@ -591,9 +606,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
);
|
||||
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory);
|
||||
const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory);
|
||||
// Messages from sync system
|
||||
// Messages from sync system. Keep this gated by `messagesEnabled`, not
|
||||
// `active`, so embedded panels can show history while the composer stays
|
||||
// inactive until the parent confirms visibility.
|
||||
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
|
||||
enabled: active,
|
||||
enabled: messagesEnabled,
|
||||
suspendPartUpdates: Boolean(streamingMessageId),
|
||||
suspendPartUpdatesForMessageId: streamingMessageId,
|
||||
});
|
||||
@@ -1042,9 +1059,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
Boolean(currentSessionId)
|
||||
&& !hasRenderableSessionSnapshot;
|
||||
const retrySessionLoad = React.useCallback(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
if (!messagesEnabled || !currentSessionId) return;
|
||||
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
|
||||
}, [active, currentSessionId, effectiveSessionDirectory, sync]);
|
||||
}, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
@@ -1069,10 +1086,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
if (!messagesEnabled || !currentSessionId) return;
|
||||
if (hasRenderableSessionSnapshot) return;
|
||||
void ensureSessionRenderable(currentSessionId);
|
||||
}, [active, currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]);
|
||||
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]);
|
||||
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
// With auto-open, the draft welcome opens on the next tick (effect below),
|
||||
|
||||
@@ -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';
|
||||
@@ -24,6 +25,7 @@ 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,
|
||||
@@ -596,59 +598,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 +661,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);
|
||||
|
||||
@@ -960,6 +965,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;
|
||||
@@ -1051,6 +1057,44 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
: undefined;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline review comments and synthetic context are consumed before
|
||||
// assembly so a failed send can restore exactly what it took.
|
||||
const syntheticParts = consumePendingSyntheticParts();
|
||||
@@ -1079,7 +1123,7 @@ 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,
|
||||
|
||||
@@ -1,25 +1,92 @@
|
||||
import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
acquireRuntimeUrlAuthToken,
|
||||
refreshRuntimeUrlAuthToken,
|
||||
subscribeRuntimeUrlAuthToken,
|
||||
} from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
import {
|
||||
extractMarkdownImageCandidates,
|
||||
MAX_MARKDOWN_IMAGE_COUNT,
|
||||
type MarkdownImageCandidate,
|
||||
} from './markdown/markdownCore';
|
||||
import { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
|
||||
import {
|
||||
getPreparedMarkdownImageUrl,
|
||||
isLocalMarkdownImageSource,
|
||||
prepareLocalMarkdownImages,
|
||||
resolveMarkdownImageSource,
|
||||
resolveWorkspaceMarkdownImageSource,
|
||||
type PreparedMarkdownImage,
|
||||
} from './markdown/markdownImageAssets';
|
||||
|
||||
const useAssetAuth = (enabled: boolean): { ready: boolean; nonce: number } => {
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const [nonce, setNonce] = React.useState(0);
|
||||
const apiBaseUrl = getRuntimeApiBaseUrl();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
setReady(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const release = acquireRuntimeUrlAuthToken(apiBaseUrl);
|
||||
const unsubscribe = subscribeRuntimeUrlAuthToken(() => {
|
||||
if (!cancelled) setNonce((current) => current + 1);
|
||||
});
|
||||
const refresh = () => {
|
||||
void refreshRuntimeUrlAuthToken(apiBaseUrl)
|
||||
.then(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) retryTimer = setTimeout(refresh, 1000);
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
release();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [apiBaseUrl, enabled]);
|
||||
|
||||
return { ready: !enabled || ready, nonce };
|
||||
};
|
||||
|
||||
const MarkdownImageThumbnail: React.FC<{
|
||||
candidate: MarkdownImageCandidate;
|
||||
preparation?: PreparedMarkdownImage;
|
||||
directory: string;
|
||||
assetAuthReady: boolean;
|
||||
assetAuthNonce: number;
|
||||
useWorkspaceFsBridge: boolean;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}> = ({ candidate, directory, onShowPopup }) => {
|
||||
}> = ({
|
||||
candidate,
|
||||
preparation,
|
||||
directory,
|
||||
assetAuthReady,
|
||||
assetAuthNonce,
|
||||
useWorkspaceFsBridge,
|
||||
onShowPopup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const thumbnailRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [shouldLoad, setShouldLoad] = React.useState(false);
|
||||
const [image, setImage] = React.useState<{
|
||||
url: string;
|
||||
status: 'loading' | 'ready' | 'error';
|
||||
}>({ url: '', status: 'loading' });
|
||||
const [image, setImage] = React.useState<{ url: string; status: 'loading' | 'ready' | 'error' }>({
|
||||
url: '',
|
||||
status: 'loading',
|
||||
});
|
||||
const local = isLocalMarkdownImageSource(candidate.source);
|
||||
|
||||
React.useEffect(() => {
|
||||
const thumbnail = thumbnailRef.current;
|
||||
@@ -28,7 +95,6 @@ const MarkdownImageThumbnail: React.FC<{
|
||||
setShouldLoad(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
setShouldLoad(true);
|
||||
@@ -39,20 +105,45 @@ const MarkdownImageThumbnail: React.FC<{
|
||||
}, [shouldLoad]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldLoad) return;
|
||||
if (!shouldLoad || (local && !useWorkspaceFsBridge && !preparation)) return;
|
||||
if (local && useWorkspaceFsBridge) {
|
||||
const controller = new AbortController();
|
||||
setImage({ url: '', status: 'loading' });
|
||||
void resolveWorkspaceMarkdownImageSource(candidate.source, directory, controller.signal).then((url) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setImage({ url, status: 'loading' });
|
||||
}).catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setImage({ url: '', status: 'error' });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}
|
||||
if (local) {
|
||||
if (preparation?.status !== 'ready') {
|
||||
setImage({ url: '', status: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!assetAuthReady) return;
|
||||
setImage({ url: getPreparedMarkdownImageUrl(preparation, directory), status: 'loading' });
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setImage({ url: '', status: 'loading' });
|
||||
void resolveMarkdownImageSource(candidate.source, directory, controller.signal)
|
||||
.then((url) => {
|
||||
if (!controller.signal.aborted) setImage({ url, status: 'loading' });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setImage({ url: '', status: 'error' });
|
||||
});
|
||||
void resolveMarkdownImageSource(candidate.source, controller.signal).then((url) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setImage({ url, status: 'loading' });
|
||||
}).catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setImage({ url: '', status: 'error' });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [candidate.source, directory, shouldLoad]);
|
||||
}, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad, useWorkspaceFsBridge]);
|
||||
|
||||
const openPreview = React.useCallback(() => {
|
||||
if (image.status === 'error') {
|
||||
toast.error(t('filesView.error.previewUnavailable'));
|
||||
return;
|
||||
}
|
||||
if (image.status !== 'ready' || !onShowPopup) return;
|
||||
onShowPopup({
|
||||
open: true,
|
||||
@@ -61,7 +152,7 @@ const MarkdownImageThumbnail: React.FC<{
|
||||
metadata: { tool: 'markdown-image-preview', filename: candidate.filename },
|
||||
image: { url: image.url, filename: candidate.filename },
|
||||
});
|
||||
}, [candidate.filename, image, onShowPopup]);
|
||||
}, [candidate.filename, image, onShowPopup, t]);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -69,7 +160,7 @@ const MarkdownImageThumbnail: React.FC<{
|
||||
type="button"
|
||||
className="w-[100px] shrink-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-label={candidate.filename}
|
||||
disabled={image.status !== 'ready'}
|
||||
disabled={image.status === 'loading'}
|
||||
onClick={openPreview}
|
||||
data-openchamber-markdown-image-action="true"
|
||||
data-openchamber-markdown-image-source={candidate.source}
|
||||
@@ -107,27 +198,94 @@ const MarkdownImageThumbnail: React.FC<{
|
||||
};
|
||||
|
||||
export const MarkdownImageGallery: React.FC<{
|
||||
sessionId?: string;
|
||||
messageId: string;
|
||||
contents: readonly string[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}> = ({ contents, onShowPopup }) => {
|
||||
}> = ({ sessionId, messageId, contents, onShowPopup }) => {
|
||||
const directory = useEffectiveDirectory() ?? '';
|
||||
const galleryRef = React.useRef<HTMLDivElement>(null);
|
||||
const [shouldPrepare, setShouldPrepare] = React.useState(false);
|
||||
const [prepared, setPrepared] = React.useState<Map<string, PreparedMarkdownImage> | null>(null);
|
||||
const [prepareEpoch, setPrepareEpoch] = React.useState(0);
|
||||
const useWorkspaceFsBridge = isVSCodeRuntime();
|
||||
const candidates = React.useMemo(
|
||||
() => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT),
|
||||
[contents],
|
||||
);
|
||||
const serverPreparationSources = React.useMemo(
|
||||
() => useWorkspaceFsBridge
|
||||
? []
|
||||
: candidates
|
||||
.filter((candidate) => isLocalMarkdownImageSource(candidate.source))
|
||||
.map((candidate) => candidate.source),
|
||||
[candidates, useWorkspaceFsBridge],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (serverPreparationSources.length === 0 || shouldPrepare) return;
|
||||
const gallery = galleryRef.current;
|
||||
if (!gallery || typeof IntersectionObserver === 'undefined') {
|
||||
setShouldPrepare(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
setShouldPrepare(true);
|
||||
observer.disconnect();
|
||||
}, { rootMargin: '200px' });
|
||||
observer.observe(gallery);
|
||||
return () => observer.disconnect();
|
||||
}, [serverPreparationSources.length, shouldPrepare]);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
React.useEffect(() => {
|
||||
if (!shouldPrepare || !sessionId || serverPreparationSources.length === 0) return;
|
||||
const controller = new AbortController();
|
||||
void prepareLocalMarkdownImages({
|
||||
sources: serverPreparationSources,
|
||||
directory,
|
||||
sessionId,
|
||||
messageId,
|
||||
signal: controller.signal,
|
||||
}).then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPrepared(result);
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setPrepared(new Map(serverPreparationSources.map((source) => [source, { status: 'error' }])));
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [directory, messageId, prepareEpoch, serverPreparationSources, sessionId, shouldPrepare]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const nextExpiry = Math.min(...[...(prepared?.values() ?? [])]
|
||||
.filter((value): value is Extract<PreparedMarkdownImage, { status: 'ready' }> => value.status === 'ready')
|
||||
.map((value) => value.expiresAt ?? Number.POSITIVE_INFINITY));
|
||||
if (!Number.isFinite(nextExpiry)) return;
|
||||
const timer = setTimeout(() => setPrepareEpoch((current) => current + 1), Math.max(0, nextExpiry - Date.now()));
|
||||
return () => clearTimeout(timer);
|
||||
}, [prepared]);
|
||||
|
||||
const visibleCandidates = candidates.filter((candidate) => prepared?.get(candidate.source)?.status !== 'missing');
|
||||
const hasPreparedAssets = [...(prepared?.values() ?? [])].some((value) => value.status === 'ready');
|
||||
const assetAuth = useAssetAuth(hasPreparedAssets);
|
||||
if (visibleCandidates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={galleryRef}
|
||||
className="mt-3 flex max-w-full gap-2 overflow-x-auto pb-1"
|
||||
data-openchamber-markdown-image-gallery="true"
|
||||
>
|
||||
{candidates.map((candidate) => (
|
||||
{visibleCandidates.map((candidate) => (
|
||||
<MarkdownImageThumbnail
|
||||
key={candidate.source}
|
||||
candidate={candidate}
|
||||
preparation={prepared?.get(candidate.source)}
|
||||
directory={directory}
|
||||
assetAuthReady={assetAuth.ready}
|
||||
assetAuthNonce={assetAuth.nonce}
|
||||
useWorkspaceFsBridge={useWorkspaceFsBridge}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -19,8 +19,7 @@ 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 { getMarkdownImageFilename, renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
||||
import { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
|
||||
import { renderMarkdownBlocks, renderMarkdownSync, type MarkdownImageMode } from './markdown/markdownCore';
|
||||
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
||||
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
||||
import {
|
||||
@@ -109,59 +108,6 @@ const useExternalLinkInteractions = ({
|
||||
}, [containerRef, enabled]);
|
||||
};
|
||||
|
||||
const useMarkdownImageLinkInteractions = ({
|
||||
containerRef,
|
||||
directory,
|
||||
enabled,
|
||||
onShowPopup,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!enabled || !container || !onShowPopup) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented || event.button !== 0) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
|
||||
const link = target.closest<HTMLAnchorElement>('[data-openchamber-markdown-image-link="true"]');
|
||||
if (!link || !container.contains(link)) return;
|
||||
|
||||
const source = link.getAttribute('data-openchamber-markdown-image-source') ?? '';
|
||||
const filename = link.getAttribute('data-openchamber-markdown-image-filename')
|
||||
|| getMarkdownImageFilename(source, '');
|
||||
if (!source || !filename) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void resolveMarkdownImageSource(source, directory, controller.signal)
|
||||
.then((url) => {
|
||||
if (controller.signal.aborted || !link.isConnected) return;
|
||||
onShowPopup({
|
||||
open: true,
|
||||
title: filename,
|
||||
content: '',
|
||||
metadata: { tool: 'markdown-image-preview', filename },
|
||||
image: { url, filename },
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
controller.abort();
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [containerRef, directory, enabled, onShowPopup]);
|
||||
};
|
||||
|
||||
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
|
||||
download: true,
|
||||
copy: true,
|
||||
@@ -195,7 +141,6 @@ interface MarkdownRendererProps {
|
||||
variant?: MarkdownVariant;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
enableFileReferences?: boolean;
|
||||
enableLocalImages?: boolean;
|
||||
}
|
||||
|
||||
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
||||
@@ -557,10 +502,6 @@ const useFileReferenceInteractions = ({
|
||||
let linkedCount = 0;
|
||||
|
||||
for (const candidate of Array.from(candidates)) {
|
||||
if (candidate.matches('[data-openchamber-markdown-image-link="true"]')) {
|
||||
clearFileLinkAttributes(candidate);
|
||||
continue;
|
||||
}
|
||||
const rawCandidate = extractPathCandidateFromElement(candidate);
|
||||
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
|
||||
clearFileLinkAttributes(candidate);
|
||||
@@ -894,16 +835,14 @@ const useMorphdomMarkdown = ({
|
||||
containerRef,
|
||||
text,
|
||||
streaming,
|
||||
cacheKey,
|
||||
deferImages = false,
|
||||
imageMode = 'inline',
|
||||
syntaxVars,
|
||||
ctx,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
cacheKey: string;
|
||||
deferImages?: boolean;
|
||||
imageMode?: MarkdownImageMode;
|
||||
syntaxVars: Record<string, string>;
|
||||
ctx: DecorateContext;
|
||||
}) => {
|
||||
@@ -942,7 +881,7 @@ const useMorphdomMarkdown = ({
|
||||
// `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, deferImages);
|
||||
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
|
||||
@@ -954,7 +893,7 @@ const useMorphdomMarkdown = ({
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
}
|
||||
}, [containerRef, text, deferImages, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, text, imageMode, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
@@ -967,7 +906,7 @@ const useMorphdomMarkdown = ({
|
||||
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
let active = true;
|
||||
|
||||
void renderMarkdownBlocks(text, streaming, cacheKey, deferImages).then((blocks) => {
|
||||
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
||||
if (!active) return;
|
||||
const existing = Array.from(target.children) as HTMLElement[];
|
||||
|
||||
@@ -1018,7 +957,7 @@ const useMorphdomMarkdown = ({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [containerRef, text, streaming, cacheKey, deferImages, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1065,7 +1004,6 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
variant = 'assistant',
|
||||
onShowPopup,
|
||||
enableFileReferences = true,
|
||||
enableLocalImages = false,
|
||||
}) => {
|
||||
streamPerfCount('ui.markdown_renderer.render');
|
||||
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
|
||||
@@ -1097,40 +1035,30 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
enabled: enableFileReferences && !isStreaming,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef });
|
||||
useMarkdownImageLinkInteractions({
|
||||
containerRef,
|
||||
directory: effectiveDirectory,
|
||||
enabled: enableLocalImages && variant === 'assistant' && !isStreaming,
|
||||
onShowPopup,
|
||||
});
|
||||
|
||||
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}`}`;
|
||||
// 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,
|
||||
deferImages: enableLocalImages && variant === 'assistant' && !isStreaming,
|
||||
imageMode: variant === 'assistant' ? 'label' : 'inline',
|
||||
syntaxVars,
|
||||
ctx,
|
||||
});
|
||||
|
||||
const markdownContent = (
|
||||
<div
|
||||
className={cn('break-words w-full min-w-0', className)}
|
||||
ref={containerRef}
|
||||
data-openchamber-finalized-assistant-images={enableLocalImages && variant === 'assistant' && !isStreaming ? 'true' : undefined}
|
||||
>
|
||||
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
|
||||
<div className={markdownContentClassName(variant)} data-markdown-content />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isAnimated) {
|
||||
return (
|
||||
<FadeInOnReveal key={cacheKey} skipAnimation={skipFadeIn}>
|
||||
<FadeInOnReveal key={fadeKey} skipAnimation={skipFadeIn}>
|
||||
{markdownContent}
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
@@ -1150,7 +1078,6 @@ export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) =>
|
||||
&& prev.messageId === next.messageId
|
||||
&& prev.onShowPopup === next.onShowPopup
|
||||
&& prev.enableFileReferences === next.enableFileReferences
|
||||
&& prev.enableLocalImages === next.enableLocalImages
|
||||
&& prev.part?.id === next.part?.id;
|
||||
});
|
||||
|
||||
@@ -1208,7 +1135,6 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
containerRef,
|
||||
text: renderedContent,
|
||||
streaming: false,
|
||||
cacheKey: `simple:${variant}`,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
});
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Regression coverage for https://github.com/openchamber/openchamber/issues/2903
|
||||
*
|
||||
* Busy embedded session-chat panels were rendering only the working-status row
|
||||
* ("…is running command") because ChatContainer gated message reads on the
|
||||
* same visibility flag used to keep the composer from stealing focus. When the
|
||||
* iframe booted inactive (or a visibility postMessage was lost),
|
||||
* useSessionMessageRecords returned [] while session status stayed busy — so
|
||||
* the empty-state branch was skipped and the transcript showed status only.
|
||||
*
|
||||
* Idle sessions hit the empty state instead (#2892). Same root cause.
|
||||
*
|
||||
* Fix: embedded session-chat keeps `messagesEnabled={true}` so history stays
|
||||
* subscribed while `active={embeddedBackgroundWorkEnabled}` still gates
|
||||
* composer focus and background work.
|
||||
*/
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
mock.module('sonner', () => ({
|
||||
toast: { dismiss: () => undefined, error: () => undefined, info: () => undefined, success: () => undefined },
|
||||
}));
|
||||
mock.module('@/components/ui', () => ({
|
||||
toast: { info: () => undefined, error: () => undefined, success: () => undefined },
|
||||
}));
|
||||
mock.module('@/lib/opencode/client', () => ({
|
||||
opencodeClient: {
|
||||
getDirectory: () => '/repo',
|
||||
setDirectory: () => undefined,
|
||||
getSdkClient: () => ({}),
|
||||
getScopedSdkClient: () => ({}),
|
||||
},
|
||||
}));
|
||||
mock.module('@/stores/permissionStore', () => ({
|
||||
usePermissionStore: { getState: () => ({ isSessionAutoAccepting: () => false, hydrate: async () => undefined }) },
|
||||
}));
|
||||
mock.module('@/stores/useConfigStore', () => ({
|
||||
useConfigStore: {
|
||||
getState: () => ({ isConnected: true, hasEverConnected: true, settingsMessageStreamTransport: 'auto' }),
|
||||
setState: () => undefined,
|
||||
},
|
||||
}));
|
||||
mock.module('@/stores/useTodosPersistStore', () => ({
|
||||
useTodosPersistStore: { getState: () => ({ setSessionTodos: () => undefined }) },
|
||||
}));
|
||||
|
||||
const { useSessionMessageRecords } = await import('@/sync/sync-context');
|
||||
const { ChildStoreManager } = await import('@/sync/child-store');
|
||||
const { getSessionMaterializationStatus } = await import('@/sync/materialization');
|
||||
import type { State } from '@/sync/types';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8');
|
||||
const chatContainerSource = readFileSync(join(__dirname, '..', 'ChatContainer.tsx'), 'utf-8');
|
||||
const chatViewSource = readFileSync(join(__dirname, '..', '..', 'views', 'ChatView.tsx'), 'utf-8');
|
||||
const syncContextSource = readFileSync(join(__dirname, '..', '..', '..', 'sync', 'sync-context.tsx'), 'utf-8');
|
||||
|
||||
const SESSION_ID = 'ses_subagent_2903';
|
||||
const DIRECTORY = '/repo';
|
||||
|
||||
const installMinimalDom = () => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: unknown) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ElementStub {}
|
||||
const documentStub: Record<string, unknown> = {
|
||||
nodeType: 9,
|
||||
defaultView: globalThis,
|
||||
activeElement: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
const container = {
|
||||
nodeType: 1,
|
||||
tagName: 'DIV',
|
||||
nodeName: 'DIV',
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument: documentStub,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
documentStub.documentElement = container;
|
||||
documentStub.body = container;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
|
||||
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
|
||||
return {
|
||||
container: container as unknown as Element,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createMessage = (id: string, role: 'user' | 'assistant', created: number): Message => ({
|
||||
id,
|
||||
sessionID: SESSION_ID,
|
||||
role,
|
||||
...(role === 'assistant' ? { parentID: `u_${created}` } : {}),
|
||||
time: { created },
|
||||
} as Message);
|
||||
|
||||
const createPart = (id: string, messageID: string, text: string): Part => ({
|
||||
id,
|
||||
messageID,
|
||||
sessionID: SESSION_ID,
|
||||
type: 'text',
|
||||
text,
|
||||
} as Part);
|
||||
|
||||
/** 14-message subagent transcript, matching the issue reproduction fixture. */
|
||||
const buildMaterializedSubagentSession = () => {
|
||||
const messages: Message[] = [];
|
||||
const part: Record<string, Part[]> = {};
|
||||
for (let index = 0; index < 14; index += 1) {
|
||||
const created = index + 1;
|
||||
const role: 'user' | 'assistant' = created % 2 === 1 ? 'user' : 'assistant';
|
||||
const id = role === 'user' ? `u_${created}` : `a_${created}`;
|
||||
messages.push(createMessage(id, role, created));
|
||||
part[id] = [createPart(`prt_${id}`, id, role === 'user' ? `prompt ${created}` : `output ${created}`)];
|
||||
}
|
||||
return { messages, part };
|
||||
};
|
||||
|
||||
const syncContext = (globalThis as unknown as {
|
||||
__openchamber_sync_context__?: React.Context<unknown>;
|
||||
}).__openchamber_sync_context__;
|
||||
|
||||
if (!syncContext) {
|
||||
throw new Error('sync context was not published on globalThis by @/sync/sync-context');
|
||||
}
|
||||
|
||||
describe('issue #2903 busy embedded subagent status-line-only', () => {
|
||||
test('cold disabled reads hide a fully materialized 14-message subagent; enabled reads return all 14', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const childStores = new ChildStoreManager();
|
||||
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
|
||||
const { messages, part } = buildMaterializedSubagentSession();
|
||||
store.setState({
|
||||
status: 'complete',
|
||||
session: [{
|
||||
id: SESSION_ID,
|
||||
title: 'Audit Searchbar implementation',
|
||||
time: { created: 1, updated: 1 },
|
||||
version: '1',
|
||||
directory: DIRECTORY,
|
||||
} as State['session'][number]],
|
||||
message: { [SESSION_ID]: messages },
|
||||
part,
|
||||
} as Partial<State>);
|
||||
|
||||
expect(getSessionMaterializationStatus(store.getState(), SESSION_ID)).toEqual({
|
||||
hasMessages: true,
|
||||
renderable: true,
|
||||
missingPartMessageIDs: [],
|
||||
});
|
||||
|
||||
const system = { childStores, messageLoader: {}, sdk: {}, runtimeKey: 'test', directory: DIRECTORY };
|
||||
const Provider = syncContext.Provider as React.Provider<unknown>;
|
||||
let inactiveCount = -1;
|
||||
let activeCount = -1;
|
||||
let enabled = false;
|
||||
|
||||
const Harness = () => {
|
||||
const records = useSessionMessageRecords(SESSION_ID, DIRECTORY, { enabled });
|
||||
if (enabled) {
|
||||
activeCount = records.length;
|
||||
} else {
|
||||
inactiveCount = records.length;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
|
||||
});
|
||||
expect(inactiveCount).toBe(0);
|
||||
|
||||
enabled = true;
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
|
||||
});
|
||||
expect(activeCount).toBe(14);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('sync gate still returns empty on cold disabled reads', () => {
|
||||
const hookStart = syncContextSource.indexOf('export function useSessionMessageRecords(');
|
||||
const hookBody = syncContextSource.slice(hookStart, hookStart + 1800);
|
||||
expect(hookBody).toContain('if (options?.enabled === false)');
|
||||
expect(hookBody).toContain('EMPTY_SESSION_MESSAGE_RECORDS');
|
||||
expect(hookBody).toContain('snapshotRef.current.sessionID === sessionID ? snapshotRef.current.list');
|
||||
});
|
||||
|
||||
test('embedded session-chat keeps message history enabled while visibility gates active', () => {
|
||||
expect(appSource).toContain('messagesEnabled={true}');
|
||||
expect(appSource).toContain('active={embeddedBackgroundWorkEnabled}');
|
||||
expect(appSource).toContain('const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(false);');
|
||||
expect(chatViewSource).toContain('messagesEnabled?: boolean');
|
||||
expect(chatContainerSource).toContain('messagesEnabled: messagesEnabledProp');
|
||||
expect(chatContainerSource).toContain('const messagesEnabled = messagesEnabledProp ?? active;');
|
||||
expect(chatContainerSource).toContain('enabled: messagesEnabled');
|
||||
expect(chatContainerSource.includes('enabled: active')).toBe(false);
|
||||
expect(chatContainerSource).toContain('if (!messagesEnabled || !currentSessionId) return;');
|
||||
expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);');
|
||||
});
|
||||
|
||||
test('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => {
|
||||
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,
|
||||
);
|
||||
expect(emptyStateBlock).toContain('<ChatEmptyState');
|
||||
expect(emptyStateBlock).not.toContain('<StatusRowContainer />');
|
||||
});
|
||||
|
||||
test('visibility handshake remains as defense-in-depth for background work', () => {
|
||||
expect(appSource).toContain('requestEmbeddedSessionVisibility();');
|
||||
expect(appSource).toContain('EMBEDDED_VISIBILITY_UPDATE');
|
||||
});
|
||||
});
|
||||
@@ -61,20 +61,46 @@ question of design, not of feasibility.
|
||||
Selection rendering: every device runs CodeMirror's `drawSelection()` — it
|
||||
keeps typing on the drawn-selection code path, and removing it makes
|
||||
CodeMirror enforce cursor association on the native selection, which iOS
|
||||
answers with severe input lag. Every device also layers
|
||||
`composerNativeSelectionExtension` (`editor/theme.ts`) on top: it re-shows
|
||||
answers with severe input lag. **That much is not platform-specific and must
|
||||
not be undone.** What differs is who paints the selection, and
|
||||
`composerSelectionExtension` (`editor/theme.ts`) picks that per platform.
|
||||
|
||||
When CodeMirror 6.43.9's iOS predicate does not match,
|
||||
`composerNativeSelectionExtension` layers over `drawSelection()`: it re-shows
|
||||
the native selection, and — only while a range is selected — the native caret,
|
||||
hiding the painted layers those replace. The native selection is the one that
|
||||
shows for two reasons: the painted layer sits behind the content, so tokens
|
||||
with their own background (inline code, fences) cover it completely; and
|
||||
iOS's selection drag handles attach to the visible native selection and take
|
||||
their colour from the caret, so a transparent caret means invisible handles.
|
||||
The range-only caret scoping is load-bearing — a native caret visible while
|
||||
typing makes WebKit re-render its caret UI after every keystroke, felt as
|
||||
severe input lag. The selection tint comes from `--primary`, not the selection
|
||||
token:
|
||||
themes define `--interactive-selection` with its own alpha, so a translucent
|
||||
mix of it is nearly invisible.
|
||||
with their own background (inline code, fences) cover it completely; and the
|
||||
platform's selection drag handles attach to the visible native selection and
|
||||
take their colour from the caret, so a transparent caret means invisible
|
||||
handles. The range-only caret scoping is load-bearing — a native caret visible
|
||||
while typing makes the browser re-render its caret UI after every keystroke,
|
||||
felt as severe input lag.
|
||||
|
||||
When CodeMirror 6.43.9's exact iOS predicate matches,
|
||||
`composerIOSSelectionExtension` leaves selection-handle geometry and appearance
|
||||
to CodeMirror. CodeMirror puts the handles in `.cm-selectionLayer`, normally at
|
||||
`z-index: -1`; the extension raises that layer above the content so opaque
|
||||
token backgrounds cannot cover them, and leaves it transparent to touch.
|
||||
The handle dots extend 8px past their range; matching scroller padding and
|
||||
negative margin expand the clip area without moving the text or changing the
|
||||
composer height. iOS still paints its taller system selection overlay even
|
||||
when CSS makes `::selection` transparent. The extension therefore suppresses
|
||||
CodeMirror's synthetic selection rectangles on iOS while leaving its handles,
|
||||
cursor path and `nativeSelectionHidden` facet active. Otherwise the grey system
|
||||
highlight and themed rectangle overlap with visibly different heights.
|
||||
Do not add a second custom layer or custom handles here: overlapping translucent
|
||||
rectangles make selection darker at their seams and imitated handles drift from
|
||||
the geometry WebKit actually manipulates. What iOS avoids is installing the
|
||||
native-selection workaround above: explicitly restoring native paint and caret
|
||||
makes WebKit re-measure them after every decoration redraw, and the composer
|
||||
rebuilds every decoration on every keystroke. That cost is felt worst during
|
||||
IME composition.
|
||||
|
||||
The non-iOS native selection tint comes from `--primary`, not the selection
|
||||
token: themes define `--interactive-selection` with its own alpha, so mixing it
|
||||
with transparent again is nearly invisible. The iOS system overlay owns its
|
||||
visible selection fill.
|
||||
|
||||
`composerLanguage.ts` retokenizes the whole document on every change. The
|
||||
composer holds a prompt, not a source file: it is short enough that a full pass
|
||||
|
||||
@@ -36,7 +36,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { ComposerLanguageContext } from '../language/tokenize';
|
||||
import { composerLanguage, setLanguageContext } from './composerLanguage';
|
||||
import type { ComposerEditorViewStore } from './viewStore';
|
||||
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
|
||||
import { composerEditorTheme, composerSelectionExtension } from './theme';
|
||||
import { handleComposerHostMouseDown } from './hostMouseDown';
|
||||
|
||||
export interface ComposerSelection {
|
||||
@@ -234,14 +234,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
doc: handlersRef.current.value,
|
||||
extensions: [
|
||||
history(),
|
||||
// `drawSelection()` must stay even though the native
|
||||
// selection is what actually shows (see the theme's
|
||||
// comment on `composerNativeSelectionExtension`):
|
||||
// removing it makes CodeMirror enforce cursor
|
||||
// association on the native selection, which iOS
|
||||
// answers with severe input lag.
|
||||
// `drawSelection()` must stay on every platform.
|
||||
// `composerSelectionExtension()` changes only who
|
||||
// paints the selection; removing `drawSelection()`
|
||||
// makes CodeMirror enforce cursor association on the
|
||||
// native selection, which iOS answers with severe lag.
|
||||
drawSelection(),
|
||||
composerNativeSelectionExtension,
|
||||
composerSelectionExtension(),
|
||||
EditorView.lineWrapping,
|
||||
// Highest precedence: the composer's own keys must win
|
||||
// over CodeMirror's defaults (Enter sends, ArrowUp
|
||||
@@ -344,6 +343,10 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (current === value) return;
|
||||
// Skip every controlled writeback while the browser is composing.
|
||||
// A stale value echo can differ from CodeMirror's newer document,
|
||||
// and replacing it would interrupt the IME session and move the caret.
|
||||
if (view.compositionStarted) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: value },
|
||||
// An external rewrite (draft restore, history navigation,
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorState, type Extension } from '@codemirror/state';
|
||||
|
||||
import {
|
||||
COMPOSER_EDITOR_THEME_SPEC,
|
||||
IOS_SELECTION_THEME_SPEC,
|
||||
NATIVE_SELECTION_THEME_SPEC,
|
||||
composerEditorTheme,
|
||||
composerIOSSelectionExtension,
|
||||
composerNativeSelectionExtension,
|
||||
composerSelectionExtension,
|
||||
isCodeMirrorIOSNavigator,
|
||||
} from '../theme';
|
||||
|
||||
const selectors = Object.keys(COMPOSER_EDITOR_THEME_SPEC);
|
||||
const declarations = JSON.stringify(COMPOSER_EDITOR_THEME_SPEC);
|
||||
|
||||
function installationError(extension: Extension): string | null {
|
||||
try {
|
||||
EditorState.create({ extensions: [extension] });
|
||||
return null;
|
||||
} catch (error) {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
describe('composerEditorTheme', () => {
|
||||
/**
|
||||
* EditorView.theme compiles its selectors when this module is imported and
|
||||
@@ -20,13 +33,7 @@ describe('composerEditorTheme', () => {
|
||||
* surfaces only in the running app, where it takes the composer down.
|
||||
*/
|
||||
test('its selectors compile and the theme can be installed', () => {
|
||||
let failure: unknown = null;
|
||||
try {
|
||||
EditorState.create({ extensions: [composerEditorTheme] });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeNull();
|
||||
expect(installationError(composerEditorTheme)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -101,6 +108,10 @@ describe('composerEditorTheme', () => {
|
||||
expect(rule.background.includes('transparent')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('the common theme does not re-show the native selection', () => {
|
||||
expect(selectors.some((selector) => selector.includes('::selection'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerNativeSelectionTheme', () => {
|
||||
@@ -108,21 +119,16 @@ describe('composerNativeSelectionTheme', () => {
|
||||
const nativeDeclarations = JSON.stringify(NATIVE_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Every device layers this over `drawSelection()`: the native selection
|
||||
* paints over token backgrounds (the painted layer is hidden behind them)
|
||||
* and iOS attaches its selection handles to it. `drawSelection()` must
|
||||
* NOT be removed for that: without it CodeMirror starts enforcing cursor
|
||||
* association on the native selection while typing in wrapped text, and
|
||||
* iOS answers those programmatic selection moves with severe input lag.
|
||||
* Every device except iOS layers this over `drawSelection()`: the native
|
||||
* selection paints over token backgrounds (the painted layer is hidden
|
||||
* behind them) and the platform attaches its selection handles to it.
|
||||
* `drawSelection()` must NOT be removed for that: without it CodeMirror
|
||||
* starts enforcing cursor association on the native selection while typing
|
||||
* in wrapped text, and iOS answers those programmatic selection moves with
|
||||
* severe input lag.
|
||||
*/
|
||||
test('it compiles and can be installed', () => {
|
||||
let failure: unknown = null;
|
||||
try {
|
||||
EditorState.create({ extensions: [composerNativeSelectionExtension] });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeNull();
|
||||
expect(installationError(composerNativeSelectionExtension)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -150,9 +156,9 @@ describe('composerNativeSelectionTheme', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* iOS colours its selection drag handles from the caret colour. With
|
||||
* `drawSelection()`'s `caret-color: transparent !important` in effect the
|
||||
* handles are drawn — invisibly. The native caret must come back with
|
||||
* A platform showing native handles colours them from the caret colour.
|
||||
* With `drawSelection()`'s `caret-color: transparent !important` in effect
|
||||
* the handles are drawn — invisibly. The native caret must come back with
|
||||
* enough weight to win, and the drawn cursor layer must go so there are
|
||||
* not two carets.
|
||||
*
|
||||
@@ -195,3 +201,106 @@ describe('composerNativeSelectionTheme', () => {
|
||||
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerIOSSelectionExtension', () => {
|
||||
const layerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller > .cm-selectionLayer'];
|
||||
const scrollerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller'];
|
||||
const selectionBackgroundRule = IOS_SELECTION_THEME_SPEC['& .cm-selectionBackground'];
|
||||
|
||||
test('it compiles and can be installed', () => {
|
||||
expect(installationError(composerIOSSelectionExtension)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* CodeMirror renders its selection layer at `z-index: -1`, behind the
|
||||
* text. Inline code and code fences have opaque backgrounds and otherwise
|
||||
* cover both the selection and the iOS handles. The base value is inline,
|
||||
* so raising it without `!important` silently does nothing.
|
||||
*/
|
||||
test('CodeMirror selection and handles are raised above token backgrounds', () => {
|
||||
expect(layerRule.zIndex).toBe('100 !important');
|
||||
});
|
||||
|
||||
/**
|
||||
* The layer now sits over the content and would intercept taps and drags
|
||||
* by default. It only paints; CodeMirror/WebKit still own the gestures.
|
||||
*/
|
||||
test('the layer does not intercept touch', () => {
|
||||
expect(layerRule.pointerEvents).toBe('none');
|
||||
});
|
||||
|
||||
/**
|
||||
* A higher z-index cannot escape overflow clipping. CodeMirror's dots
|
||||
* extend 8px past the range, so the scroller needs that much internal room;
|
||||
* the matching negative margin keeps the text and composer height fixed.
|
||||
*/
|
||||
test('the scroller reserves unclipped room for both handles', () => {
|
||||
expect(scrollerRule.paddingBlock).toBe('8px');
|
||||
expect(scrollerRule.marginBlock).toBe('-8px');
|
||||
});
|
||||
|
||||
test('the CodeMirror fill does not stack over the iOS system highlight', () => {
|
||||
expect(selectionBackgroundRule.background).toBe('transparent !important');
|
||||
});
|
||||
|
||||
/**
|
||||
* A second custom layer was visually indistinguishable from duplicate
|
||||
* native selection UI. iOS must only reposition the one layer that
|
||||
* CodeMirror already uses for both selection rectangles and handles.
|
||||
*/
|
||||
test('it does not add a second selection implementation', () => {
|
||||
expect(Object.keys(IOS_SELECTION_THEME_SPEC)).toEqual([
|
||||
'& .cm-scroller',
|
||||
'& .cm-scroller > .cm-selectionLayer',
|
||||
'& .cm-selectionBackground',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerSelectionExtension', () => {
|
||||
/**
|
||||
* The split is the point: iOS is the only platform that pays for a visible
|
||||
* native selection during composition, and CodeMirror 6.43.9 draws its
|
||||
* handles. Collapsing the two branches into one would
|
||||
* either restore the latency on iOS or leave every other platform without
|
||||
* discoverable range selection.
|
||||
*/
|
||||
test('the CodeMirror iOS path uses its handles; other platforms keep native selection', () => {
|
||||
expect(composerSelectionExtension(true)).toBe(composerIOSSelectionExtension);
|
||||
expect(composerSelectionExtension(false)).toBe(composerNativeSelectionExtension);
|
||||
});
|
||||
|
||||
/**
|
||||
* The composer may remove the native fallback only when CodeMirror's own
|
||||
* browser predicate enables its replacement handles. This deliberately
|
||||
* includes CodeMirror's vendor and touch thresholds rather than using a
|
||||
* broader application-level iOS heuristic.
|
||||
*/
|
||||
test('the platform predicate matches CodeMirror 6.43.9', () => {
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6) Mobile/15E148 Safari/604.1',
|
||||
'Apple Computer, Inc.',
|
||||
5,
|
||||
)).toBe(true);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
'Apple Computer, Inc.',
|
||||
5,
|
||||
)).toBe(true);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
'Google Inc.',
|
||||
5,
|
||||
)).toBe(false);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
'Apple Computer, Inc.',
|
||||
0,
|
||||
)).toBe(false);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0)',
|
||||
'Apple Computer, Inc.',
|
||||
5,
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const composerEditorSource = readFileSync(
|
||||
new URL('../ComposerEditor.tsx', import.meta.url),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const writebackEffect = (): string => {
|
||||
const start = composerEditorSource.indexOf('// Controlled value:');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = composerEditorSource.indexOf('}, [value]);', start);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return composerEditorSource.slice(start, end);
|
||||
};
|
||||
|
||||
describe('composer value writeback composition guard (issue #2527)', () => {
|
||||
test('checks equality, then composition, before dispatching', () => {
|
||||
const effect = writebackEffect();
|
||||
const equalityCheck = effect.indexOf('if (current === value) return;');
|
||||
const compositionGuard = effect.indexOf('if (view.compositionStarted) return;');
|
||||
const dispatch = effect.indexOf('view.dispatch({');
|
||||
|
||||
expect(equalityCheck).toBeGreaterThan(-1);
|
||||
expect(compositionGuard).toBeGreaterThan(equalityCheck);
|
||||
expect(dispatch).toBeGreaterThan(compositionGuard);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
* language layer emits, so the composer and the message list stay in step.
|
||||
*/
|
||||
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
@@ -78,23 +79,16 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
|
||||
'&.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
||||
background: 'color-mix(in srgb, var(--interactive-selection) 55%, transparent)',
|
||||
},
|
||||
// The native selection still shows through in places CodeMirror does not
|
||||
// draw over, such as the placeholder. Same colour as the native-selection
|
||||
// theme below, for the same reason: the selection token carries its own
|
||||
// alpha and reads as nearly invisible when mixed down again.
|
||||
'& ::selection': {
|
||||
background: 'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
},
|
||||
};
|
||||
|
||||
export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Every device keeps `drawSelection()` but shows the NATIVE selection through
|
||||
* it, for two independent reasons:
|
||||
* Outside CodeMirror's iOS branch, devices keep `drawSelection()` but show the
|
||||
* NATIVE selection through it, for two independent reasons:
|
||||
*
|
||||
* - iOS attaches its selection handles (the draggable pins after a
|
||||
* double-tap) to the *visible* native selection, and `drawSelection()`
|
||||
* - Their selection drag handles (the draggable pins after a double-tap)
|
||||
* attach to the *visible* native selection, and `drawSelection()`
|
||||
* hides it with `.cm-line ::selection { background: transparent
|
||||
* !important }`, so the handles never appear and range selection is
|
||||
* undiscoverable.
|
||||
@@ -103,12 +97,15 @@ export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
|
||||
* the selection is invisible inside those spans. The native selection
|
||||
* paints over element backgrounds.
|
||||
*
|
||||
* Dropping `drawSelection()` entirely is NOT an option: without it CodeMirror
|
||||
* clears the `nativeSelectionHidden` facet and starts enforcing cursor
|
||||
* association on the native selection while typing in wrapped text —
|
||||
* programmatic selection moves that iOS answers with severe input lag (each
|
||||
* one also resets the keyboard's autocorrect context). Typing must stay on
|
||||
* the drawn-selection code path; only the paint changes.
|
||||
* Dropping `drawSelection()` entirely is NOT an option, on any platform:
|
||||
* without it CodeMirror clears the `nativeSelectionHidden` facet and starts
|
||||
* enforcing cursor association on the native selection while typing in
|
||||
* wrapped text — programmatic selection moves that iOS answers with severe
|
||||
* input lag (each one also resets the keyboard's autocorrect context). Typing
|
||||
* must stay on the drawn-selection code path; only the paint changes.
|
||||
*
|
||||
* CodeMirror's iOS branch does NOT use this arrangement —
|
||||
* `composerIOSSelectionExtension` below explains why.
|
||||
*
|
||||
* Both rules below fight `drawSelection()`'s own `Prec.highest` theme, so
|
||||
* they carry `!important` and one class more specificity
|
||||
@@ -155,14 +152,103 @@ export const NATIVE_SELECTION_THEME_SPEC = {
|
||||
const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* The native-selection arrangement, installed on every device: the theme
|
||||
* above plus the `.oc-native-range` marker class that scopes its caret rules
|
||||
* to the moments a range is actually selected. `editorAttributes`
|
||||
* The native-selection arrangement, installed outside CodeMirror's iOS branch:
|
||||
* the theme above plus the `.oc-native-range` marker class that scopes its
|
||||
* caret rules to the moments a range is actually selected. `editorAttributes`
|
||||
* re-evaluates on every update, so the class follows the selection with no
|
||||
* listener of its own.
|
||||
*/
|
||||
export const composerNativeSelectionExtension = [
|
||||
export const composerNativeSelectionExtension: Extension = [
|
||||
composerNativeSelectionTheme,
|
||||
EditorView.editorAttributes.of((view) =>
|
||||
view.state.selection.main.empty ? null : { class: 'oc-native-range' }),
|
||||
];
|
||||
|
||||
/**
|
||||
* When its iOS predicate matches, CodeMirror 6.43.9 draws the range handles
|
||||
* into the same layer as the selection, so CodeMirror owns both their geometry
|
||||
* and appearance.
|
||||
*
|
||||
* That layer normally renders at `z-index: -1`, behind the content. Inline
|
||||
* code and code fences have opaque backgrounds and would cover both the tint
|
||||
* and handles. Raising the one existing layer fixes that without introducing
|
||||
* a second set of rectangles or trying to imitate WebKit's controls. The
|
||||
* layer remains transparent to touch so WebKit receives selection gestures.
|
||||
*
|
||||
* What iOS avoids is the native-selection workaround above: explicitly
|
||||
* restoring the native highlight and caret makes WebKit re-measure and repaint
|
||||
* that UI after every decoration redraw. `composerLanguage.ts` rebuilds the
|
||||
* whole decoration set on every keystroke, so the cost is felt worst during
|
||||
* IME composition where each intermediate replacement pays for it. WebKit's
|
||||
* unavoidable system selection overlay remains the only visible fill.
|
||||
*/
|
||||
export const IOS_SELECTION_THEME_SPEC = {
|
||||
// The handles extend 8px above/below their range. The scroller clips them
|
||||
// at its own edge even when the layer has a high z-index, so reserve that
|
||||
// room inside the clipping box and pull the box outward by the same amount.
|
||||
// Text and composer height stay where they were; only the clip area grows.
|
||||
'& .cm-scroller': {
|
||||
marginBlock: '-8px',
|
||||
paddingBlock: '8px',
|
||||
},
|
||||
'& .cm-scroller > .cm-selectionLayer': {
|
||||
// CodeMirror writes `z-index: -1` inline. `!important` is intentional:
|
||||
// without it token backgrounds cover the selection and its handles.
|
||||
zIndex: '100 !important',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// iOS keeps showing its taller system selection overlay even when
|
||||
// ::selection is transparent. Painting CodeMirror's themed rectangles as
|
||||
// well produces two visibly misaligned fills, so only the synthetic
|
||||
// background is suppressed. The handles in this layer remain visible.
|
||||
'& .cm-selectionBackground': {
|
||||
background: 'transparent !important',
|
||||
},
|
||||
};
|
||||
|
||||
export const composerIOSSelectionExtension: Extension =
|
||||
EditorView.theme(IOS_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Which selection paint the composer installs. The split is the platform's,
|
||||
* not a preference: iOS is the one place where restoring native selection
|
||||
* paint and caret costs measurable input latency, and the only place
|
||||
* CodeMirror supplies replacement drag handles.
|
||||
*
|
||||
* The caller can pass the policy, so the choice stays testable and is made
|
||||
* once per editor rather than once per module load.
|
||||
*/
|
||||
export function composerSelectionExtension(
|
||||
useCodeMirrorIOSHandles: boolean = usesCodeMirrorIOSSelectionHandles(),
|
||||
): Extension {
|
||||
return useCodeMirrorIOSHandles
|
||||
? composerIOSSelectionExtension
|
||||
: composerNativeSelectionExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors @codemirror/view 6.43.9's iOS predicate. This branch may only rely
|
||||
* on the drawn handles when CodeMirror itself will create them; a broader iOS
|
||||
* heuristic could remove the native fallback without installing a replacement.
|
||||
*/
|
||||
export function isCodeMirrorIOSNavigator(
|
||||
userAgent: string,
|
||||
vendor: string,
|
||||
maxTouchPoints: number,
|
||||
): boolean {
|
||||
const isIE = /Edge\/(\d+)/.test(userAgent)
|
||||
|| /MSIE \d/.test(userAgent)
|
||||
|| /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.test(userAgent);
|
||||
if (isIE || !/Apple Computer/.test(vendor)) return false;
|
||||
return /Mobile\/\w+/.test(userAgent) || maxTouchPoints > 2;
|
||||
}
|
||||
|
||||
function usesCodeMirrorIOSSelectionHandles(): boolean {
|
||||
const nav = globalThis.navigator;
|
||||
if (!nav) return false;
|
||||
return isCodeMirrorIOSNavigator(
|
||||
nav.userAgent || '',
|
||||
nav.vendor || '',
|
||||
nav.maxTouchPoints ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ const ICONS = {
|
||||
zoomOut: spriteIcon('subtract'),
|
||||
fit: spriteIcon('refresh'),
|
||||
textWrap: spriteIcon('text-wrap'),
|
||||
image: spriteIcon('file-image'),
|
||||
} as const;
|
||||
|
||||
const ICON_BTN_CLASS =
|
||||
@@ -66,6 +67,18 @@ const setIconHtml = (el: Element, html: string): void => {
|
||||
el.innerHTML = html;
|
||||
};
|
||||
|
||||
const decorateImageLabels = (root: HTMLElement): void => {
|
||||
for (const label of Array.from(root.querySelectorAll<HTMLElement>('[data-openchamber-markdown-image-label="true"]'))) {
|
||||
if (label.querySelector('[data-openchamber-markdown-image-label-icon]')) continue;
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'inline-flex shrink-0';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
|
||||
setIconHtml(icon, ICONS.image);
|
||||
label.prepend(icon);
|
||||
}
|
||||
};
|
||||
|
||||
const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string): HTMLButtonElement => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
@@ -487,6 +500,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
|
||||
/** Run all idempotent DOM decoration passes over freshly-rendered markdown. */
|
||||
export const decorateMarkdown = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
decorateImageLabels(root);
|
||||
decorateInlineCode(root);
|
||||
decorateMermaid(root, ctx);
|
||||
decorateCodeBlocks(root, ctx);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Bounded LRU for rendered markdown / Shiki highlight results.
|
||||
//
|
||||
// Used by `markdownCore` (per-block HTML) and by the main-thread markdown
|
||||
// worker client (highlight results) so unchanged content is never re-rendered
|
||||
// or re-tokenized. Keys are short content fingerprints (not the full source) so
|
||||
// cache maps do not duplicate large strings. Entry byte sizes are recorded once
|
||||
// at insert time — get/evict never re-walk the payload.
|
||||
|
||||
export type HighlightResultCacheOptions = {
|
||||
maxEntries: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
type CacheEntry<T> = {
|
||||
value: T;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
/** UTF-16 storage estimate for a JS string (chars × 2). Avoids TextEncoder allocs. */
|
||||
export const utf16Bytes = (value: string): number => value.length * 2;
|
||||
|
||||
/** Final avalanche so near-identical sources do not land in adjacent buckets. */
|
||||
const mix32 = (hash: number): number => {
|
||||
let h = hash;
|
||||
h ^= h >>> 16;
|
||||
h = Math.imul(h, 0x85ebca6b);
|
||||
h ^= h >>> 13;
|
||||
return h >>> 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Short stable fingerprint for cache keys: length + two independent 32-bit
|
||||
* multiplicative hashes (~64 bits of key space).
|
||||
*
|
||||
* These caches are content-addressed and global, so a collision does not merely
|
||||
* mis-color a block — the cache returns a *different* block's rendered HTML and
|
||||
* the user is shown source they never wrote. One 32-bit hash is not enough for
|
||||
* that failure mode: a few thousand same-length entries reach a birthday
|
||||
* collision probability worth caring about, and the result would be
|
||||
* undiagnosable in the field. Two multiplies per character are free next to
|
||||
* Shiki tokenization.
|
||||
*/
|
||||
export const contentFingerprint = (value: string): string => {
|
||||
let h1 = 0x811c9dc5;
|
||||
let h2 = 0xc2b2ae35;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const code = value.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ code, 0x01000193);
|
||||
h2 = Math.imul(h2 ^ code, 0x27220a95);
|
||||
}
|
||||
return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`;
|
||||
};
|
||||
|
||||
/** Approximate byte cost of token-run lines without JSON.stringify. */
|
||||
export const estimateTokenRunsBytes = (
|
||||
lines: ReadonlyArray<ReadonlyArray<readonly [number, string, number]>>,
|
||||
): number => {
|
||||
let total = 0;
|
||||
for (const line of lines) {
|
||||
total += 4;
|
||||
for (const run of line) {
|
||||
total += 8 + utf16Bytes(run[1]);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
export class HighlightResultCache<T> {
|
||||
private readonly maxEntries: number;
|
||||
private readonly maxBytes: number;
|
||||
private readonly map = new Map<string, CacheEntry<T>>();
|
||||
private totalBytes = 0;
|
||||
|
||||
constructor(options: HighlightResultCacheOptions) {
|
||||
this.maxEntries = Math.max(1, options.maxEntries);
|
||||
this.maxBytes = Math.max(1, options.maxBytes);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
get bytes(): number {
|
||||
return this.totalBytes;
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
const entry = this.map.get(key);
|
||||
if (entry === undefined) return undefined;
|
||||
// Refresh LRU order without recomputing size.
|
||||
this.map.delete(key);
|
||||
this.map.set(key, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: T, bytes: number): void {
|
||||
const existing = this.map.get(key);
|
||||
if (existing !== undefined) {
|
||||
this.totalBytes -= existing.bytes;
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
const entryBytes = Math.max(0, bytes);
|
||||
while (
|
||||
this.map.size > 0
|
||||
&& (this.map.size >= this.maxEntries || this.totalBytes + entryBytes > this.maxBytes)
|
||||
) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
const oldestEntry = this.map.get(oldest);
|
||||
if (oldestEntry !== undefined) this.totalBytes -= oldestEntry.bytes;
|
||||
this.map.delete(oldest);
|
||||
// Always allow a single oversized entry so huge files still cache once.
|
||||
if (this.map.size === 0) break;
|
||||
}
|
||||
|
||||
this.map.set(key, { value, bytes: entryBytes });
|
||||
this.totalBytes += entryBytes;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
this.totalBytes = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
|
||||
import {
|
||||
contentFingerprint,
|
||||
estimateTokenRunsBytes,
|
||||
HighlightResultCache,
|
||||
utf16Bytes,
|
||||
} from './highlightResultCache';
|
||||
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
||||
|
||||
// Main-thread client for the markdown Shiki worker. Moves syntax tokenization
|
||||
@@ -6,9 +12,39 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse }
|
||||
// ready-to-splice Shiki HTML. On any failure (no worker support, worker crash,
|
||||
// tokenization error) the promise resolves to `null` and the caller keeps the
|
||||
// escaped plain-text code — highlighting never falls back onto the main thread.
|
||||
//
|
||||
// Results are memoized by content fingerprint (+ lang / theme). Unchanged
|
||||
// content must not re-enter the worker — that was the sustained ~40 msg/s
|
||||
// re-highlight load in openchamber/openchamber#2769. In-flight requests with
|
||||
// the same key coalesce so remount storms share one round-trip. Cache keys are
|
||||
// fingerprints (not full source) so large files are not duplicated in the Map.
|
||||
//
|
||||
// This module is the only sender to the worker, so memoizing here is sufficient
|
||||
// and the worker itself stays stateless apart from the Shiki instance. A second
|
||||
// cache inside the worker would only duplicate these payloads in another heap.
|
||||
//
|
||||
// `highlight` / `highlightLines` results are theme-independent: the worker
|
||||
// tokenizes with the CSS-variable `MARKDOWN_SHIKI_THEME`, so a theme switch
|
||||
// repaints via CSS and must not invalidate these entries. Only
|
||||
// `highlightTokens` resolves concrete colors, so only its key carries a theme.
|
||||
|
||||
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
|
||||
|
||||
type CachedHighlight =
|
||||
| { type: 'highlight'; html: string }
|
||||
| { type: 'highlightLines'; lines: string[] }
|
||||
| { type: 'highlightTokens'; lines: MarkdownTokenRun[][] };
|
||||
|
||||
const CLIENT_CACHE_MAX_ENTRIES = 2000;
|
||||
const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
||||
|
||||
const resultCache = new HighlightResultCache<CachedHighlight>({
|
||||
maxEntries: CLIENT_CACHE_MAX_ENTRIES,
|
||||
maxBytes: CLIENT_CACHE_MAX_BYTES,
|
||||
});
|
||||
|
||||
const inflight = new Map<string, Promise<CachedHighlight | null>>();
|
||||
|
||||
let worker: Worker | undefined;
|
||||
let nextId = 0;
|
||||
const pending = new Map<number, PendingResolver>();
|
||||
@@ -16,10 +52,23 @@ const pending = new Map<number, PendingResolver>();
|
||||
// repeat tokenization sends only the name (not the whole theme object) again.
|
||||
const sentThemes = new Set<string>();
|
||||
|
||||
const entryBytes = (key: string, value: CachedHighlight): number => {
|
||||
const keyBytes = utf16Bytes(key);
|
||||
if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html);
|
||||
if (value.type === 'highlightLines') {
|
||||
let total = keyBytes;
|
||||
for (const line of value.lines) total += utf16Bytes(line);
|
||||
return total;
|
||||
}
|
||||
return keyBytes + estimateTokenRunsBytes(value.lines);
|
||||
};
|
||||
|
||||
const failAll = (): void => {
|
||||
pending.forEach((resolve) => resolve(null));
|
||||
pending.clear();
|
||||
sentThemes.clear();
|
||||
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
|
||||
inflight.clear();
|
||||
worker?.terminate();
|
||||
worker = undefined;
|
||||
};
|
||||
@@ -55,13 +104,47 @@ const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<Markdo
|
||||
});
|
||||
};
|
||||
|
||||
const coalesce = (
|
||||
key: string,
|
||||
run: () => Promise<CachedHighlight | null>,
|
||||
): Promise<CachedHighlight | null> => {
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
const pendingRequest = run().finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, pendingRequest);
|
||||
return pendingRequest;
|
||||
};
|
||||
|
||||
const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => {
|
||||
const fp = contentFingerprint(code);
|
||||
return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`;
|
||||
};
|
||||
|
||||
/** Test-only: clear client-side highlight memoization. */
|
||||
export const resetMarkdownWorkerClientCacheForTests = (): void => {
|
||||
resultCache.clear();
|
||||
inflight.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight a complete code block in the worker. Resolves to Shiki `<pre>` HTML,
|
||||
* or `null` if highlighting is unavailable or failed (caller keeps plain code).
|
||||
*/
|
||||
export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
|
||||
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
|
||||
return response?.type === 'highlight' ? response.html : null;
|
||||
const key = cacheKeyFor('highlight', lang, code);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlight') return cached.html;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
|
||||
if (response?.type !== 'highlight') return null;
|
||||
const entry: CachedHighlight = { type: 'highlight', html: response.html };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
return result?.type === 'highlight' ? result.html : null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,8 +153,18 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise
|
||||
* round-trip instead of one per line. Resolves to `null` on failure.
|
||||
*/
|
||||
export const highlightLinesInWorker = async (code: string, lang: string): Promise<string[] | null> => {
|
||||
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
||||
return response?.type === 'highlightLines' ? response.lines : null;
|
||||
const key = cacheKeyFor('highlightLines', lang, code);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlightLines') return cached.lines;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
||||
if (response?.type !== 'highlightLines') return null;
|
||||
const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
return result?.type === 'highlightLines' ? result.lines : null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -86,18 +179,25 @@ export const highlightTokensInWorker = async (
|
||||
themeName: string,
|
||||
theme: unknown,
|
||||
): Promise<MarkdownTokenRun[][] | null> => {
|
||||
const needsTheme = !sentThemes.has(themeName);
|
||||
const response = await request((id) => ({
|
||||
type: 'highlightTokens',
|
||||
id,
|
||||
code,
|
||||
lang,
|
||||
themeName,
|
||||
...(needsTheme ? { theme } : {}),
|
||||
}));
|
||||
if (response?.type === 'highlightTokens') {
|
||||
const key = cacheKeyFor('highlightTokens', lang, code, themeName);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlightTokens') return cached.lines;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
const needsTheme = !sentThemes.has(themeName);
|
||||
const response = await request((id) => ({
|
||||
type: 'highlightTokens',
|
||||
id,
|
||||
code,
|
||||
lang,
|
||||
themeName,
|
||||
...(needsTheme ? { theme } : {}),
|
||||
}));
|
||||
if (response?.type !== 'highlightTokens') return null;
|
||||
sentThemes.add(themeName);
|
||||
return response.lines;
|
||||
}
|
||||
return null;
|
||||
const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
return result?.type === 'highlightTokens' ? result.lines : null;
|
||||
};
|
||||
|
||||
@@ -13,7 +13,11 @@ mock.module('./markdown-worker', () => ({
|
||||
|
||||
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
|
||||
const { extractMarkdownImageCandidates, renderMarkdownSync } = await import('./markdownCore');
|
||||
const {
|
||||
__markdownImageCandidateCacheForTests,
|
||||
extractMarkdownImageCandidates,
|
||||
renderMarkdownSync,
|
||||
} = await import('./markdownCore');
|
||||
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
|
||||
|
||||
describe('markdown sanitization', () => {
|
||||
@@ -39,45 +43,31 @@ describe('markdown sanitization', () => {
|
||||
});
|
||||
|
||||
describe('Markdown images', () => {
|
||||
test('keeps local image links in text and emits inert image placeholders', () => {
|
||||
test('renders assistant images as icon-ready text without loading the source', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[linked image](packages/vscode/extension.jpg)',
|
||||
'',
|
||||
].join('\n\n'), true);
|
||||
].join('\n\n'), 'label');
|
||||
|
||||
expect(html).toContain('data-openchamber-markdown-image-link="true"');
|
||||
expect(html.match(/data-openchamber-markdown-image-source="packages\/vscode\/extension.jpg"/g)).toHaveLength(1);
|
||||
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
|
||||
expect(html).toContain('image syntax');
|
||||
expect(html).not.toContain('src="packages/vscode/extension.jpg"');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image-state');
|
||||
expect(html).toContain('data-openchamber-markdown-image-label="true"');
|
||||
expect(html).toContain('extension.jpg');
|
||||
expect(html).not.toContain('image syntax');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html.match(/<a /g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps HTTP links as links and defers remote image tokens to finalized rendering', () => {
|
||||
test('keeps non-chat Markdown images inline', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[remote link](https://example.test/image.png)',
|
||||
'',
|
||||
].join('\n\n'), true);
|
||||
].join('\n\n'));
|
||||
|
||||
expect(html).toContain('<a href="https://example.test/image.png"');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
|
||||
expect(html).toContain('remote image');
|
||||
expect(html).toContain('<img src="https://example.test/image.png" alt="remote image">');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image-label');
|
||||
});
|
||||
|
||||
test('preserves file URLs inertly and never activates unknown schemes', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'',
|
||||
')',
|
||||
].join('\n\n'), true);
|
||||
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).not.toContain('src="file:');
|
||||
expect(html).not.toContain('src="javascript:');
|
||||
});
|
||||
|
||||
test('collects a single ordered gallery across mixed Markdown and ignores code', () => {
|
||||
test('collects image syntax across mixed Markdown and ignores links and code', () => {
|
||||
const candidates = extractMarkdownImageCandidates([
|
||||
[
|
||||
'Before [local link](screens/first%20view.png) and ``.',
|
||||
@@ -99,6 +89,10 @@ describe('Markdown images', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not add an ordinary local image link to the gallery', () => {
|
||||
expect(extractMarkdownImageCandidates(['[download](screens/image.png)'])).toEqual([]);
|
||||
});
|
||||
|
||||
test('limits one finalized message gallery to twelve unique candidates', () => {
|
||||
const markdown = Array.from({ length: 14 }, (_, index) => ``).join('\n');
|
||||
|
||||
@@ -108,12 +102,69 @@ describe('Markdown images', () => {
|
||||
expect(candidates.at(-1)?.source).toBe('screens/11.png');
|
||||
});
|
||||
|
||||
test('reuses extracted candidates across virtualized remounts without changing gallery behavior', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const contents = Array.from({ length: 20 }, (_, index) => ``);
|
||||
|
||||
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
|
||||
expect(__markdownImageCandidateCacheForTests.stats().scans).toBe(12);
|
||||
|
||||
for (let round = 0; round < 1000; round += 1) {
|
||||
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
|
||||
}
|
||||
|
||||
const stats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(stats.entries).toBe(12);
|
||||
expect(stats.scans).toBe(12);
|
||||
});
|
||||
|
||||
test('scans one thousand independent messages once across virtualized remounts', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const messages = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, index) => ``,
|
||||
);
|
||||
|
||||
for (const message of messages) extractMarkdownImageCandidates([message]);
|
||||
for (const message of messages) extractMarkdownImageCandidates([message]);
|
||||
|
||||
const stats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(stats.entries).toBe(1000);
|
||||
expect(stats.scans).toBe(1000);
|
||||
});
|
||||
|
||||
test('gives embedded images without alt text a stable filename', () => {
|
||||
const source = 'data:image/png;base64,AAAA';
|
||||
|
||||
expect(extractMarkdownImageCandidates([``])).toEqual([
|
||||
{ source, filename: 'image.png' },
|
||||
]);
|
||||
expect(renderMarkdownSync(``, 'label')).toContain('image.png');
|
||||
});
|
||||
|
||||
test('bounds cached candidate entries and bytes, and skips oversized individual content', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
for (let index = 0; index < 1025; index += 1) {
|
||||
extractMarkdownImageCandidates([``]);
|
||||
}
|
||||
const boundedStats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(boundedStats.entries).toBe(1024);
|
||||
expect(boundedStats.bytes <= 2 * 1024 * 1024).toBe(true);
|
||||
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const oversized = `\n${'x'.repeat(64 * 1024)}`;
|
||||
|
||||
extractMarkdownImageCandidates([oversized]);
|
||||
extractMarkdownImageCandidates([oversized]);
|
||||
expect(__markdownImageCandidateCacheForTests.stats()).toEqual({ entries: 0, bytes: 0, scans: 2 });
|
||||
});
|
||||
|
||||
test('validates embedded image bytes against the declared MIME type', async () => {
|
||||
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, '', signal)).toBe(`data:image/png;base64,${png}`);
|
||||
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, '', signal).then(
|
||||
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, signal)).toBe(`data:image/png;base64,${png}`);
|
||||
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, signal).then(
|
||||
() => { throw new Error('Expected mismatched image data to fail'); },
|
||||
(error: unknown) => expect((error as Error).message).toBe('Unsupported image data'),
|
||||
);
|
||||
@@ -123,7 +174,7 @@ describe('Markdown images', () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await resolveMarkdownImageSource('https://example.test/image.png', '', controller.signal).then(
|
||||
await resolveMarkdownImageSource('https://example.test/image.png', controller.signal).then(
|
||||
() => { throw new Error('Expected an aborted image load to fail'); },
|
||||
(error: unknown) => expect((error as Error).name).toBe('AbortError'),
|
||||
);
|
||||
@@ -133,6 +184,6 @@ describe('Markdown images', () => {
|
||||
const html = renderMarkdownSync('');
|
||||
|
||||
expect(html).toContain('<img src="https://example.test/image.png"');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image-placeholder');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
|
||||
import { highlightCodeInWorker } from './markdown-worker';
|
||||
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
|
||||
@@ -19,8 +20,23 @@ export interface MarkdownImageCandidate {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export type MarkdownImageMode = 'inline' | 'label';
|
||||
|
||||
export const MAX_MARKDOWN_IMAGE_COUNT = 12;
|
||||
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES = 1024;
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES = 64 * 1024;
|
||||
|
||||
type MarkdownImageCandidateCacheEntry = {
|
||||
candidates: MarkdownImageCandidate[];
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
const markdownImageCandidateCache = new Map<string, MarkdownImageCandidateCacheEntry>();
|
||||
let markdownImageCandidateCacheBytes = 0;
|
||||
let markdownImageCandidateScanCount = 0;
|
||||
|
||||
const isLocalMarkdownImageSource = (source: string): boolean => {
|
||||
if (/^\/\//.test(source) || !LOCAL_IMAGE_EXTENSION_RE.test(source)) return false;
|
||||
return WINDOWS_ABSOLUTE_PATH_RE.test(source)
|
||||
@@ -34,8 +50,11 @@ const isSupportedMarkdownImageSource = (source: string): boolean => (
|
||||
|| isLocalMarkdownImageSource(source)
|
||||
);
|
||||
|
||||
export const getMarkdownImageFilename = (source: string, fallback: string): string => {
|
||||
if (/^data:/i.test(source)) return fallback.trim();
|
||||
const getMarkdownImageFilename = (source: string, fallback: string): string => {
|
||||
if (/^data:image\/(png|jpeg|gif|webp)/i.test(source)) {
|
||||
const extension = /^data:image\/([^;,]+)/i.exec(source)?.[1]?.replace('jpeg', 'jpg') ?? 'png';
|
||||
return fallback.trim() || `image.${extension}`;
|
||||
}
|
||||
|
||||
const path = source.split(/[?#]/, 1)[0]?.replace(/\\/g, '/') ?? '';
|
||||
const encodedName = path.split('/').filter(Boolean).at(-1) ?? '';
|
||||
@@ -47,6 +66,87 @@ export const getMarkdownImageFilename = (source: string, fallback: string): stri
|
||||
}
|
||||
};
|
||||
|
||||
const estimateMarkdownImageCandidateCacheEntryBytes = (
|
||||
markdown: string,
|
||||
candidates: readonly MarkdownImageCandidate[],
|
||||
): number => (
|
||||
(markdown.length + candidates.reduce((total, candidate) => total + candidate.source.length + candidate.filename.length, 0)) * 2
|
||||
);
|
||||
|
||||
const scanMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
|
||||
markdownImageCandidateScanCount += 1;
|
||||
const candidates: MarkdownImageCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
const tokens = marked.lexer(markdown);
|
||||
marked.walkTokens(tokens, (token) => {
|
||||
if (token.type !== 'image') return;
|
||||
|
||||
const source = token.href ?? '';
|
||||
if (!source || !isSupportedMarkdownImageSource(source) || seen.has(source)) return;
|
||||
const fallback = typeof token.text === 'string' ? token.text : '';
|
||||
const filename = getMarkdownImageFilename(source, fallback);
|
||||
if (!filename) return;
|
||||
|
||||
seen.add(source);
|
||||
candidates.push({ source, filename });
|
||||
});
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const getMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
|
||||
const cached = markdownImageCandidateCache.get(markdown);
|
||||
if (cached) {
|
||||
markdownImageCandidateCache.delete(markdown);
|
||||
markdownImageCandidateCache.set(markdown, cached);
|
||||
return cached.candidates;
|
||||
}
|
||||
|
||||
const candidates = scanMarkdownImageCandidates(markdown);
|
||||
const bytes = estimateMarkdownImageCandidateCacheEntryBytes(markdown, candidates);
|
||||
if (bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES) return candidates;
|
||||
|
||||
while (
|
||||
markdownImageCandidateCache.size >= MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES
|
||||
|| markdownImageCandidateCacheBytes + bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES
|
||||
) {
|
||||
const oldest = markdownImageCandidateCache.entries().next().value;
|
||||
if (!oldest) break;
|
||||
markdownImageCandidateCache.delete(oldest[0]);
|
||||
markdownImageCandidateCacheBytes -= oldest[1].bytes;
|
||||
}
|
||||
markdownImageCandidateCache.set(markdown, { candidates, bytes });
|
||||
markdownImageCandidateCacheBytes += bytes;
|
||||
return candidates;
|
||||
};
|
||||
|
||||
/** @internal Test-only cache instrumentation for deterministic regression tests. */
|
||||
export const __markdownImageCandidateCacheForTests = {
|
||||
reset: (): void => {
|
||||
markdownImageCandidateCache.clear();
|
||||
markdownImageCandidateCacheBytes = 0;
|
||||
markdownImageCandidateScanCount = 0;
|
||||
},
|
||||
stats: () => ({
|
||||
entries: markdownImageCandidateCache.size,
|
||||
bytes: markdownImageCandidateCacheBytes,
|
||||
scans: markdownImageCandidateScanCount,
|
||||
}),
|
||||
};
|
||||
|
||||
const renderMarkdownImageLabel = ({
|
||||
href,
|
||||
title,
|
||||
text,
|
||||
}: {
|
||||
href: string;
|
||||
title?: string | null;
|
||||
text: string;
|
||||
}): string => {
|
||||
const label = getMarkdownImageFilename(href ?? '', text);
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
return `<span${titleAttr} class="inline-flex items-center gap-1 align-text-bottom text-muted-foreground" data-openchamber-markdown-image-label="true">${escapeAttr(label)}</span>`;
|
||||
};
|
||||
|
||||
export const extractMarkdownImageCandidates = (
|
||||
markdownTexts: readonly string[],
|
||||
limit = MAX_MARKDOWN_IMAGE_COUNT,
|
||||
@@ -58,47 +158,17 @@ export const extractMarkdownImageCandidates = (
|
||||
|
||||
for (const markdown of markdownTexts) {
|
||||
if (!markdown || candidates.length >= limit) continue;
|
||||
const tokens = marked.lexer(markdown);
|
||||
marked.walkTokens(tokens, (token) => {
|
||||
if (candidates.length >= limit) return;
|
||||
|
||||
if (token.type !== 'image' && token.type !== 'link') return;
|
||||
if (token.type === 'link' && !isLocalMarkdownImageSource(token.href ?? '')) return;
|
||||
|
||||
const source = token.href ?? '';
|
||||
if (!source || !isSupportedMarkdownImageSource(source) || seen.has(source)) return;
|
||||
const fallback = typeof token.text === 'string' ? token.text : '';
|
||||
const filename = getMarkdownImageFilename(source, fallback);
|
||||
if (!filename) return;
|
||||
|
||||
seen.add(source);
|
||||
candidates.push({ source, filename });
|
||||
});
|
||||
for (const candidate of getMarkdownImageCandidates(markdown)) {
|
||||
if (candidates.length >= limit) break;
|
||||
if (seen.has(candidate.source)) continue;
|
||||
seen.add(candidate.source);
|
||||
candidates.push({ ...candidate });
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const renderMarkdownImage = ({
|
||||
href,
|
||||
title,
|
||||
text,
|
||||
}: {
|
||||
href: string;
|
||||
title?: string | null;
|
||||
text: string;
|
||||
}): string => {
|
||||
const source = href ?? '';
|
||||
const alt = escapeAttr(text ?? '');
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
const supported = isSupportedMarkdownImageSource(source);
|
||||
if (!supported) {
|
||||
return `<span role="img" aria-label="${alt}"${titleAttr}>${alt}</span>`;
|
||||
}
|
||||
|
||||
return `<span role="img" aria-label="${alt}"${titleAttr} data-openchamber-markdown-image-placeholder="true">${alt}</span>`;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming block segmentation (port of OpenCode's markdown-stream)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -251,7 +321,7 @@ const blockMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
const createParser = (deferImages: boolean) => new Marked().use({
|
||||
const createParser = (imageMode: MarkdownImageMode) => new Marked().use({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
@@ -264,11 +334,6 @@ const createParser = (deferImages: boolean) => new Marked().use({
|
||||
},
|
||||
link({ href, title, text }) {
|
||||
const target = href ?? '';
|
||||
if (deferImages && isLocalMarkdownImageSource(target)) {
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
const filename = getMarkdownImageFilename(target, '');
|
||||
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer" data-openchamber-markdown-image-link="true" data-openchamber-markdown-image-source="${escapeAttr(target)}" data-openchamber-markdown-image-filename="${escapeAttr(filename)}">${text}</a>`;
|
||||
}
|
||||
const agentName = parseAgentHref(target);
|
||||
if (agentName) {
|
||||
return `<a href="${escapeAttr(buildAgentMentionUrl(agentName))}" data-openchamber-agent-mention="true" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
@@ -280,12 +345,12 @@ const createParser = (deferImages: boolean) => new Marked().use({
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
},
|
||||
...(deferImages ? { image: renderMarkdownImage } : {}),
|
||||
...(imageMode === 'label' ? { image: renderMarkdownImageLabel } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const parser = createParser(false);
|
||||
const imageParser = createParser(true);
|
||||
const inlineImageParser = createParser('inline');
|
||||
const imageLabelParser = createParser('label');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
|
||||
@@ -351,32 +416,37 @@ const highlightCodeBlocks = async (html: string): Promise<string> => {
|
||||
|
||||
const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT;
|
||||
|
||||
let result = html;
|
||||
for (const match of matches) {
|
||||
const [full, rawLang, escapedCode] = match;
|
||||
const requested = (rawLang || 'text').toLowerCase();
|
||||
// Leave mermaid fences untouched so the decorate pass can render them as
|
||||
// diagrams (highlighting would strip the `language-mermaid` class).
|
||||
if (requested === 'mermaid') continue;
|
||||
// Highlight all eligible fences concurrently — sequential await was O(n)
|
||||
// worker round-trips for messages with multiple code blocks.
|
||||
const replacements = await Promise.all(
|
||||
matches.map(async (match) => {
|
||||
const [full, rawLang, escapedCode] = match;
|
||||
const requested = (rawLang || 'text').toLowerCase();
|
||||
// Leave mermaid fences untouched so the decorate pass can render them as
|
||||
// diagrams (highlighting would strip the `language-mermaid` class).
|
||||
if (requested === 'mermaid') return null;
|
||||
|
||||
const code = unescapeHtml(escapedCode ?? '');
|
||||
const code = unescapeHtml(escapedCode ?? '');
|
||||
|
||||
// Oversized block: skip highlight, keep plain code but stamp the language.
|
||||
if (exceedsLineLimit(code, lineLimit)) {
|
||||
result = result.replace(full, () => full.replace('<pre', `<pre data-md-lang="${requested}"`));
|
||||
continue;
|
||||
}
|
||||
// Oversized block: skip highlight, keep plain code but stamp the language.
|
||||
if (exceedsLineLimit(code, lineLimit)) {
|
||||
return { full, next: full.replace('<pre', `<pre data-md-lang="${requested}"`) };
|
||||
}
|
||||
|
||||
// Tokenize off the main thread. On failure the worker resolves to null and
|
||||
// we keep the original escaped <pre><code> (no main-thread highlight).
|
||||
const highlighted = await highlightCodeInWorker(code, requested);
|
||||
if (highlighted) {
|
||||
// Tokenize off the main thread. On failure the worker resolves to null and
|
||||
// we keep the original escaped <pre><code> (no main-thread highlight).
|
||||
const highlighted = await highlightCodeInWorker(code, requested);
|
||||
if (!highlighted) return null;
|
||||
// Stamp the language so the decorate pass can show a header label.
|
||||
const stamped = highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`);
|
||||
result = result.replace(full, () => stamped);
|
||||
}
|
||||
}
|
||||
return { full, next: highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`) };
|
||||
}),
|
||||
);
|
||||
|
||||
let result = html;
|
||||
for (const replacement of replacements) {
|
||||
if (!replacement) continue;
|
||||
result = result.replace(replacement.full, () => replacement.next);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -419,32 +489,64 @@ const sanitize = (html: string): string => {
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
|
||||
// Per-block HTML cache (content-addressed LRU)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Keyed by content hash + mode + highlight flag + image mode — NOT by renderer
|
||||
// instance id. `SimpleMarkdownRenderer` historically used a shared
|
||||
// `simple:${variant}` key, so every same-variant instance fought over one cache
|
||||
// slot and re-highlighted unchanged content on every pass
|
||||
// (openchamber/openchamber#2769). Content addressing makes identical blocks
|
||||
// share one entry and stops that thrash. Bounds are high enough for long
|
||||
// sessions; byte cap keeps memory bounded.
|
||||
//
|
||||
// `full` (settled) and `live` (trailing, still streaming) blocks get separate
|
||||
// caches. A live block's content changes on every stream step, so under one
|
||||
// shared content-addressed cache each step would insert a new entry and a long
|
||||
// streaming message would evict the settled blocks this fix exists to keep
|
||||
// warm. The live cache is small on purpose: it only has to absorb repeat
|
||||
// renders of the *same* step.
|
||||
|
||||
const CACHE_MAX = 240;
|
||||
const htmlCache = new Map<string, { hash: string; html: string }>();
|
||||
const FULL_CACHE_MAX_ENTRIES = 2000;
|
||||
const FULL_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
||||
const LIVE_CACHE_MAX_ENTRIES = 32;
|
||||
const LIVE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// FNV-1a 32-bit hash of the block content.
|
||||
const hash = (value: string): string => {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
h ^= value.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
const fullBlockCache = new HighlightResultCache<string>({
|
||||
maxEntries: FULL_CACHE_MAX_ENTRIES,
|
||||
maxBytes: FULL_CACHE_MAX_BYTES,
|
||||
});
|
||||
const liveBlockCache = new HighlightResultCache<string>({
|
||||
maxEntries: LIVE_CACHE_MAX_ENTRIES,
|
||||
maxBytes: LIVE_CACHE_MAX_BYTES,
|
||||
});
|
||||
|
||||
const cacheForMode = (mode: MarkdownBlock['mode']): HighlightResultCache<string> =>
|
||||
(mode === 'live' ? liveBlockCache : fullBlockCache);
|
||||
|
||||
/** Content-addressed cache key for a markdown block. */
|
||||
const markdownBlockCacheKey = (
|
||||
contentHash: string,
|
||||
mode: MarkdownBlock['mode'],
|
||||
highlight: boolean,
|
||||
imageMode: MarkdownImageMode,
|
||||
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
|
||||
|
||||
/** Test-only: clear the render HTML caches between cases. */
|
||||
export const resetMarkdownHtmlCacheForTests = (): void => {
|
||||
fullBlockCache.clear();
|
||||
liveBlockCache.clear();
|
||||
};
|
||||
|
||||
const touch = (key: string, entry: { hash: string; html: string }): void => {
|
||||
htmlCache.delete(key);
|
||||
htmlCache.set(key, entry);
|
||||
if (htmlCache.size <= CACHE_MAX) return;
|
||||
const oldest = htmlCache.keys().next().value;
|
||||
if (oldest) htmlCache.delete(oldest);
|
||||
};
|
||||
/** Test-only: entry counts per block cache, for churn/eviction assertions. */
|
||||
export const __markdownBlockCacheSizesForTests = (): { full: number; live: number } => ({
|
||||
full: fullBlockCache.size,
|
||||
live: liveBlockCache.size,
|
||||
});
|
||||
|
||||
const parseBlock = async (block: MarkdownBlock, deferImages: boolean): Promise<string> => {
|
||||
const parsed = await Promise.resolve((deferImages ? imageParser : parser).parse(block.src));
|
||||
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
|
||||
return sanitize(highlighted);
|
||||
@@ -459,9 +561,10 @@ const parseBlock = async (block: MarkdownBlock, deferImages: boolean): Promise<s
|
||||
* is synchronous (marked is not configured `async`), so this never blocks on a
|
||||
* worker round-trip.
|
||||
*/
|
||||
export const renderMarkdownSync = (text: string, deferImages = false): string => {
|
||||
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => {
|
||||
if (!text) return '';
|
||||
const parsed = (deferImages ? imageParser : parser).parse(text) as string;
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = parser.parse(text) as string;
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
return sanitize(withMath);
|
||||
};
|
||||
@@ -479,28 +582,29 @@ export type RenderedBlock = {
|
||||
* splits into blocks, caches per-block, heals incomplete syntax. Returning
|
||||
* blocks (instead of one joined string) lets the renderer re-morph only the
|
||||
* block that changed, keeping per-step streaming cost ~O(last block).
|
||||
*
|
||||
* Lookup is content-addressed: distinct renderers holding identical blocks
|
||||
* share one entry and cannot evict each other by identity collision.
|
||||
*/
|
||||
export const renderMarkdownBlocks = async (
|
||||
text: string,
|
||||
streaming: boolean,
|
||||
cacheKey: string,
|
||||
deferImages = false,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): Promise<RenderedBlock[]> => {
|
||||
if (!text) return [];
|
||||
|
||||
const blocks = streamBlocks(text, streaming);
|
||||
return Promise.all(
|
||||
blocks.map(async (block, index) => {
|
||||
const contentHash = hash(block.raw);
|
||||
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${deferImages ? 1 : 0}`;
|
||||
const key = `${cacheKey}:${index}:${block.mode}:${deferImages ? 1 : 0}`;
|
||||
const cached = htmlCache.get(key);
|
||||
if (cached && cached.hash === contentHash) {
|
||||
touch(key, cached);
|
||||
return { id, html: cached.html };
|
||||
blocks.map(async (block) => {
|
||||
const contentHash = contentFingerprint(block.raw);
|
||||
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
|
||||
const cache = cacheForMode(block.mode);
|
||||
const cached = cache.get(id);
|
||||
if (cached !== undefined) {
|
||||
return { id, html: cached };
|
||||
}
|
||||
const html = await parseBlock(block, deferImages);
|
||||
touch(key, { hash: contentHash, html });
|
||||
const html = await parseBlock(block, imageMode);
|
||||
cache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
|
||||
return { id, html };
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Regression tests for https://github.com/openchamber/openchamber/issues/2769
|
||||
*
|
||||
* Sustained Shiki worker CPU came from re-tokenizing unchanged content:
|
||||
* 1. `htmlCache` keyed by renderer identity (`simple:${variant}`) so
|
||||
* same-variant instances evicted each other every pass.
|
||||
* 2. LRU capped at 240 entries, so long sessions missed 100% on every pass.
|
||||
* 3. Worker/client had no result memoization.
|
||||
*
|
||||
* These tests assert the fixed contracts: content-addressed caching, room for
|
||||
* long sessions, bounded LRU behavior, and fingerprint-key helpers.
|
||||
*/
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
contentFingerprint,
|
||||
estimateTokenRunsBytes,
|
||||
HighlightResultCache,
|
||||
utf16Bytes,
|
||||
} from './highlightResultCache';
|
||||
|
||||
let highlightCalls = 0;
|
||||
let highlightInflight = 0;
|
||||
let highlightMaxInflight = 0;
|
||||
|
||||
const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
|
||||
highlightCalls += 1;
|
||||
highlightInflight += 1;
|
||||
highlightMaxInflight = Math.max(highlightMaxInflight, highlightInflight);
|
||||
await Promise.resolve();
|
||||
highlightInflight -= 1;
|
||||
return `<pre data-lang="${lang}"><code>${code}</code></pre>`;
|
||||
});
|
||||
|
||||
mock.module('./markdown-worker', () => ({
|
||||
highlightCodeInWorker: highlightCodeInWorkerMock,
|
||||
highlightLinesInWorker: mock(async () => null),
|
||||
highlightTokensInWorker: mock(async () => null),
|
||||
resetMarkdownWorkerClientCacheForTests: mock(() => undefined),
|
||||
}));
|
||||
|
||||
const {
|
||||
renderMarkdownBlocks,
|
||||
resetMarkdownHtmlCacheForTests,
|
||||
__markdownBlockCacheSizesForTests,
|
||||
} = await import('./markdownCore');
|
||||
|
||||
const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
|
||||
|
||||
beforeEach(() => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
resetMarkdownWorkerClientCacheForTests();
|
||||
highlightCalls = 0;
|
||||
highlightInflight = 0;
|
||||
highlightMaxInflight = 0;
|
||||
});
|
||||
|
||||
describe('HighlightResultCache', () => {
|
||||
test('returns cached values for identical keys and refreshes LRU order', () => {
|
||||
const cache = new HighlightResultCache<string>({ maxEntries: 2, maxBytes: 10_000 });
|
||||
cache.set('a', 'one', utf16Bytes('a') + utf16Bytes('one'));
|
||||
cache.set('b', 'two', utf16Bytes('b') + utf16Bytes('two'));
|
||||
expect(cache.get('a')).toBe('one');
|
||||
// Touch `a` so `b` is oldest; inserting `c` should evict `b`.
|
||||
cache.set('c', 'three', utf16Bytes('c') + utf16Bytes('three'));
|
||||
expect(cache.get('b')).toEqual(undefined);
|
||||
expect(cache.get('a')).toBe('one');
|
||||
expect(cache.get('c')).toBe('three');
|
||||
});
|
||||
|
||||
test('evicts by byte budget while still caching a single oversized entry', () => {
|
||||
const cache = new HighlightResultCache<string>({ maxEntries: 10, maxBytes: 64 });
|
||||
cache.set('small', 'x', utf16Bytes('small') + utf16Bytes('x'));
|
||||
cache.set('huge', 'y'.repeat(200), utf16Bytes('huge') + utf16Bytes('y'.repeat(200)));
|
||||
expect(cache.get('huge')).toBe('y'.repeat(200));
|
||||
// Oversized insert cleared prior entries to make room.
|
||||
expect(cache.size).toBe(1);
|
||||
});
|
||||
|
||||
test('contentFingerprint is stable and length-qualified', () => {
|
||||
expect(contentFingerprint('const x = 1')).toBe(contentFingerprint('const x = 1'));
|
||||
expect(contentFingerprint('const x = 1')).not.toBe(contentFingerprint('const x = 2'));
|
||||
expect(contentFingerprint('ab')).not.toBe(contentFingerprint('abc'));
|
||||
});
|
||||
|
||||
test('contentFingerprint stays collision-free across a realistic session', () => {
|
||||
// A collision here does not mis-color a block — it returns a *different*
|
||||
// block's HTML, showing the user source they never wrote. Keep enough key
|
||||
// space that a session-sized working set never collides.
|
||||
const seen = new Map<string, string>();
|
||||
for (let i = 0; i < 20_000; i += 1) {
|
||||
// Same-length, near-identical sources are the realistic worst case:
|
||||
// repeated tool output differing by a few characters.
|
||||
const source = `const value_${String(i).padStart(6, '0')} = ${String(i).padStart(6, '0')};`;
|
||||
const fingerprint = contentFingerprint(source);
|
||||
expect(seen.get(fingerprint) ?? source).toBe(source);
|
||||
seen.set(fingerprint, source);
|
||||
}
|
||||
expect(seen.size).toBe(20_000);
|
||||
});
|
||||
|
||||
test('estimateTokenRunsBytes avoids JSON and stays positive', () => {
|
||||
const lines: Array<Array<[number, string, number]>> = [
|
||||
[[3, '#fff', 0], [1, '', 1]],
|
||||
[[8, 'var(--md-syntax-keyword)', 0]],
|
||||
];
|
||||
expect(estimateTokenRunsBytes(lines)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdownCore content-addressed htmlCache (#2769)', () => {
|
||||
test('repeat renders of unchanged content never re-enter the worker', async () => {
|
||||
const toolOutputA = '```ts\nconst a = 1;\n```';
|
||||
const toolOutputB = '```ts\nconst b = 2;\n```';
|
||||
|
||||
// First pass: cold miss for each distinct block.
|
||||
await renderMarkdownBlocks(toolOutputA, false);
|
||||
await renderMarkdownBlocks(toolOutputB, false);
|
||||
const coldCalls = highlightCalls;
|
||||
expect(coldCalls).toBeGreaterThan(0);
|
||||
|
||||
// 100 more passes. Renderers used to pass a shared `simple:${variant}`
|
||||
// identity key here and evict each other every pass; lookup is now
|
||||
// content-addressed, so no additional worker calls may happen.
|
||||
for (let pass = 0; pass < 100; pass += 1) {
|
||||
await renderMarkdownBlocks(toolOutputA, false);
|
||||
await renderMarkdownBlocks(toolOutputB, false);
|
||||
}
|
||||
|
||||
expect(highlightCalls).toBe(coldCalls);
|
||||
});
|
||||
|
||||
test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => {
|
||||
const parts = Array.from({ length: 600 }, (_, i) => ({
|
||||
content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``,
|
||||
}));
|
||||
|
||||
for (const part of parts) {
|
||||
await renderMarkdownBlocks(part.content, false);
|
||||
}
|
||||
const afterCold = highlightCalls;
|
||||
expect(afterCold).toBe(parts.length);
|
||||
|
||||
for (let pass = 0; pass < 5; pass += 1) {
|
||||
for (const part of parts) {
|
||||
await renderMarkdownBlocks(part.content, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Unchanged content must not re-enter the worker.
|
||||
expect(highlightCalls).toBe(afterCold);
|
||||
});
|
||||
|
||||
test('content changes invalidate only the changed block', async () => {
|
||||
const stable = '```ts\nconst stable = true;\n```';
|
||||
const changing = '```ts\nconst n = 1;\n```';
|
||||
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
await renderMarkdownBlocks(changing, false);
|
||||
const afterFirst = highlightCalls;
|
||||
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false);
|
||||
expect(highlightCalls).toBe(afterFirst + 1);
|
||||
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
expect(highlightCalls).toBe(afterFirst + 1);
|
||||
});
|
||||
|
||||
test('image mode is part of the cache identity, not shared across modes', async () => {
|
||||
const source = '';
|
||||
|
||||
const [inline] = await renderMarkdownBlocks(source, false, 'inline');
|
||||
expect(__markdownBlockCacheSizesForTests().full).toBe(1);
|
||||
|
||||
// Same source, different rendering: content addressing must not let the
|
||||
// first-rendered mode answer for both.
|
||||
const [label] = await renderMarkdownBlocks(source, false, 'label');
|
||||
expect(inline?.id).not.toBe(label?.id);
|
||||
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
|
||||
|
||||
// Re-rendering a mode already seen stays a cache hit.
|
||||
const [inlineAgain] = await renderMarkdownBlocks(source, false, 'inline');
|
||||
expect(inlineAgain?.id).toBe(inline?.id);
|
||||
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
|
||||
});
|
||||
|
||||
test('streaming a message does not evict settled blocks (live cache is separate)', async () => {
|
||||
const settled = Array.from(
|
||||
{ length: 40 },
|
||||
(_, i) => `\`\`\`ts\nconst settled_${i} = ${i};\n\`\`\``,
|
||||
);
|
||||
for (const block of settled) {
|
||||
await renderMarkdownBlocks(block, false);
|
||||
}
|
||||
const settledEntries = __markdownBlockCacheSizesForTests().full;
|
||||
expect(settledEntries).toBe(settled.length);
|
||||
const afterSettled = highlightCalls;
|
||||
|
||||
// Stream a message: every step is new content for the trailing live block,
|
||||
// so a single shared content-addressed cache would insert one entry per
|
||||
// step and evict the settled working set this fix exists to keep warm.
|
||||
let streamed = '';
|
||||
for (let step = 0; step < 150; step += 1) {
|
||||
streamed += `word_${step} `;
|
||||
await renderMarkdownBlocks(streamed, true);
|
||||
}
|
||||
|
||||
const sizes = __markdownBlockCacheSizesForTests();
|
||||
expect(sizes.live).toBeLessThanOrEqual(32);
|
||||
expect(sizes.full).toBe(settledEntries);
|
||||
|
||||
for (const block of settled) {
|
||||
await renderMarkdownBlocks(block, false);
|
||||
}
|
||||
expect(highlightCalls).toBe(afterSettled);
|
||||
});
|
||||
|
||||
test('a repeated streaming step is served from the live cache', async () => {
|
||||
const step = 'partial answer text';
|
||||
const [first] = await renderMarkdownBlocks(step, true);
|
||||
const [second] = await renderMarkdownBlocks(step, true);
|
||||
|
||||
expect(second?.id).toBe(first?.id);
|
||||
expect(__markdownBlockCacheSizesForTests()).toEqual({ full: 0, live: 1 });
|
||||
});
|
||||
|
||||
test('multiple code fences in one document highlight concurrently', async () => {
|
||||
const multi = [
|
||||
'```ts\nconst a = 1;\n```',
|
||||
'',
|
||||
'```ts\nconst b = 2;\n```',
|
||||
'',
|
||||
'```ts\nconst c = 3;\n```',
|
||||
].join('\n');
|
||||
|
||||
await renderMarkdownBlocks(multi, false);
|
||||
expect(highlightCalls).toBe(3);
|
||||
// Sequential awaits would keep max inflight at 1.
|
||||
expect(highlightMaxInflight).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
let requestCount = 0;
|
||||
let requestPaths: string[] = [];
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const runtimeFetch = mock(async (path: string, init?: RequestInit & { query?: Record<string, unknown> }) => {
|
||||
requestPaths.push(path);
|
||||
if (path === '/api/fs/stat') {
|
||||
return new Response(JSON.stringify({ isFile: true, size: PNG.byteLength }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (path === '/api/fs/raw') {
|
||||
return new Response(PNG, { status: 200, headers: { 'content-type': 'image/png' } });
|
||||
}
|
||||
requestCount += 1;
|
||||
const body = JSON.parse(String(init?.body)) as { sources: string[] };
|
||||
return new Response(JSON.stringify({
|
||||
results: body.sources.map((source) => ({ source, status: 'ready', path: `/repo/${source}` })),
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const resolver = {
|
||||
api: () => '',
|
||||
authenticatedAsset: (path: string, query: Record<string, string | undefined>) => {
|
||||
const params = new URLSearchParams(Object.entries(query).filter((entry): entry is [string, string] => Boolean(entry[1])));
|
||||
return `${path}?${params}`;
|
||||
},
|
||||
};
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch }));
|
||||
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => resolver }));
|
||||
|
||||
class TestFileReader {
|
||||
result: string | ArrayBuffer | null = null;
|
||||
error: DOMException | null = null;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
readAsDataURL(blob: Blob) {
|
||||
void blob.arrayBuffer().then((buffer) => {
|
||||
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`;
|
||||
this.onload?.();
|
||||
}).catch((error) => {
|
||||
this.error = error as DOMException;
|
||||
this.onerror?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.FileReader = TestFileReader as unknown as typeof FileReader;
|
||||
|
||||
const {
|
||||
getPreparedMarkdownImageUrl,
|
||||
prepareLocalMarkdownImages,
|
||||
resolveWorkspaceMarkdownImageSource,
|
||||
} = await import('./markdownImageAssets');
|
||||
|
||||
describe('Markdown image asset preparation', () => {
|
||||
test('prepares many images in one message-level request', async () => {
|
||||
requestCount = 0;
|
||||
const sources = Array.from({ length: 12 }, (_, index) => `${index}.png`);
|
||||
|
||||
const result = await prepareLocalMarkdownImages({
|
||||
sources,
|
||||
directory: '/repo',
|
||||
sessionId: 'ses_batch',
|
||||
messageId: 'msg_batch',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.size).toBe(12);
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
test('reuses preparation for one thousand messages after virtualized remounts', async () => {
|
||||
requestCount = 0;
|
||||
const requests = Array.from({ length: 1000 }, (_, index) => ({
|
||||
sources: [`${index}.png`],
|
||||
directory: '/repo',
|
||||
sessionId: 'ses_long',
|
||||
messageId: `msg_${index}`,
|
||||
signal: new AbortController().signal,
|
||||
}));
|
||||
|
||||
for (const request of requests) await prepareLocalMarkdownImages(request);
|
||||
for (const request of requests) await prepareLocalMarkdownImages(request);
|
||||
|
||||
expect(requestCount).toBe(1000);
|
||||
});
|
||||
|
||||
test('reuses the existing authenticated raw-file asset URL', () => {
|
||||
const url = getPreparedMarkdownImageUrl({
|
||||
status: 'ready',
|
||||
path: '/tmp/opencode/image.png',
|
||||
outsideFileGrant: 'grant-1',
|
||||
}, '/repo');
|
||||
|
||||
expect(url).toContain('/api/fs/raw?');
|
||||
expect(url).toContain('path=%2Ftmp%2Fopencode%2Fimage.png');
|
||||
expect(url).toContain('outsideFileGrant=grant-1');
|
||||
});
|
||||
|
||||
test('loads a workspace image through the local filesystem bridge', async () => {
|
||||
requestPaths = [];
|
||||
|
||||
const url = await resolveWorkspaceMarkdownImageSource(
|
||||
'screens/image.png',
|
||||
'/repo',
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(url.startsWith('data:image/png;base64,')).toBe(true);
|
||||
expect(requestPaths).toEqual(['/api/fs/stat', '/api/fs/raw']);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeUrlResolver, type RuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
|
||||
const MAX_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_PREPARE_CACHE_ENTRIES = 1024;
|
||||
const NON_READY_CACHE_MS = 30_000;
|
||||
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
@@ -9,6 +12,21 @@ const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export type PreparedMarkdownImage =
|
||||
| { status: 'ready'; path: string; outsideFileGrant?: string; expiresAt?: number }
|
||||
| { status: 'missing' | 'error' };
|
||||
|
||||
type PrepareCacheEntry = {
|
||||
result: Map<string, PreparedMarkdownImage>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const prepareCaches = new WeakMap<RuntimeUrlResolver, Map<string, PrepareCacheEntry>>();
|
||||
|
||||
const throwIfAborted = (signal: AbortSignal): void => {
|
||||
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
|
||||
};
|
||||
|
||||
const parseLocalImagePath = (source: string): string => {
|
||||
let value = source;
|
||||
if (/^file:\/\//i.test(value)) {
|
||||
@@ -34,9 +52,13 @@ const parseLocalImagePath = (source: string): string => {
|
||||
|
||||
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => typeof reader.result === 'string'
|
||||
? resolve(reader.result)
|
||||
: reject(new Error('Unable to encode image'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Unable to encode image'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Unable to encode image'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
@@ -44,18 +66,21 @@ const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, rej
|
||||
const hasImageSignature = async (blob: Blob, mimeType: string): Promise<boolean> => {
|
||||
const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
|
||||
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end));
|
||||
if (mimeType === 'image/png') {
|
||||
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
|
||||
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
|
||||
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
|
||||
case 'image/jpeg':
|
||||
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
case 'image/gif': {
|
||||
const gif = ascii(0, 6);
|
||||
return gif === 'GIF87a' || gif === 'GIF89a';
|
||||
}
|
||||
case 'image/webp':
|
||||
return ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
if (mimeType === 'image/jpeg') {
|
||||
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
}
|
||||
if (mimeType === 'image/gif') {
|
||||
const gif = ascii(0, 6);
|
||||
return gif === 'GIF87a' || gif === 'GIF89a';
|
||||
}
|
||||
return mimeType === 'image/webp' && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
|
||||
};
|
||||
|
||||
const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => {
|
||||
@@ -67,36 +92,125 @@ const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> =>
|
||||
const validateDataImage = async (source: string): Promise<void> => {
|
||||
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([\s\S]*)$/i.exec(source);
|
||||
if (!match?.[1] || match[2] === undefined) throw new Error('Invalid image data URL');
|
||||
const encoded = match[2];
|
||||
if (encoded.length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) {
|
||||
throw new Error('Image is too large');
|
||||
}
|
||||
|
||||
if (match[2].length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) throw new Error('Image is too large');
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(encoded);
|
||||
binary = atob(match[2]);
|
||||
} catch {
|
||||
throw new Error('Invalid image data URL');
|
||||
}
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
await validateImageBlob(new Blob([bytes]), match[1].toLowerCase());
|
||||
};
|
||||
|
||||
export const isLocalMarkdownImageSource = (source: string): boolean => (
|
||||
!/^(?:https?:)?\/\//i.test(source)
|
||||
&& !/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|
||||
);
|
||||
|
||||
export const prepareLocalMarkdownImages = async ({
|
||||
sources,
|
||||
directory,
|
||||
sessionId,
|
||||
messageId,
|
||||
signal,
|
||||
}: {
|
||||
sources: readonly string[];
|
||||
directory: string;
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<Map<string, PreparedMarkdownImage>> => {
|
||||
const resolver = getRuntimeUrlResolver();
|
||||
let cache = prepareCaches.get(resolver);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
prepareCaches.set(resolver, cache);
|
||||
}
|
||||
const key = `${sessionId}\0${messageId}\0${directory}\0${sources.join('\0')}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
cache.delete(key);
|
||||
cache.set(key, cached);
|
||||
return cached.result;
|
||||
}
|
||||
if (cached) cache.delete(key);
|
||||
|
||||
const response = await runtimeFetch(
|
||||
`/api/openchamber/sessions/${encodeURIComponent(sessionId)}/markdown-image-grants`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ directory, messageId, sources }),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Unable to prepare images (${response.status})`);
|
||||
const payload = await response.json() as {
|
||||
results?: Array<{
|
||||
source?: string;
|
||||
status?: string;
|
||||
path?: string;
|
||||
outsideFileGrant?: string;
|
||||
expiresAt?: number;
|
||||
}>;
|
||||
};
|
||||
const prepared = new Map<string, PreparedMarkdownImage>();
|
||||
for (const result of payload.results ?? []) {
|
||||
if (!result.source) continue;
|
||||
if (result.status === 'ready' && result.path) {
|
||||
prepared.set(result.source, {
|
||||
status: 'ready',
|
||||
path: result.path,
|
||||
outsideFileGrant: result.outsideFileGrant,
|
||||
expiresAt: result.expiresAt,
|
||||
});
|
||||
} else if (result.status === 'missing') {
|
||||
prepared.set(result.source, { status: 'missing' });
|
||||
} else {
|
||||
prepared.set(result.source, { status: 'error' });
|
||||
}
|
||||
}
|
||||
for (const source of sources) {
|
||||
if (!prepared.has(source)) prepared.set(source, { status: 'error' });
|
||||
}
|
||||
while (cache.size >= MAX_PREPARE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!);
|
||||
const allReady = [...prepared.values()].every((value) => value.status === 'ready');
|
||||
const grantExpiry = Math.min(...[...prepared.values()]
|
||||
.filter((value): value is Extract<PreparedMarkdownImage, { status: 'ready' }> => value.status === 'ready')
|
||||
.map((value) => value.expiresAt ?? Number.POSITIVE_INFINITY));
|
||||
cache.set(key, {
|
||||
result: prepared,
|
||||
expiresAt: allReady ? grantExpiry : Date.now() + NON_READY_CACHE_MS,
|
||||
});
|
||||
return prepared;
|
||||
};
|
||||
|
||||
export const resolveMarkdownImageSource = async (
|
||||
source: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
throwIfAborted(signal);
|
||||
if (/^(?:https?:)?\/\//i.test(source)) return source;
|
||||
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
|
||||
await validateDataImage(source);
|
||||
throwIfAborted(signal);
|
||||
return source;
|
||||
}
|
||||
throw new Error('Local image has not been prepared');
|
||||
};
|
||||
|
||||
/**
|
||||
* VS Code has no OpenChamber server route for message-scoped temporary-file
|
||||
* grants. Preserve its existing workspace-only gallery path through the local
|
||||
* filesystem bridge, including the same size and signature validation.
|
||||
*/
|
||||
export const resolveWorkspaceMarkdownImageSource = async (
|
||||
source: string,
|
||||
directory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
|
||||
if (/^(?:https?:)?\/\//i.test(source)) return source;
|
||||
|
||||
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
|
||||
await validateDataImage(source);
|
||||
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
|
||||
return source;
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
const localPath = parseLocalImagePath(source);
|
||||
const absolutePath = toAbsoluteFilePath(directory, localPath);
|
||||
if (!directory || !localPath || !isFilePathWithinDirectory(absolutePath, directory)) {
|
||||
@@ -128,5 +242,19 @@ export const resolveMarkdownImageSource = async (
|
||||
|
||||
const blob = await response.blob();
|
||||
await validateImageBlob(blob, mimeType);
|
||||
throwIfAborted(signal);
|
||||
return blobToDataUrl(blob);
|
||||
};
|
||||
|
||||
export const getPreparedMarkdownImageUrl = (
|
||||
image: Extract<PreparedMarkdownImage, { status: 'ready' }>,
|
||||
directory: string,
|
||||
): string => getRuntimeUrlResolver().authenticatedAsset(
|
||||
'/api/fs/raw',
|
||||
{
|
||||
path: image.path,
|
||||
directory,
|
||||
allowOutsideWorkspace: image.outsideFileGrant ? 'true' : undefined,
|
||||
outsideFileGrant: image.outsideFileGrant,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
|
||||
import { StaticToolRow } from './parts/ProgressiveGroup';
|
||||
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
|
||||
import TurnActivity from '../components/TurnActivity';
|
||||
import { createProjectPlanFile } from '@/lib/openchamberConfig';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -1509,7 +1509,7 @@ const AssistantMessageBody = React.memo(({
|
||||
|
||||
setIsSavingPlan(true);
|
||||
try {
|
||||
const created = await createProjectPlanFile(currentProjectRef, {
|
||||
const created = await useProjectContextStore.getState().createPlan(currentProjectRef, {
|
||||
title,
|
||||
body: assistantPlanText,
|
||||
});
|
||||
@@ -1517,9 +1517,6 @@ const AssistantMessageBody = React.memo(({
|
||||
toast.error(t('chat.messageBody.toast.savePlanFailed'));
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
|
||||
detail: { projectId: currentProjectRef.id },
|
||||
}));
|
||||
setIsPlanDialogOpen(false);
|
||||
toast.success(t('chat.messageBody.toast.planSaved'));
|
||||
} finally {
|
||||
@@ -1876,7 +1873,6 @@ const AssistantMessageBody = React.memo(({
|
||||
chatRenderMode={chatRenderMode}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
enableMarkdownImages={isMessageCompleted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -2031,7 +2027,6 @@ const AssistantMessageBody = React.memo(({
|
||||
collapsedPreviewCount,
|
||||
expandedTools,
|
||||
isMobile,
|
||||
isMessageCompleted,
|
||||
isActivityOwnerMessage,
|
||||
isSortedRenderMode,
|
||||
lastRenderableTextPartIndex,
|
||||
@@ -2244,6 +2239,8 @@ const AssistantMessageBody = React.memo(({
|
||||
</div>
|
||||
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
||||
<MarkdownImageGallery
|
||||
sessionId={sessionId}
|
||||
messageId={messageId}
|
||||
contents={finalizedAssistantMarkdownContents}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,8 @@ import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
|
||||
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { summarizeSelectionForNotes } from '@/lib/smallModel';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
@@ -34,15 +35,9 @@ interface SelectionPayload {
|
||||
rect: DOMRect;
|
||||
}
|
||||
|
||||
const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => {
|
||||
const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '').slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH);
|
||||
if (!trimmedInsight) {
|
||||
return existingNotes;
|
||||
}
|
||||
|
||||
const trimmedNotes = existingNotes.trimEnd();
|
||||
return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight;
|
||||
};
|
||||
const normalizeDistilledInsight = (insight: string): string => (
|
||||
insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH)
|
||||
);
|
||||
|
||||
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
|
||||
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
||||
@@ -366,19 +361,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
// Long selections are distilled into a compact note by the small model;
|
||||
// short ones (and any generation failure) go in verbatim.
|
||||
const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId);
|
||||
const projectData = await getProjectNotesAndTodos(currentProjectRef);
|
||||
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
|
||||
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
|
||||
notes: nextNotes,
|
||||
todos: projectData.todos,
|
||||
const insight = normalizeDistilledInsight(noteText);
|
||||
if (!insight) {
|
||||
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
|
||||
return;
|
||||
}
|
||||
// Recorded as its own note with provenance, so the distilled insight can
|
||||
// later be traced back to the conversation it came from.
|
||||
const saved = await useProjectContextStore.getState().createNote(currentProjectRef, {
|
||||
body: insight,
|
||||
source: 'selection',
|
||||
...(currentSessionId ? { origin: { sessionId: currentSessionId } } : {}),
|
||||
});
|
||||
if (!saved) {
|
||||
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
|
||||
detail: { projectId: currentProjectRef.id },
|
||||
}));
|
||||
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
|
||||
hideMenu();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
@@ -19,7 +19,6 @@ interface AssistantTextPartProps {
|
||||
chatRenderMode?: 'sorted' | 'live';
|
||||
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
enableMarkdownImages?: boolean;
|
||||
}
|
||||
|
||||
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
||||
@@ -28,7 +27,6 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
||||
streamPhase,
|
||||
chatRenderMode = 'live',
|
||||
onShowPopup,
|
||||
enableMarkdownImages = false,
|
||||
}) => {
|
||||
// Use part directly from props — parent provides the latest version from the store.
|
||||
// No store subscription here to avoid re-render cascade from unrelated delta events.
|
||||
@@ -103,7 +101,6 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
||||
disableStreamAnimation={chatRenderMode === 'sorted'}
|
||||
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
|
||||
enableFileReferences={isFinalized}
|
||||
enableLocalImages={enableMarkdownImages && !isStreaming && part.type === 'text'}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -55,23 +55,32 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
HTML is sanitized as defense in depth, with script and style elements
|
||||
forbidden, so message content cannot inject active DOM or application-wide
|
||||
CSS into any runtime surface.
|
||||
- Final assistant Markdown collects HTTP(S), embedded, and workspace-local
|
||||
- Final assistant Markdown rendering is independent from image gallery
|
||||
extraction: gallery presence never changes the chat body. Assistant image
|
||||
syntax consistently renders as a shared image icon followed by its filename,
|
||||
without loading the image in the body; tool and simple Markdown retain normal
|
||||
inline image rendering. The gallery separately collects HTTP(S), embedded, and workspace-local
|
||||
PNG/JPEG/GIF/WebP image candidates into one 100px thumbnail gallery in the
|
||||
message-completion area after all message text and above the turn's changed
|
||||
files. Each muted filename caption includes the shared image-file icon.
|
||||
HTTP(S) images keep their browser URL. Embedded and workspace-local images
|
||||
are limited to 10 MiB, validated as PNG/JPEG/GIF/WebP, and local paths are
|
||||
fetched through the active runtime before conversion to data URLs. Local
|
||||
Markdown links whose target has one of
|
||||
those image suffixes stay links in the text and open the same existing
|
||||
full-screen image preview as the gallery; image syntax does not insert a
|
||||
large inline image. A
|
||||
are limited to 10 MiB and validated as PNG/JPEG/GIF/WebP. Chat Markdown uses
|
||||
the assistant image-label policy without gallery-specific link rewriting,
|
||||
completion-state switching, or hidden placeholders. A
|
||||
completed assistant message hydrates at most 12 unique image candidates,
|
||||
including persisted text parts that omit their optional part-level end time.
|
||||
Thumbnail assets begin loading only when their gallery items approach the
|
||||
viewport, so mounted historical messages do not eagerly read every image.
|
||||
In server-backed runtimes, a gallery approaching the viewport prepares all
|
||||
local candidates in one message-level request, then reuses the authenticated
|
||||
`/api/fs/raw` asset route. Each URL loads only when its thumbnail approaches
|
||||
the viewport. VS Code instead loads workspace-contained images through its
|
||||
local filesystem bridge and never calls the server grant route; OpenCode
|
||||
temporary-directory images remain unsupported there. Mounted historical
|
||||
messages therefore do not eagerly read every image.
|
||||
Gallery clicks do not introduce or alter preview chrome: desktop and mobile
|
||||
both reuse the pre-existing attachment image preview overlay.
|
||||
Workspace-external images receive the existing path-bound `outsideFileGrant`
|
||||
only when the server verifies the exact source in the owning assistant
|
||||
message and the real file is inside OpenCode's dedicated temporary directory.
|
||||
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
|
||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
||||
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||
|
||||
@@ -1259,6 +1259,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
const hasVisualDiffEntry = diffEntries.some((entry) => entry.renderMode === 'diff');
|
||||
const hideToolInputPreview = part.tool === 'openchamber'
|
||||
|| part.tool === 'openchamber_web'
|
||||
|| part.tool === 'openchamber_memory'
|
||||
|| part.tool === 'apply_patch'
|
||||
|| part.tool === 'edit'
|
||||
|| part.tool === 'multiedit';
|
||||
|
||||
@@ -59,6 +59,9 @@ export const getToolIcon = (toolName: string) => {
|
||||
if (tool === 'openchamber_web') {
|
||||
return <Icon name="global" className={iconClass} />;
|
||||
}
|
||||
if (tool === 'openchamber_memory') {
|
||||
return <Icon name="brain-4" className={iconClass} />;
|
||||
}
|
||||
if (tool === 'question') {
|
||||
return <Icon name="survey" className={iconClass} />;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ which requests only providers enabled for this panel.
|
||||
| Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this |
|
||||
| Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` and refreshed from Git mutation hints |
|
||||
| Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits |
|
||||
| PR + checks | `usePrVisualSummary` | **read-only** |
|
||||
| PR + checks | `useFreshestPrVisualSummaryForBranch` | **read-only**; follows the freshest remote-keyed entry for the branch |
|
||||
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | |
|
||||
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
|
||||
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
|
||||
@@ -145,6 +145,10 @@ The panel never calls `startWatching`. PR watching is owned by the background
|
||||
tracker, and its concurrency gate exists because per-consumer PR fetches once
|
||||
saturated the browser's connection pool and stalled startup for ~20s. A panel
|
||||
that started a watch per open session would reintroduce exactly that fan-out.
|
||||
The PR surface can watch a concrete remote while passive readers initially know
|
||||
only the automatic remote key, so the panel reads the freshest entry for the
|
||||
directory and branch across remote keys. This keeps its PR and checks rows in
|
||||
sync with the live PR surface without adding another request owner.
|
||||
|
||||
### Changed files come from git status, not the session
|
||||
|
||||
|
||||
@@ -5,8 +5,16 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
import { getLinkedIssues } from '@/lib/linkedIssues';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { resolveProjectContextId } from '@/lib/projectContextApi';
|
||||
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
|
||||
import { useReportWorkStatusPresence } from './presenceContext';
|
||||
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
|
||||
|
||||
type Props = {
|
||||
sessionId: string | null;
|
||||
@@ -26,6 +34,11 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
const { t } = useI18n();
|
||||
|
||||
const session = useSession(sessionId ?? '', directory ?? undefined);
|
||||
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const isDraft = sessionId === null && newSessionDraft.open;
|
||||
const skills = useSkillsStore((state) => state.skills);
|
||||
const mcpStatus = useMcpStore(
|
||||
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
|
||||
@@ -43,6 +56,87 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
void loadSkills();
|
||||
}, [directory, loadSkills]);
|
||||
|
||||
/**
|
||||
* What this session carries. Read from the server
|
||||
* rather than from the notes panel's store, because this must be right
|
||||
* whether or not that panel has ever been opened.
|
||||
*/
|
||||
const [knowledge, setKnowledge] = React.useState<SessionKnowledgeSummary>(
|
||||
{ notes: [], plans: [], memory: { global: 0, project: 0 } },
|
||||
);
|
||||
|
||||
// Re-read when source content or memory changes, not only when the session does.
|
||||
const contextEntries = useProjectContextStore((state) => state.entries);
|
||||
const loadProjectContext = useProjectContextStore((state) => state.load);
|
||||
const memoryProject = useAgentMemoryStore((state) => state.project);
|
||||
const memoryGlobal = useAgentMemoryStore((state) => state.global);
|
||||
|
||||
const draftProject = React.useMemo(() => {
|
||||
if (!isDraft) return null;
|
||||
const selected = newSessionDraft.selectedProjectId
|
||||
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
|
||||
: null;
|
||||
return selected ?? resolveProjectForSessionDirectory(
|
||||
projects,
|
||||
availableWorktreesByProject,
|
||||
newSessionDraft.directoryOverride ?? directory,
|
||||
);
|
||||
}, [availableWorktreesByProject, directory, isDraft, newSessionDraft.directoryOverride, newSessionDraft.selectedProjectId, projects]);
|
||||
|
||||
const draftContextEntry = draftProject
|
||||
? contextEntries[resolveProjectContextId({ id: draftProject.id, path: draftProject.path })]
|
||||
: undefined;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDraft || !draftProject) return;
|
||||
void loadProjectContext({ id: draftProject.id, path: draftProject.path });
|
||||
}, [draftProject, isDraft, loadProjectContext]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => {
|
||||
if (!cancelled) setKnowledge(summary);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]);
|
||||
|
||||
const visibleKnowledge = React.useMemo<SessionKnowledgeSummary>(() => {
|
||||
if (!isDraft) return knowledge;
|
||||
const pinned = resolveDraftPinnedKnowledge(
|
||||
draftContextEntry?.notes ?? [],
|
||||
draftContextEntry?.plans ?? [],
|
||||
newSessionDraft.projectContextPins ?? { notes: [], plans: [] },
|
||||
);
|
||||
return { ...knowledge, ...pinned };
|
||||
}, [draftContextEntry?.notes, draftContextEntry?.plans, isDraft, knowledge, newSessionDraft.projectContextPins]);
|
||||
|
||||
// Unpinning from here, like the pinned-messages section: a panel that says
|
||||
// what is attached should be able to detach it, or the user has to go find
|
||||
// the surface that can.
|
||||
const unpinNote = React.useCallback((noteId: string) => {
|
||||
if (isDraft) {
|
||||
setDraftProjectContextPin('note', noteId, false);
|
||||
return;
|
||||
}
|
||||
if (!directory || !sessionId) return;
|
||||
void setSessionProjectContextPin(directory, sessionId, 'note', noteId, false).then((pins) => {
|
||||
if (pins) setKnowledge((current) => ({ ...current, notes: current.notes.filter((note) => note.id !== noteId) }));
|
||||
});
|
||||
}, [directory, isDraft, sessionId, setDraftProjectContextPin]);
|
||||
const unpinPlan = React.useCallback((planId: string) => {
|
||||
if (isDraft) {
|
||||
setDraftProjectContextPin('plan', planId, false);
|
||||
return;
|
||||
}
|
||||
if (!directory || !sessionId) return;
|
||||
void setSessionProjectContextPin(directory, sessionId, 'plan', planId, false).then((pins) => {
|
||||
if (pins) setKnowledge((current) => ({ ...current, plans: current.plans.filter((plan) => plan.id !== planId) }));
|
||||
});
|
||||
}, [directory, isDraft, sessionId, setDraftProjectContextPin]);
|
||||
|
||||
const memoryCount = visibleKnowledge.memory.global + visibleKnowledge.memory.project;
|
||||
const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length;
|
||||
|
||||
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
|
||||
// Connected servers only. A disabled server contributes nothing to the
|
||||
// context, so counting it here contradicts the MCP section right above,
|
||||
@@ -52,9 +146,14 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
[mcpStatus],
|
||||
);
|
||||
|
||||
useReportWorkStatusPresence('context-sources', linked.length > 0 || skills.length > 0 || mcpCount > 0);
|
||||
useReportWorkStatusPresence(
|
||||
'context-sources',
|
||||
linked.length > 0 || skills.length > 0 || mcpCount > 0 || pinnedCount > 0 || memoryCount > 0,
|
||||
);
|
||||
|
||||
if (linked.length === 0 && skills.length === 0 && mcpCount === 0) return null;
|
||||
if (linked.length === 0 && skills.length === 0 && mcpCount === 0 && pinnedCount === 0 && memoryCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The heading names what is distinctive about this session when there is
|
||||
// something — an attached thread — and falls back to the ambient counts
|
||||
@@ -72,6 +171,12 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
? t('chat.workStatus.breakdown.prCountSingle', { count: prCount })
|
||||
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
|
||||
}
|
||||
// Pinned knowledge outranks ambient counts because the user chose it for this session.
|
||||
if (summaryParts.length === 0 && pinnedCount > 0) {
|
||||
summaryParts.push(pinnedCount === 1
|
||||
? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount })
|
||||
: t('chat.workStatus.breakdown.pinnedKnowledgePlural', { count: pinnedCount }));
|
||||
}
|
||||
if (summaryParts.length === 0) {
|
||||
if (skills.length > 0) {
|
||||
summaryParts.push(skills.length === 1
|
||||
@@ -115,6 +220,62 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Named individually: a count alone would not identify this session's context. */}
|
||||
{/* The pin is the control, exactly as in the pinned-messages section
|
||||
above: same icon, same placement, same behaviour. Two pins that look
|
||||
different in one panel would read as two different things. */}
|
||||
{visibleKnowledge.notes.map((note) => (
|
||||
<WorkStatusRow
|
||||
key={note.id}
|
||||
muted
|
||||
leading={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isDraft && (!sessionId || !directory)}
|
||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
unpinNote(note.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="pushpin-2-fill" className="size-3.5" style={{ color: 'var(--primary)' }} />
|
||||
</button>
|
||||
)}
|
||||
label={note.body.trim().split('\n')[0] || note.body.trim()}
|
||||
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedNote')}</WorkStatusValue>}
|
||||
/>
|
||||
))}
|
||||
{visibleKnowledge.plans.map((plan) => (
|
||||
<WorkStatusRow
|
||||
key={plan.id}
|
||||
muted
|
||||
leading={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isDraft && (!sessionId || !directory)}
|
||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
unpinPlan(plan.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="pushpin-2-fill" className="size-3.5" style={{ color: 'var(--primary)' }} />
|
||||
</button>
|
||||
)}
|
||||
label={plan.title}
|
||||
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedPlan')}</WorkStatusValue>}
|
||||
/>
|
||||
))}
|
||||
{memoryCount > 0 ? (
|
||||
<WorkStatusRow
|
||||
muted
|
||||
label={t('chat.workStatus.breakdown.memory')}
|
||||
value={<WorkStatusValue>{memoryCount}</WorkStatusValue>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<WorkStatusRow
|
||||
muted
|
||||
label={t('chat.workStatus.breakdown.skills')}
|
||||
|
||||
@@ -97,6 +97,7 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
|
||||
>
|
||||
{mcpServers.map(([name, entry]) => {
|
||||
const connected = entry?.status === 'connected';
|
||||
const busy = busyServer === name;
|
||||
const needsAuth = entry?.status === 'needs_auth' || entry?.status === 'needs_client_registration';
|
||||
const failed = entry?.status === 'failed';
|
||||
return (
|
||||
@@ -105,8 +106,9 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
|
||||
leading={(
|
||||
<Switch
|
||||
checked={connected}
|
||||
disabled={busyServer === name}
|
||||
className="scale-75 data-[checked]:bg-status-info"
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
className="scale-75 disabled:opacity-100 data-[checked]:bg-status-info"
|
||||
aria-label={t('chat.workStatus.mcp.toggle', { name })}
|
||||
onCheckedChange={(checked) => { void handleToggle(name, checked); }}
|
||||
/>
|
||||
@@ -118,7 +120,7 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
|
||||
value={needsAuth ? (
|
||||
<WorkStatusRowAction
|
||||
tone="warning"
|
||||
disabled={busyServer === name}
|
||||
disabled={busy}
|
||||
onClick={() => { void handleAuthorize(name); }}
|
||||
>
|
||||
{t('chat.workStatus.mcp.needsAuth')}
|
||||
@@ -126,7 +128,7 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
|
||||
) : failed ? (
|
||||
<WorkStatusRowAction
|
||||
tone="error"
|
||||
disabled={busyServer === name}
|
||||
disabled={busy}
|
||||
onClick={() => { void handleToggle(name, true); }}
|
||||
>
|
||||
{t('chat.workStatus.mcp.failed')}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useSession, useSessionMessages } from '@/sync/sync-context';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -107,11 +107,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
|
||||
// Read-only: PR watching is owned by the background tracker. Starting a watch
|
||||
// here would multiply GitHub requests per open session, which is exactly the
|
||||
// fan-out the PR-status concurrency gate exists to prevent.
|
||||
const prKey = React.useMemo(
|
||||
() => (directory && branch ? getGitHubPrStatusKey(directory, branch) : null),
|
||||
[directory, branch],
|
||||
);
|
||||
const prSummary = usePrVisualSummary(prKey);
|
||||
const prSummary = useFreshestPrVisualSummaryForBranch(directory, branch);
|
||||
|
||||
// `getCurrentModel` is an imperative getter: its reference never changes, so
|
||||
// calling it in render subscribes to nothing. Subscribe to the selected model
|
||||
|
||||
@@ -25,7 +25,7 @@ const SECTION_CLASS = cn(
|
||||
'[&:not(:first-child)]:border-[var(--interactive-border)] [&:not(:first-child)]:pt-3',
|
||||
);
|
||||
|
||||
const HEADING_CLASS = 'text-xs font-normal text-muted-foreground';
|
||||
const HEADING_CLASS = 'text-xs font-semibold text-foreground';
|
||||
|
||||
export const WorkStatusSection: React.FC<{
|
||||
title: string;
|
||||
@@ -158,7 +158,10 @@ export const WorkStatusRow: React.FC<RowProps> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const shared = cn('flex h-7 w-full items-center gap-2 rounded-md px-1 text-left', className);
|
||||
const shared = cn(
|
||||
'flex h-7 w-full items-center gap-2 rounded-md px-1 text-left text-muted-foreground',
|
||||
className,
|
||||
);
|
||||
|
||||
if (!onClick) return <div className={shared}>{body}</div>;
|
||||
|
||||
|
||||
@@ -124,8 +124,7 @@ export const WorkStatusUsageSection: React.FC = () => {
|
||||
<React.Fragment key={group.providerId}>
|
||||
<WorkStatusRow
|
||||
leading={<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />}
|
||||
label={group.providerName}
|
||||
muted
|
||||
label={<span className="font-semibold text-foreground">{group.providerName}</span>}
|
||||
value={group.status && group.rows.length === 0 ? (
|
||||
<WorkStatusValue tone="muted">{group.status}</WorkStatusValue>
|
||||
) : undefined}
|
||||
|
||||
@@ -14,8 +14,8 @@ describe('computeContextUsage', () => {
|
||||
});
|
||||
|
||||
test('reports the latest turn rather than a sum across turns', () => {
|
||||
// Each assistant turn reports the whole window it saw, so adding them up
|
||||
// would report several times the real fill.
|
||||
// A turn's tokens describe that turn's window, so adding turns up would
|
||||
// report several times the real fill.
|
||||
const usage = computeContextUsage(
|
||||
[
|
||||
assistant({ input: 400, output: 0, reasoning: 0 }, 'old'),
|
||||
@@ -61,4 +61,24 @@ describe('computeContextUsage', () => {
|
||||
const usage = computeContextUsage([assistant({ input: 10 })], 100);
|
||||
expect(usage?.totalTokens).toBe(10);
|
||||
});
|
||||
|
||||
test('prefers the server-reported total over summing round-trip fields', () => {
|
||||
// Real payload from opencode 1.18.18: ~14 tool-call round-trips accumulated
|
||||
// cache.read to 3.29M while the 1M window really held 232,872. Summing
|
||||
// rendered 330.6%; the reported total renders the real 23.3%.
|
||||
const usage = computeContextUsage(
|
||||
[assistant({ total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } })],
|
||||
1_000_000,
|
||||
);
|
||||
expect(usage?.totalTokens).toBe(232_872);
|
||||
expect(usage?.percent.toFixed(4)).toBe('23.2872');
|
||||
});
|
||||
|
||||
test('selects a message whose only signal is the reported total', () => {
|
||||
const usage = computeContextUsage(
|
||||
[assistant({ total: 5_000, input: 0, output: 0, reasoning: 0 })],
|
||||
100_000,
|
||||
);
|
||||
expect(usage?.totalTokens).toBe(5_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
* global read to race with.
|
||||
*/
|
||||
|
||||
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
|
||||
|
||||
type MessageTokens = {
|
||||
/** Server-reported window of the turn's final round-trip; absent on older servers. */
|
||||
total?: number;
|
||||
input?: number;
|
||||
output?: number;
|
||||
reasoning?: number;
|
||||
@@ -37,18 +41,12 @@ type WorkStatusContextUsage = {
|
||||
/** The store's own fallback when a model exposes no context limit. */
|
||||
export const DEFAULT_CONTEXT_LIMIT = 200_000;
|
||||
|
||||
const sumTokens = (tokens: MessageTokens): number => (
|
||||
(tokens.input ?? 0)
|
||||
+ (tokens.output ?? 0)
|
||||
+ (tokens.reasoning ?? 0)
|
||||
+ (tokens.cache?.read ?? 0)
|
||||
+ (tokens.cache?.write ?? 0)
|
||||
);
|
||||
|
||||
/**
|
||||
* Usage from the newest assistant message that reported a non-zero token count.
|
||||
* Each assistant turn reports the whole window it saw, so the latest one is the
|
||||
* current fill — not a sum across turns.
|
||||
* The latest turn describes the current fill — not a sum across turns. Within
|
||||
* a turn, the server-reported `total` is the final round-trip's window;
|
||||
* summing the breakdown fields instead overstates multi-step turns, whose
|
||||
* input/cache fields accumulate across round-trips.
|
||||
*/
|
||||
export const computeContextUsage = (
|
||||
messages: readonly MessageLike[],
|
||||
@@ -60,7 +58,7 @@ export const computeContextUsage = (
|
||||
const message = messages[index];
|
||||
if (message?.role !== 'assistant' || !message.tokens) continue;
|
||||
|
||||
const totalTokens = sumTokens(message.tokens);
|
||||
const totalTokens = contextTokensFromBreakdown(message.tokens);
|
||||
if (totalTokens <= 0) continue;
|
||||
|
||||
const limit = contextLimit > 0 ? contextLimit : DEFAULT_CONTEXT_LIMIT;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
|
||||
|
||||
describe('resolveDraftPinnedKnowledge', () => {
|
||||
test('shows only notes and plans pinned on this draft', () => {
|
||||
expect(resolveDraftPinnedKnowledge(
|
||||
[{ id: 'note-a', body: 'Attached' }, { id: 'note-b', body: 'Not attached' }],
|
||||
[{ id: 'plan-a', title: 'Attached plan' }, { id: 'plan-b', title: 'Other plan' }],
|
||||
{ notes: ['note-a'], plans: ['plan-a'] },
|
||||
)).toEqual({
|
||||
notes: [{ id: 'note-a', body: 'Attached' }],
|
||||
plans: [{ id: 'plan-a', title: 'Attached plan' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('drops stale ids without borrowing project-wide pins', () => {
|
||||
expect(resolveDraftPinnedKnowledge(
|
||||
[{ id: 'note-a', body: 'Project note' }],
|
||||
[{ id: 'plan-a', title: 'Project plan' }],
|
||||
{ notes: ['missing'], plans: [] },
|
||||
)).toEqual({ notes: [], plans: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SessionKnowledgeSummary, SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
|
||||
|
||||
type NoteSource = { id: string; body: string };
|
||||
type PlanSource = { id: string; title: string };
|
||||
|
||||
export const resolveDraftPinnedKnowledge = (
|
||||
notes: NoteSource[],
|
||||
plans: PlanSource[],
|
||||
pins: SessionProjectContextPins,
|
||||
): Pick<SessionKnowledgeSummary, 'notes' | 'plans'> => {
|
||||
const noteIds = new Set(pins.notes);
|
||||
const planIds = new Set(pins.plans);
|
||||
return {
|
||||
notes: notes.filter((note) => noteIds.has(note.id)).map(({ id, body }) => ({ id, body })),
|
||||
plans: plans.filter((plan) => planIds.has(plan.id)).map(({ id, title }) => ({ id, title })),
|
||||
};
|
||||
};
|
||||
@@ -36,6 +36,10 @@ describe('resolveQuotaProviderId', () => {
|
||||
expect(resolveQuotaProviderId('anthropic')).toBe('claude');
|
||||
});
|
||||
|
||||
test('maps the opencode-claude integration provider onto Claude quota', () => {
|
||||
expect(resolveQuotaProviderId('claude-code')).toBe('claude');
|
||||
});
|
||||
|
||||
test('is case and whitespace tolerant, and rejects empties', () => {
|
||||
expect(resolveQuotaProviderId(' OpenAI ')).toBe('codex');
|
||||
expect(resolveQuotaProviderId('')).toBeNull();
|
||||
|
||||
@@ -13,11 +13,15 @@ import type { UsageProviderGroup, UsageLimitRow } from '@/components/usage/usage
|
||||
/**
|
||||
* Quota provider ids mostly match OpenCode provider ids; these are the ones
|
||||
* that do not. Unmatched providers simply produce no headline.
|
||||
*
|
||||
* `claude-code` is the provider the opencode-claude integration registers, and
|
||||
* it bills against the same Claude subscription the `claude` quota reports.
|
||||
*/
|
||||
const QUOTA_PROVIDER_ALIASES = new Map<string, string>([
|
||||
['openai', 'codex'],
|
||||
['chatgpt', 'codex'],
|
||||
['anthropic', 'claude'],
|
||||
['claude-code', 'claude'],
|
||||
['gemini', 'google'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -30,6 +30,29 @@ function ensureSpriteOnce() {
|
||||
spriteInjected = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a single missing symbol. Needed when the sprite was injected before a
|
||||
* newly generated icon landed (HMR / late sprite regenerate) — a one-shot inject
|
||||
* would otherwise leave `<use href="#oc-…"/>` pointing at nothing.
|
||||
*/
|
||||
function ensureSpriteSymbol(name: IconName) {
|
||||
if (typeof document === "undefined") return
|
||||
ensureSpriteOnce()
|
||||
if (document.getElementById(`oc-${name}`)) return
|
||||
|
||||
const content = iconSpriteData[name]
|
||||
if (typeof content !== "string") return
|
||||
|
||||
const sprite = document.getElementById(SPRITE_ID)
|
||||
if (!sprite) return
|
||||
|
||||
const symbol = document.createElementNS("http://www.w3.org/2000/svg", "symbol")
|
||||
symbol.id = `oc-${name}`
|
||||
symbol.setAttribute("viewBox", "0 0 24 24")
|
||||
symbol.innerHTML = content
|
||||
sprite.appendChild(symbol)
|
||||
}
|
||||
|
||||
export interface IconProps extends React.ComponentPropsWithoutRef<"svg"> {
|
||||
name: IconName
|
||||
}
|
||||
@@ -38,7 +61,7 @@ export const Icon = React.memo(({ name, className, ...rest }: IconProps) => {
|
||||
// Inline sprite injection during render – must run before <use> tries
|
||||
// to resolve the #oc-* reference during the same commit.
|
||||
if (typeof document !== "undefined") {
|
||||
ensureSpriteOnce()
|
||||
ensureSpriteSymbol(name)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -28,10 +28,12 @@ export const iconSpriteData = {
|
||||
"bar-chart-2": `<path d="M2 13H8V21H2V13ZM16 8H22V21H16V8ZM9 3H15V21H9V3ZM4 15V19H6V15H4ZM11 5V19H13V5H11ZM18 10V19H20V10H18Z" fill="currentColor"/>`,
|
||||
"bar-chart-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM7 13H9V17H7V13ZM11 7H13V17H11V7ZM15 10H17V17H15V10Z" fill="currentColor"/>`,
|
||||
"book": `<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM5 15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H6C5.44772 4 5 4.44772 5 5V15.3368Z" fill="currentColor"/>`,
|
||||
"book-marked": `<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z" fill="currentColor"/>`,
|
||||
"book-open": `<path d="M13 21V23H11V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H9C10.1947 3 11.2671 3.52375 12 4.35418C12.7329 3.52375 13.8053 3 15 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H13ZM20 19V5H15C13.8954 5 13 5.89543 13 7V19H20ZM11 19V7C11 5.89543 10.1046 5 9 5H4V19H11Z" fill="currentColor"/>`,
|
||||
"booklet": `<path d="M20.0049 2C21.1068 2 22 2.89821 22 3.9908V20.0092C22 21.1087 21.1074 22 20.0049 22H4V18H2V16H4V13H2V11H4V8H2V6H4V2H20.0049ZM8 4H6V20H8V4ZM20 4H10V20H20V4Z" fill="currentColor"/>`,
|
||||
"braces": `<path d="M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V10.1C6 10.9858 5.42408 11.7372 4.62623 12C5.42408 12.2628 6 13.0142 6 13.9V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V13.9C18 13.0142 18.5759 12.2628 19.3738 12C18.5759 11.7372 18 10.9858 18 10.1V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z" fill="currentColor"/>`,
|
||||
"brain": `<path d="M9 4C10.1046 4 11 4.89543 11 6V12.8271C10.1058 12.1373 8.96602 11.7305 7.6644 11.5136L7.3356 13.4864C8.71622 13.7165 9.59743 14.1528 10.1402 14.7408C10.67 15.3147 11 16.167 11 17.5C11 18.8807 9.88071 20 8.5 20C7.11929 20 6 18.8807 6 17.5V17.1493C6.43007 17.2926 6.87634 17.4099 7.3356 17.4864L7.6644 15.5136C6.92149 15.3898 6.1752 15.1144 5.42909 14.7599C4.58157 14.3573 4 13.499 4 12.5C4 11.6653 4.20761 11.0085 4.55874 10.5257C4.90441 10.0504 5.4419 9.6703 6.24254 9.47014L7 9.28078V6C7 4.89543 7.89543 4 9 4ZM12 3.35418C11.2671 2.52376 10.1947 2 9 2C6.79086 2 5 3.79086 5 6V7.77422C4.14895 8.11644 3.45143 8.64785 2.94126 9.34933C2.29239 10.2415 2 11.3347 2 12.5C2 14.0652 2.79565 15.4367 4 16.2422V17.5C4 19.9853 6.01472 22 8.5 22C9.91363 22 11.175 21.3482 12 20.3287C12.825 21.3482 14.0864 22 15.5 22C17.9853 22 20 19.9853 20 17.5V16.2422C21.2044 15.4367 22 14.0652 22 12.5C22 11.3347 21.7076 10.2415 21.0587 9.34933C20.5486 8.64785 19.8511 8.11644 19 7.77422V6C19 3.79086 17.2091 2 15 2C13.8053 2 12.7329 2.52376 12 3.35418ZM18 17.1493V17.5C18 18.8807 16.8807 20 15.5 20C14.1193 20 13 18.8807 13 17.5C13 16.167 13.33 15.3147 13.8598 14.7408C14.4026 14.1528 15.2838 13.7165 16.6644 13.4864L16.3356 11.5136C15.034 11.7305 13.8942 12.1373 13 12.8271V6C13 4.89543 13.8954 4 15 4C16.1046 4 17 4.89543 17 6V9.28078L17.7575 9.47014C18.5581 9.6703 19.0956 10.0504 19.4413 10.5257C19.7924 11.0085 20 11.6653 20 12.5C20 13.499 19.4184 14.3573 18.5709 14.7599C17.8248 15.1144 17.0785 15.3898 16.3356 15.5136L16.6644 17.4864C17.1237 17.4099 17.5699 17.2926 18 17.1493Z" fill="currentColor"/>`,
|
||||
"brain-4": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227L12.999 8.42285L15.9639 10.1338L14.9639 11.8662L11 9.57715V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287L11.001 15.5771L8.03613 13.8652L9.03613 12.1338L13.001 14.4229V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227Z" fill="currentColor"/>`,
|
||||
"brain-ai-3": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227V7H11V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287V17H13V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227ZM14.2646 13.1602C14.3529 12.9473 14.6472 12.9473 14.7354 13.1602L14.8623 13.4648C15.0783 13.986 15.4807 14.4027 15.9873 14.6279L16.3457 14.7871C16.5511 14.8784 16.5511 15.1773 16.3457 15.2686L15.9658 15.4375C15.4721 15.6571 15.0761 16.0586 14.8564 16.5625L14.7334 16.8447C14.6432 17.0517 14.3569 17.0517 14.2666 16.8447L14.1436 16.5625C13.9239 16.0586 13.5279 15.6571 13.0342 15.4375L12.6543 15.2686C12.4489 15.1773 12.4489 14.8784 12.6543 14.7871L13.0127 14.6279C13.5193 14.4027 13.9217 13.986 14.1377 13.4648L14.2646 13.1602ZM9.58789 7.7793C9.74239 7.40671 10.2577 7.4067 10.4121 7.7793L10.6338 8.31445C11.0118 9.22695 11.7161 9.95624 12.6025 10.3506L13.2305 10.6289C13.5899 10.7887 13.5897 11.3117 13.2305 11.4717L12.5654 11.7676C11.7013 12.152 11.0086 12.8548 10.624 13.7373L10.4082 14.2324C10.2504 14.5948 9.74973 14.5948 9.5918 14.2324L9.37598 13.7373C8.99143 12.8548 8.29875 12.152 7.43457 11.7676L6.76953 11.4717C6.41033 11.3117 6.41022 10.7887 6.76953 10.6289L7.39746 10.3506C8.2839 9.95624 8.98832 9.22697 9.36621 8.31445L9.58789 7.7793Z" fill="currentColor"/>`,
|
||||
"briefcase": `<path d="M7 5V2C7 1.44772 7.44772 1 8 1H16C16.5523 1 17 1.44772 17 2V5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V6C2 5.44772 2.44772 5 3 5H7ZM4 16V19H20V16H4ZM4 14H20V7H4V14ZM9 3V5H15V3H9ZM11 11H13V13H11V11Z" fill="currentColor"/>`,
|
||||
"bug": `<path d="M13 19.9C15.2822 19.4367 17 17.419 17 15V12C17 11.299 16.8564 10.6219 16.5846 10H7.41538C7.14358 10.6219 7 11.299 7 12V15C7 17.419 8.71776 19.4367 11 19.9V14H13V19.9ZM5.5358 17.6907C5.19061 16.8623 5 15.9534 5 15H2V13H5V12C5 11.3573 5.08661 10.7348 5.2488 10.1436L3.0359 8.86602L4.0359 7.13397L6.05636 8.30049C6.11995 8.19854 6.18609 8.09835 6.25469 8H17.7453C17.8139 8.09835 17.88 8.19854 17.9436 8.30049L19.9641 7.13397L20.9641 8.86602L18.7512 10.1436C18.9134 10.7348 19 11.3573 19 12V13H22V15H19C19 15.9534 18.8094 16.8623 18.4642 17.6907L20.9641 19.134L19.9641 20.866L17.4383 19.4077C16.1549 20.9893 14.1955 22 12 22C9.80453 22 7.84512 20.9893 6.56171 19.4077L4.0359 20.866L3.0359 19.134L5.5358 17.6907ZM8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6H8Z" fill="currentColor"/>`,
|
||||
@@ -51,6 +53,7 @@ export const iconSpriteData = {
|
||||
"checkbox-blank-circle-fill": `<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor"/>`,
|
||||
"checkbox-circle": `<path d="M4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12ZM12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM17.4571 9.45711L16.0429 8.04289L11 13.0858L8.20711 10.2929L6.79289 11.7071L11 15.9142L17.4571 9.45711Z" fill="currentColor"/>`,
|
||||
"checkbox-multiple": `<path d="M6.99979 7V3C6.99979 2.44772 7.4475 2 7.99979 2H20.9998C21.5521 2 21.9998 2.44772 21.9998 3V16C21.9998 16.5523 21.5521 17 20.9998 17H17V20.9925C17 21.5489 16.551 22 15.9925 22H3.00728C2.45086 22 2 21.5511 2 20.9925L2.00276 8.00748C2.00288 7.45107 2.4518 7 3.01025 7H6.99979ZM8.99979 7H15.9927C16.549 7 17 7.44892 17 8.00748V15H19.9998V4H8.99979V7ZM15 9H4.00255L4.00021 20H15V9ZM8.50242 18L4.96689 14.4645L6.3811 13.0503L8.50242 15.1716L12.7451 10.9289L14.1593 12.3431L8.50242 18Z" fill="currentColor"/>`,
|
||||
"claude-code": `<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" fill="currentColor"/>`,
|
||||
"clipboard": `<path d="M7 4V2H17V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7ZM7 6H5V20H19V6H17V8H7V6ZM9 4V6H15V4H9Z" fill="currentColor"/>`,
|
||||
"close": `<path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z" fill="currentColor"/>`,
|
||||
"close-circle": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 10.5858L14.8284 7.75736L16.2426 9.17157L13.4142 12L16.2426 14.8284L14.8284 16.2426L12 13.4142L9.17157 16.2426L7.75736 14.8284L10.5858 12L7.75736 9.17157L9.17157 7.75736L12 10.5858Z" fill="currentColor"/>`,
|
||||
@@ -62,11 +65,12 @@ export const iconSpriteData = {
|
||||
"code-sslash": `<path d="M24 12L18.3431 17.6569L16.9289 16.2426L21.1716 12L16.9289 7.75736L18.3431 6.34315L24 12ZM2.82843 12L7.07107 16.2426L5.65685 17.6569L0 12L5.65685 6.34315L7.07107 7.75736L2.82843 12ZM9.78845 21H7.66009L14.2116 3H16.3399L9.78845 21Z" fill="currentColor"/>`,
|
||||
"collapse-vertical": `<path d="M11.9995 13.4995 16.9492 18.4493 15.535 19.8635 12.9995 17.3279 12.9995 22.9995H10.9995L10.9995 17.3279 8.46643 19.861 7.05222 18.4468 11.9995 13.4995ZM10.9995.999512 10.9995 6.67035 8.46448 4.13535 7.05026 5.54956 12 10.4995 16.9497 5.54977 15.5355 4.13555 12.9995 6.67157V.999512L10.9995.999512Z" fill="currentColor"/>`,
|
||||
"command": `<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"/>`,
|
||||
"command-code": `<path fill="currentColor" d="M5.8 5.8h4.8v4.8h-4.8Z M13.4 5.8h4.8v4.8h-4.8Z M10.6 10.6h2.8v2.8h-2.8Z M5.8 13.4h4.8v4.8h-4.8Z M13.4 13.4h4.8v4.8h-4.8Z"/>`,
|
||||
"compass-3": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM16.5 7.5L14 14L7.5 16.5L10 10L16.5 7.5ZM12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z" fill="currentColor"/>`,
|
||||
"computer": `<path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" fill="currentColor"/>`,
|
||||
"contract-up-down": `<path d="M5.79285 5.20718 12 11.4143 18.2071 5.20718 16.7928 3.79297 12 8.58586 7.20706 3.79297 5.79285 5.20718ZM18.2072 18.7928 12.0001 12.5857 5.793 18.7928 7.20721 20.207 12.0001 15.4141 16.793 20.207 18.2072 18.7928Z" fill="currentColor"/>`,
|
||||
"corner-down-left": `<path d="M19.0001 13.9999L19.0002 5L17.0002 4.99997L17.0001 11.9999L6.8283 12L10.778 8.05024L9.36382 6.63603L2.99986 13L9.36382 19.364L10.778 17.9497L6.82826 14L19.0001 13.9999Z" fill="currentColor"/>`,
|
||||
"cursor": `<path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z" fill="currentColor"/>`,
|
||||
"cursor": `<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" fill="currentColor"/>`,
|
||||
"database-2": `<path d="M5 12.5C5 12.8134 5.46101 13.3584 6.53047 13.8931C7.91405 14.5849 9.87677 15 12 15C14.1232 15 16.0859 14.5849 17.4695 13.8931C18.539 13.3584 19 12.8134 19 12.5V10.3287C17.35 11.3482 14.8273 12 12 12C9.17273 12 6.64996 11.3482 5 10.3287V12.5ZM19 15.3287C17.35 16.3482 14.8273 17 12 17C9.17273 17 6.64996 16.3482 5 15.3287V17.5C5 17.8134 5.46101 18.3584 6.53047 18.8931C7.91405 19.5849 9.87677 20 12 20C14.1232 20 16.0859 19.5849 17.4695 18.8931C18.539 18.3584 19 17.8134 19 17.5V15.3287ZM3 17.5V7.5C3 5.01472 7.02944 3 12 3C16.9706 3 21 5.01472 21 7.5V17.5C21 19.9853 16.9706 22 12 22C7.02944 22 3 19.9853 3 17.5ZM12 10C14.1232 10 16.0859 9.58492 17.4695 8.89313C18.539 8.3584 19 7.81342 19 7.5C19 7.18658 18.539 6.6416 17.4695 6.10687C16.0859 5.41508 14.1232 5 12 5C9.87677 5 7.91405 5.41508 6.53047 6.10687C5.46101 6.6416 5 7.18658 5 7.5C5 7.81342 5.46101 8.3584 6.53047 8.89313C7.91405 9.58492 9.87677 10 12 10Z" fill="currentColor"/>`,
|
||||
"delete-bin": `<path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z" fill="currentColor"/>`,
|
||||
"discord-fill": `<path d="M19.3034 5.33716C17.9344 4.71103 16.4805 4.2547 14.9629 4C14.7719 4.32899 14.5596 4.77471 14.411 5.12492C12.7969 4.89144 11.1944 4.89144 9.60255 5.12492C9.45397 4.77471 9.2311 4.32899 9.05068 4C7.52251 4.2547 6.06861 4.71103 4.70915 5.33716C1.96053 9.39111 1.21766 13.3495 1.5891 17.2549C3.41443 18.5815 5.17612 19.388 6.90701 19.9187C7.33151 19.3456 7.71356 18.73 8.04255 18.0827C7.41641 17.8492 6.82211 17.5627 6.24904 17.2231C6.39762 17.117 6.5462 17.0003 6.68416 16.8835C10.1438 18.4648 13.8911 18.4648 17.3082 16.8835C17.4568 17.0003 17.5948 17.117 17.7434 17.2231C17.1703 17.5627 16.576 17.8492 15.9499 18.0827C16.2789 18.73 16.6609 19.3456 17.0854 19.9187C18.8152 19.388 20.5875 18.5815 22.4033 17.2549C22.8596 12.7341 21.6806 8.80747 19.3034 5.33716ZM8.5201 14.8459C7.48007 14.8459 6.63107 13.9014 6.63107 12.7447C6.63107 11.5879 7.45884 10.6434 8.5201 10.6434C9.57071 10.6434 10.4303 11.5879 10.4091 12.7447C10.4091 13.9014 9.57071 14.8459 8.5201 14.8459ZM15.4936 14.8459C14.4535 14.8459 13.6034 13.9014 13.6034 12.7447C13.6034 11.5879 14.4323 10.6434 15.4936 10.6434C16.5442 10.6434 17.4038 11.5879 17.3825 12.7447C17.3825 13.9014 16.5548 14.8459 15.4936 14.8459Z" fill="currentColor"/>`,
|
||||
@@ -159,6 +163,7 @@ export const iconSpriteData = {
|
||||
"lock-unlock": `<path d="M7 10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H5V9C5 5.13401 8.13401 2 12 2C14.7405 2 17.1131 3.5748 18.2624 5.86882L16.4731 6.76344C15.6522 5.12486 13.9575 4 12 4C9.23858 4 7 6.23858 7 9V10ZM5 12V20H19V12H5ZM10 15H14V17H10V15Z" fill="currentColor"/>`,
|
||||
"loop-right-ai": `<path d="M22 12C22 17.5228 17.5228 22 12 22C8.72774 22 5.82382 20.4286 4 18.001V20.5H2V14.5H8V16.5H5.38477C6.82543 18.6137 9.25151 20 12 20C16.4183 20 20 16.4183 20 12H22ZM11.5293 8.31934C11.7059 7.8935 12.2943 7.89349 12.4707 8.31934L12.7236 8.93066C13.1556 9.97346 13.9615 10.8062 14.9746 11.2568L15.6924 11.5762C16.1026 11.759 16.1026 12.3562 15.6924 12.5391L14.9326 12.877C13.9449 13.3162 13.1534 14.1194 12.7139 15.1279L12.4668 15.6934C12.2864 16.1075 11.7137 16.1075 11.5332 15.6934L11.2871 15.1279C10.8476 14.1193 10.0552 13.3163 9.06738 12.877L8.30762 12.5391C7.89744 12.3562 7.89741 11.759 8.30762 11.5762L9.02539 11.2568C10.0385 10.8062 10.8445 9.97348 11.2764 8.93066L11.5293 8.31934ZM12 2C15.2723 2 18.1762 3.57144 20 5.99902V3.5H22V9.5H16V7.5H18.6152C17.1746 5.38634 14.7485 4 12 4C7.58172 4 4 7.58172 4 12H2C2 6.47715 6.47715 2 12 2Z" fill="currentColor"/>`,
|
||||
"macbook": `<path d="M4 5V16H20V5H4ZM2 4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V18H2V4.00748ZM1 19H23V21H1V19Z" fill="currentColor"/>`,
|
||||
"markup": `<path d="M10 10.4967L11.0385 6.86204C11.1902 6.331 11.7437 6.02351 12.2747 6.17523C12.6069 6.27015 12.8666 6.52983 12.9615 6.86204L14 10.4967V11.9967H14.7192C15.1781 11.9967 15.5781 12.309 15.6894 12.7542L17.051 18.2008C18.8507 16.7339 20 14.4995 20 11.9967C20 7.57843 16.4183 3.9967 12 3.9967C7.58172 3.9967 4 7.57843 4 11.9967C4 14.4995 5.14932 16.7339 6.94897 18.2008L8.31063 12.7542C8.42193 12.309 8.82191 11.9967 9.28078 11.9967H10V10.4967ZM12 19.9967C12.2415 19.9967 12.4813 19.986 12.7189 19.9649C13.6187 19.8847 14.4756 19.6556 15.2649 19.3024L13.9384 13.9967H10.0616L8.73514 19.3024C9.52438 19.6556 10.3813 19.8847 11.2811 19.9648C11.5187 19.986 11.7585 19.9967 12 19.9967ZM12 21.9967C6.47715 21.9967 2 17.5196 2 11.9967C2 6.47386 6.47715 1.9967 12 1.9967C17.5228 1.9967 22 6.47386 22 11.9967C22 17.5196 17.5228 21.9967 12 21.9967Z" fill="currentColor"/>`,
|
||||
"menu-2": `<path d="M3 4H21V6H3V4ZM3 11H15V13H3V11ZM3 18H21V20H3V18Z" fill="currentColor"/>`,
|
||||
"menu-fold-2": `<path d="M4.40347 3.90332L2.98926 5.31753L6.17124 8.49951L2.98926 11.6815L4.40347 13.0957L8.99967 8.49951L4.40347 3.90332ZM20.9997 19.9995V17.9995H2.99967V19.9995H20.9997ZM20.9997 12.9995V10.9995H11.9997V12.9995H20.9997ZM20.9997 5.99951V3.99951H11.9997V5.99951H20.9997Z" fill="currentColor"/>`,
|
||||
"menu-search": `<path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z" fill="currentColor"/>`,
|
||||
@@ -225,6 +230,7 @@ export const iconSpriteData = {
|
||||
"target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`,
|
||||
"target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`,
|
||||
"task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`,
|
||||
"telegram-fill": `<path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM12.3584 9.38246C11.3857 9.78702 9.4418 10.6244 6.5266 11.8945C6.05321 12.0827 5.80524 12.2669 5.78266 12.4469C5.74451 12.7513 6.12561 12.8711 6.64458 13.0343C6.71517 13.0565 6.78832 13.0795 6.8633 13.1039C7.37388 13.2698 8.06071 13.464 8.41776 13.4717C8.74164 13.4787 9.10313 13.3452 9.50222 13.0711C12.226 11.2325 13.632 10.3032 13.7203 10.2832C13.7826 10.269 13.8689 10.2513 13.9273 10.3032C13.9858 10.3552 13.98 10.4536 13.9739 10.48C13.9361 10.641 12.4401 12.0318 11.666 12.7515C11.4351 12.9661 11.2101 13.1853 10.9833 13.4039C10.509 13.8611 10.1533 14.204 11.003 14.764C11.8644 15.3317 12.7323 15.8982 13.5724 16.4971C13.9867 16.7925 14.359 17.0579 14.8188 17.0156C15.0861 16.991 15.3621 16.7397 15.5022 15.9903C15.8335 14.2193 16.4847 10.3821 16.6352 8.80083C16.6484 8.6623 16.6318 8.485 16.6185 8.40717C16.6052 8.32934 16.5773 8.21844 16.4762 8.13635C16.3563 8.03913 16.1714 8.01863 16.0887 8.02009C15.7125 8.02672 15.1355 8.22737 12.3584 9.38246Z" fill="currentColor"/>`,
|
||||
"terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`,
|
||||
"terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`,
|
||||
"terminal-window": `<path d="M20 9V5H4V9H20ZM20 11H4V19H20V11ZM3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM5 12H8V17H5V12ZM5 6H7V8H5V6ZM9 6H11V8H9V6Z" fill="currentColor"/>`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user