Commit Graph

188 Commits

Author SHA1 Message Date
Arvin Xu c3a7642295 ✨ feat(oauth-apps): support web applications with redirect URIs (#19504)
* ✨ feat(oauth-apps): support web applications with redirect URIs

OAuth apps created from the settings page were device-flow only: no
redirect URIs, no response types, and no client secret. That covers CLIs
but leaves no way to register a server-side app that signs users in with
a LobeHub account through the authorization code flow.

Adds a `web` application type alongside the existing device type. A web
app gets `authorization_code` + `refresh_token` grants, a `code` response
type, the redirect URIs its owner registers, and a confidential client
secret issued once at creation with a rotate action afterwards.

The secret is encrypted at rest with `KeyVaultsGateKeeper`, mirroring how
API keys are stored, and decrypted in the OIDC adapter because the token
endpoint compares it in plaintext. A secret that fails to decrypt is
dropped rather than passed through, so the request fails as
`invalid_client` instead of making ciphertext the credential.

Redirect URIs are matched exactly: no wildcards, no fragments, no
embedded credentials, and https everywhere except loopback hosts. The
same rules run in the form and again in the tRPC input, since only the
server-side pass is binding.

No migration: `oidc_clients` already carries every column this needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(oauth-apps): keep the adapter's secret round-trip symmetric

Two follow-ups from review.

`Client.upsert` still wrote the secret in plaintext while `find` now
expects ciphertext, so anything persisting a client through the adapter
would read back a credential it could not authenticate with. Encrypt on
the way in too, and leave the stored secret untouched when an update
payload omits it. The conflict branch also referenced a camelCase key
that never existed on the payload, so it silently wrote nothing.

An `update` carrying an empty redirect URI list passed validation and
stripped a web app of every callback, contradicting the create-time
requirement. Reject a list that normalizes to empty wherever one is
supplied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 feat(oauth-apps): address acceptance review on create, detail and consent flows

Application type is now picked from two icon cards that explain each
kind, instead of a bare segmented toggle. Redirect URIs move out of the
create dialog and are configured on the app page afterwards; the type
stays at creation because it decides whether a client secret is issued
and cannot change once the app exists. A freshly created web app shows
that it has no redirect URIs yet and cannot sign anyone in until one is
added.

The detail page separates reading from editing. Basic info and redirect
URIs each render read-only with an Edit action, and open a form with
Save and Cancel only on request. Redirect URIs are edited as a list of
rows rather than a newline-separated textarea.

Authorization errors the provider cannot redirect back with, such as an
unregistered redirect URI, now land on a dedicated page with a plain
explanation per error code instead of a raw JSON dump. Only `error` and
`error_description` are forwarded.

The consent and sign-in confirmation cards center their title and
subtitle so the text lines up with the centered application logo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ♿ fix(oauth-apps): label redirect URI row removal and color the list error

The icon-only remove button on each redirect URI row had no accessible
name, so assistive tech announced it as an unlabeled button.

The "at least one redirect URI" message rendered in body color: antd's
ErrorList only picks up error styling inside a Form.Item with an error
status, and the list-level message sits outside one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:25:20 +08:00
Rdmclin2 021bd4b4b3 🐛 fix(editor): rehost pasted document images (#19527)
* 🐛 fix(editor): rehost pasted document images

* 🐛 fix(server): cap remote image fetch size

* 🐛 fix(editor): handle rehosted image URLs

* ✅ test: type happy-dom URL helper
2026-09-14 12:57:30 +08:00
Innei fc346bc4cb 🐛 fix(cli): print wrapped artifact share URL after publish (#19536)
* 🐛 fix(cli): print wrapped artifact share URL after publish

CLI artifact publish was emitting the raw hosting publicUrl. Match the
App by returning the product share link /share/artifact/<deploymentId>.

* ♻️ refactor: drop src/utils/artifactShare re-export

Callers import artifactShareUrl from @lobechat/utils/artifactShare.
2026-09-14 00:07:39 +08:00
Rdmclin2 515f3b8461 🐛 fix(task-slug): keep combining marks and fall back to the instruction (#19519)
* 🐛 fix(task-slug): keep combining marks and fall back to the instruction

`taskTitleSlug` only allowed `\p{L}` and `\p{N}`, so scripts built on combining
marks were shredded one character at a time — `कार्य पूरा करें` became
`क-र-य-प-र-कर`, `สวัสดี` became `สว-สด`. CJK needs no marks, which is why every
existing case read as correct. Add `\p{M}`, and truncate by grapheme cluster so a
cut can neither halve a surrogate pair nor strand a mark without its base letter.

The Recent feed links a task by `COALESCE(name, instruction)` while
`useCanonicalTaskSlug` read `name` alone, so a task with only an instruction had
its slug stripped the moment the detail landed, and the copied link came out
bare. Add a shared `taskSlugTitle` helper mirroring that COALESCE (`??`, so a
name cleared to `''` still collapses the URL) and read it through store
selectors everywhere a task link is built or repaired.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

* 🔒 fix(task-slug): keep prompt bodies out of task URLs and bound the slug

A task's instruction is a prompt body, not a title, so it must not reach a path
segment — from there it lands in browser history, the analytics page view fired
on every history change, access logs, and anything pasted from the clipboard.
That was already true on canary for the Recent feed, which links a task by the
display title (`COALESCE(name, instruction)`); the canonicaliser happened to
strip it on arrival. Rather than making that fallback stick everywhere, drop it
from URLs entirely: `slugTitle` carries `tasks.name` alone through the Recent
payload, and the canonicaliser, the copied link and the Portal header all read
`name`. A nameless task simply has no slug, and every surface now agrees on
that instead of rewriting each other.

Grapheme counting is also not a size bound. A single cluster accepts an
unlimited run of combining marks, so `a` plus 10k accents stayed one grapheme
and produced a ~20KB segment, past what browsers and proxies accept in a
request line — and titles arrive from imported and generated content, not only
from typing. Keep whole clusters, but stop at a code-point ceiling as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

* 🌐 chore: update i18n

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

* 🐛 fix(recents): move a task's slug source with an inline rename

Splitting the link's slug source out of the display title left the two able to
drift: renaming a task from Home updates `title` optimistically and on commit,
but `slugTitle` kept the server's old name, so the row went on linking to the
previous title's slug until the next sync. That is the same
link-vs-canonicaliser disagreement this feature set out to remove, reintroduced
one field over.

A rename sets `tasks.name`, which is exactly what `slugTitle` carries, so both
the committed reducer path and the optimistic selector overlay now move it with
the title. Topics and documents have no slug source and keep theirs null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:55:01 +08:00
Rdmclin2 35098de372 ✨ feat: add readable task URL slugs and workspace seat copy (#19503)
* ✨ feat: add readable task URL slugs and workspace seat copy

Task detail URLs gain a cosmetic `/task/:taskId/:slug` segment. The slug is
built from the task title and never participates in resolution — `taskId`
stays the only key — so stale or hand-edited slugs still resolve, and
`useCanonicalTaskSlug` rewrites the URL to the current title.

Also bundles the workspace seat locale strings for pending-invite seat
accounting, and the knowledge base library visibility confirmation copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WtR156jiuRTA4aa5eBZUL

* 🐛 fix(document): scope the library mirror update to the caller's own file

`setVisibility` mirrors a page's visibility onto its paired `files` row, but
scoped the update by file id and workspace alone. `fileId` is a plain foreign
key and a document can point at a file somebody else uploaded — parsing another
member's shared file yields a caller-owned document that keeps the original
`fileId` — so taking such a derived page private also hid the uploader's
original from the whole workspace.

Require `files.userId` to match the caller, the same guard the `documents`
update in this transaction already applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WtR156jiuRTA4aa5eBZUL

* 🐛 fix(task): collapse the task URL when the title is cleared

`useCanonicalTaskSlug` gated on the title itself, so a resolved task whose name
is empty looked identical to one whose detail had not loaded yet. Clearing a
task title persists `name: ''` (the input saves the raw value), which left the
URL pinned to the slug of the title that had just been deleted.

Read loaded-ness separately from the title so an empty name is honoured and the
URL collapses back to `/task/:taskId`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WtR156jiuRTA4aa5eBZUL

* 🐛 fix(task): stop the My tasks board from hiding automated tasks

The "My tasks" board copied `automated: false` from the boards around it, but
its list view sends no automation filter at all. A scheduled or heartbeat task
assigned to the caller therefore disappeared the moment they switched that same
collection from list to board — the one place the two views must agree.

Extracts the scope-to-query mapping into `buildKanbanGroupQuery` so the rule is
stated once and covered by unit tests instead of living in a nested ternary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WtR156jiuRTA4aa5eBZUL

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 12:32:03 +08:00
Innei 6b85e332f1 🐛 fix(desktop): pin bundled npm deps through the Desktop lockfile (#19462)
* 🐛 fix(desktop): pin bundled npm deps through the Desktop lockfile

The main hash accepted any npm module that happened to resolve inside apps/desktop/node_modules, so a module linked from outside the frozen install (or from a stale store entry) could enter the renderer OTA hash without the lockfile ever pinning it.

Resolve each bundled module to its package name@version and require apps/desktop/pnpm-lock.yaml to pin it, keeping the file:/link:/git/tarball escape hatch for specifiers that pin by resolution instead of version.

partial-json is imported by packages/utils and packages/context-engine but was never declared, so the Desktop lockfile could not pin it: declare it in both packages and record it in apps/desktop/pnpm-lock.yaml.

* 🔊 chore(desktop): name the changed bundles before a dev restart

The dev orchestrator logged only that a main/preload bundle changed, which cannot distinguish the debounced restart from an unrelated vite rebuild. Collect every file reported by the watcher during the debounce window and print it with its event type, size and mtime.

* 📝 docs(acceptance): publish the round from the session's own observations

A delivery verified on the real product earlier in the session still had to re-run the plan and checker stage to obtain a published round. Document that those observations and artifacts are the evidence: write plan[] and cases[] from them, disclose anything never captured, and ingest.
2026-09-12 12:35:36 +08:00
Innei aba9e8150c 👷 build: migrate the test suite to vitest 5 (#19355)
* 👷 build: upgrade vitest to 5.0.0

Bump vitest and @vitest/coverage-v8 to 5.0.0 and migrate the configs for Vitest 4/5: drop the removed coverage.all option and replace poolOptions.forks.singleFork with maxWorkers/isolate.

* 👷 build: shim jest-dom matcher types for vitest 5

Vitest 5 exposes Assertion<R, T> and no longer reads the jest.Matchers augmentation that @testing-library/jest-dom ships, so its matchers vanish from expect(). Re-declare them on the Vitest interface, and replace the removed describe.sequential with { concurrent: false }.

* 👷 build: migrate the test suite to vitest 5

Vitest 5 (and @vitest/coverage-v8) replaces the esbuild dep optimizer with Rolldown, removed environmentMatchGlobs/coverage.all/poolOptions, forwards `new` to mock implementations, requires top-level vi.mock, and no longer resets automocks in restoreAllMocks. Migrate the root config to app/server projects with Rolldown optimizer options, shim the jest-dom matchers on Assertion<R, T>, and update the affected tests (constructor mocks, hoisted mocks, awaited assertions, explicit mock types, per-test mock defaults).

* 👷 build: make the openapi knowledge-base FileService mock constructable

tests/ was outside the per-area sweep; vitest 5 forwards `new` to the mock implementation, so the arrow implementation threw "is not a constructor".

* 👷 build: point the CI blob-report artifacts at the vitest 5 output dir

Vitest 5 writes blob reports to .vitest/blob instead of .vitest-reports, so the sharded jobs uploaded nothing and the merge jobs failed with ENOENT. Track the new directory in .gitignore too.

* 👷 build: refresh the desktop lockfile for vitest 5

apps/desktop/pnpm-lock.yaml is committed and the desktop build action installs with --frozen-lockfile, so the vitest/override change has to be reflected in it.

* 👷 build: keep the pre-vitest-4 coverage exclusions explicit

Vitest 4+ ships an empty coverageConfigDefaults.exclude, so configs that spread it silently lost the default exclusions (node_modules, dist, *.config.*) and counted more files. Inline the previous list.

* 🎨 style: format the migrated vitest configs

* 👷 build: keep canary's new class mocks constructable under vitest 5

exploreGraph/dispatchAuthorization/scopedDevices (canary #19375, #19352) mock classes with arrow implementations; vitest 5 constructs the implementation when the mock is newed, so the arrows throw 'is not a constructor'.
2026-09-10 22:18:49 +08:00
Arvin Xu f4a39da7d9 ✨ feat(goal): let exploration revise a flawed protocol instead of only branching (#19375)
* ✨ feat(goal): let exploration revise a flawed protocol instead of only branching

The exploration planner could only `expand` or `verify`, so when an experiment
measured the wrong thing its only move was a sibling that inherited the same
flawed instrument. It also had no way to tell which experiments a previous turn
authored, so it could not see that its last branch had made things worse.

- Add a `revise` action that re-runs one experiment with a corrected protocol
  inside the parent's container, without consuming an experiment slot.
- Bound revisions per target via `derived_from` edges so a planner cannot keep
  polishing one instrument instead of changing the question.
- Pass `derivedFromId` to the planner so it can compare a derived experiment
  against its parent before repeating that direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): separate a protocol correction from an ordinary branch

Codex review found three ways the revision path and the expansion path were
conflated, because both write a `derived_from` edge.

- The revision cap counted every `derived_from` edge at the target, so an
  experiment with two ordinary branches was refused its first correction. Count
  only edges a task sourced, which is what a correction is.
- A standalone correction read back as an experiment: it spent an experiment
  slot and could then be revised as a fresh target with an empty budget, an
  unbounded chain. Exclude corrections from the experiment selector on both the
  planner and the apply side so the two see the same graph.
- A dispatched correction resolved its provenance through its container, which
  carries the container's own branch parent, so the run received the results of
  an older experiment. Prefer the node's own provenance edge.

The lookup moves to `provenanceParentId` in `@lobechat/utils/goalGraph` next to
the two new predicates, so the rule is unit-testable rather than inline in a
600-line dispatch path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): keep a correction's evidence and stop a spent allowance pausing the goal

Two consequences of the previous commit, both found in review.

- Excluding corrections from the experiment selector also hid their results.
  `experimentResults` only walked containment, so a standalone correction's
  findings were folded into neither itself nor the experiment it corrected, and
  the next exploration turn evaluated pre-correction evidence only. Gather an
  `experimentScope` that includes the corrections aimed at a node, and use it for
  produced Work versions too.
- A spent revision allowance threw, and `exploreGraph` treats a throw as a
  planner crash: it paused the whole goal, and resuming could reach the same
  choice again because nothing told the planner the allowance was gone. Return it
  as an outcome that re-plans, and put `revisionsRemaining` in the planner input
  so the choice is observable before it is made.

`MAX_PROTOCOL_REVISIONS` moves next to the revision predicates in
`@lobechat/utils/goalGraph`, since both the model and the planner input need it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): give corrections a container, a released lease and a documented contract

Four more from review, all consequences of the correction path being bolted onto
a graph shaped for branches.

- A spent allowance returned without clearing the checkpoint, so the re-plan it
  asked for was refused by the next claim until the lease timed out. Release it
  like every other terminal path.
- A correction of an uncontained seed was another root task: invisible to the
  planner, unable to inherit the previous correction's Work, and read back as a
  slot-consuming experiment by an older release. Require an experiment container
  and let those goals expand first, which removes all three at once. Both apply
  paths now gather `experimentScope`, so a correction's Work carries forward.
- `revisionsRemaining` reports zero for a node that cannot hold a correction,
  rather than inviting a choice the apply step would refuse.
- `EXPLORATION.md` documented two actions. It now covers the third, the
  slot-free guarantee, the per-experiment bound and why corrections are serial.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): record corrections on their own edge kind

Review found `derived_from` was the wrong carrier. It is generic provenance that
predates this feature, `goal.addEdge` accepts it, `createEdge` puts no
task-source restriction on it, and an existing dispatch test authors exactly that
shape. Treating any task-sourced one as a correction therefore reinterpreted
hand-authored provenance: those tasks vanished from planner history and slot
counting while their results were attributed to another node.

Corrections now use a dedicated `revises` kind, written only by the coordinator
and deliberately absent from the public `addEdge` vocabulary, so the
discriminator is unambiguous and nothing existing changes meaning. The column is
plain text, so no migration is involved.

Also stop replanning on a rejected revision. Returning `advanced` re-invoked the
planner with the same graph and the same `revisionsRemaining: 0`, which could
spend an advance's whole tick budget repeating one question; the advance now ends
and the goal stays running for the next wakeup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 📝 docs(goal): note the dedicated revises edge in the exploration contract

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): park a goal whose planner asks past a spent revision allowance

Review found ending only the current advance left the goal running with an
unchanged graph, so every sweep could buy another identical planning call
indefinitely, and enough such goals would crowd the sweep's bounded scan. The
previous round's `advanced` had the same defect inside one advance.

Park it instead, the way an exhausted experiment budget already does:
`pausedBy: 'exploration_revision_limit'`, a recorded status transition naming the
target and the count, and `no_progress` carrying that reason. Resuming replans.
The new reason is deliberately not treated as budget-stopped, so raising a budget
does not silently reopen a goal that needs a different plan.

Also documents the rollback shape for a queued correction: the previous
coordinator resolves provenance through the containing experiment, so a rolled
back deployment dispatches it with that experiment's branch parent instead of the
one it corrects. The corrected protocol lives in the Task description and
survives, so the loss is context rather than a wrong instruction; the note says
to drain queued corrections first if that matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:12:47 +08:00
Arvin Xu c04405343a ✨ feat(goal): add nested experiment answers and exploration navigation (#19261)
* ✨ feat(goal): add nested experiment answers and exploration navigation

* 📝 docs(goal): correct exploration budget command

* 🐛 fix(cli): isolate dispatched agent run identity

* 🐛 fix: register CLI notebook reports as operation deliverables

* 🐛 fix: avoid signing notebook origins as file URLs

* 🐛 fix(cli): retain bounded event backlog across temporary outages
2026-09-08 17:09:07 +08:00
Arvin Xu 5512942ba4 🐛 fix: clamp message plugin identifier/apiName to 255 chars at every write path (#19002)
* 🐛 fix: clamp message plugin identifier/apiName to 255 chars at every write path

Hallucinated tool calls have written multi-KB values (artifact XML, shell
commands) into message_plugins.identifier / api_name. With those columns now
btree-indexed, any value past the index tuple size limit fails the whole row
insert, so every write path clamps before hitting the database.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ✅ test: cover message plugin identifier clamping

* ✅ test: include plugin arguments in clamp fixture

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 22:57:51 +08:00
Arvin Xu 5590527309 ✨ feat(file): support .v / .sv (Verilog / SystemVerilog) attachments (#19118)
* ✨ feat(file): support .v / .sv (Verilog / SystemVerilog) attachments

Wire HDL source files through the full attachment chain so .v/.sv files
upload, parse, and reach the model like other text code attachments:

- uploadGuard: add `v` / `sv` to the chat document-extension whitelist so
  plain-chat uploads are no longer rejected
- utils/mimeType: map `.v` → `text/x-verilog`, `.sv` → `text/x-systemverilog`
  in CUSTOM_MIME_TYPES (mime-db has no entries for either, so they used to
  resolve to `application/octet-stream`)
- file-loaders: add `v` / `sv` to TEXT_READABLE_FILE_TYPES so the server
  DocumentService.parseFile dispatch uses TextLoader instead of throwing
  UnsupportedFileTypeError — this is what empties the file content injected
  into the model context via filesPrompts
- FileViewer: route `.v` / `.sv` into the Code renderer (extension entries
  plus bare tokens and text/x-* mimes in CODE_MIME_TYPES) instead of the
  NotSupport view
- fileLanguage: map `.v` → `verilog`, `.sv` → `system-verilog`. The shiki
  bundled grammar id is `system-verilog`; `systemverilog` is not a bundled
  id/alias and would silently degrade highlighting to plaintext

Tests: upload admission (empty/octet-stream/uppercase mime), HDL mime
resolution with charset, loadFile content extraction from real .v/.sv
fixtures, filesPrompts context injection, shiki grammar id validity,
token-accounting text-like classification, and lookalike-extension
negatives (.vsv/.vv). Existing behavior for other types unchanged
(.zip still rejected, .pdf still uses the dedicated loader).

* refactor(file): route FileViewer HDL entries through the shared verilogSupport module

Address codex review on PR #19118:

- FileViewer CODE_EXTENSIONS / CODE_MIME_TYPES now spread the canonical
  VERILOG_FILE_EXTENSIONS / VERILOG_FILE_MIME_TYPES from ./verilogSupport
  instead of keeping private copies, so the regression test protects the
  shipped routing — dropping an entry from either side fails the suite
  instead of silently regressing .v/.sv previews to NotSupport.
- verilogSupport: the guard now matches the same text/x-* mime tokens the
  production router consumes, and the canonical entries are exported as
  the single source of truth.
- uploadGuard.test: drop the duplicated upload-admission test block
  (identical purpose and assertions as the preceding test).

* fix(file): apply chat-side subtree patch missed in previous commit

* fix(file): correct corrupted verilogSupport blob (sv entry, mime list literal)

* 💄 style(file): fix import order in FileViewer (simple-import-sort)

CI lint failed on simple-import-sort/imports — the verilogSupport import
inserted via the review fix sat above the './NotSupport' block. eslint
autofix moves it to its sorted position after './Renderer/Video'.
2026-09-04 09:57:30 +08:00
Arvin Xu f23693b121 ♻️ refactor(acceptance): converge agent-testing into the acceptance skill (#18830)
* 🔧 refactor(cli): commit the installed acceptance skill instead of ignoring it

`lh acceptance install` used to write the materialized skill into a nested
`.agents/skills/.gitignore`, keeping it out of the consuming repo's history.
That made every skill change invisible to review and impossible to pin to a
commit.

The skill is a reviewable team asset like any other file, so installing it no
longer touches any ignore file: `ensureSkillIgnored` and the `--no-gitignore`
flag are gone, and wiring only creates the harness symlink.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(acceptance): make GOMS-KLM interaction cost a platform-owned measurement

Interaction cost used to be computed by a skill-local analyzer script: the
timing model lived in the driver, so a published number could not be checked
against its trace and two rounds were not necessarily priced the same way.

Split the seam. A UI driver now records only raw operator counts into
`interaction-trace.jsonl`; `summarizeKlmTrace` in `@lobechat/utils` is the one
place counts become seconds, against the timing model pinned in
`@lobechat/const/verify`. `lh acceptance run ingest` picks the trace up from the
report directory and prices it itself, so no analyze step can be forgotten and
every round is comparable.

Interaction cost stays an optional overlay. No trace — a CLI round, or a machine
without `agent-browser` — publishes exactly as before, silently. A trace whose
actions were all blocked (the shape a missing driver produces) yields no cost
rather than a 0s journey, and an explicit `result.json.interactionCost` is left
untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ♻️ refactor(acceptance): converge agent-testing into the acceptance skill + project layer

Two skills described the same job. `agent-testing` (repo-local, 5.4k lines) and
the portable `acceptance` skill each carried their own copy of the report schema,
the agent-browser reference, and the CLI/web/Electron surface guides — already
drifting apart while both fed the same `lh acceptance run ingest`.

Collapse them into one skill plus one project layer:

- `acceptance` owns the contract — what a check is, what evidence counts, what a
  valid round is — and now also the generic living logs and the project-adapter
  contract. Its entry point reads `.agents/acceptance/` first when the repository
  has one, and its triggers absorbed agent-testing's ('local test', 'test in
  electron', …) so no invocation loses a route.
- `.agents/acceptance/` owns how THIS repository runs: the new `PROCESS.md`
  (target grounding, living-log admission check, environment/auth gates, approval
  gate, surface choice, publishing, teardown), plus the capture scripts and macOS
  how-tos that were only ever generic by accident.
- `.agents/skills/acceptance` is a symlink onto the skill source, so in this repo
  the skill cannot drift from what ships.

`agent-testing` is deleted. Its analyzer script goes with it (ingest computes the
cost now); its KLM smoke test keeps asserting operator counts, and the pricing
half of that assertion moved to the shared module's unit test. Consumers —
agent-testing-bot, debug-frontend-with-browser, ux-audit, skills-audit,
heterogeneous-agent, ux — are repointed.

Also drops `skillLocator`, dead since nothing called it and the bundled skill
directory it resolved never existed in this repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(acceptance): version the acceptance skill in its own frontmatter

An installed skill was indistinguishable from any other copy of it: nothing on
disk said which revision a builder was holding, so `install` could only blindly
skip an existing file or blindly overwrite it.

Declare `version` in SKILL.md's frontmatter and parse it from there. The
frontmatter is the single source of truth precisely because that same file is
what gets materialized onto disk — so an installed copy always states the
version it carries, and a later install can compare the two without a side-car
marker that drifts from the content it describes. The version flows out through
`getSkillBundle` and into the install result, which is what a future
version-comparing update needs; this commit stops at making the comparison
possible.

`version` is optional on BuiltinSkill: every other builtin skill stays
unversioned, and an unversioned bundle installs exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🔧 fix(acceptance): stop coupling the skill to host environment variables

The skill told builders to branch on `$LOBE_OPERATION_ID` and `$LOBEHUB_TOPIC_ID`.
Both were wrong:

- `LOBE_OPERATION_ID` exists nowhere in this codebase. An agent following the
  document literally always found it empty, so the entire "two entry points"
  framing rested on a variable that was never set by anything.
- `LOBEHUB_TOPIC_ID` is real but is read by the CLI itself (`subjectFromEnv` /
  `originFromEnv`) to resolve subject and origin. Naming it in the skill invites
  an agent to go hunting for an id it never needs to touch, and couples a
  deliberately portable skill to one host.

The skill now states the actual contract: it needs no ids at all. You have an
operation id only when the invocation hands you one; `--subject` is for when the
caller named a Task/Topic/Document; otherwise ingest attaches the round or
creates a standalone acceptance on its own. Examples use a plain `$OPERATION_ID`
shell variable, which carries no claim about who exports it.

A test now asserts the bundle names no `LOBEHUB_*` variable, so the coupling
cannot quietly return. Version bumped to 0.1.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(acceptance): price a trace even when result.json scaffolds a null cost

Found by running the converged chain end to end, which is the only way it could
have been found: `report-init.sh` writes `"interactionCost": null` to document
the field, and the ingest guard treated mere key presence as "the author already
computed this". So the project's own report scaffolder silently suppressed
pricing on every traced round it created — and no unit test caught it, because a
test fixture is hand-written, not scaffolded.

Only an object now counts as an author-supplied summary. A null is the absence of
a measurement, not an assertion that the round cost nothing.

The same run also showed the shipped `interaction-cost.md` overstating where the
cost appears: it claimed the acceptance page renders it "next to its checks", but
it is a collapsible section of the round report, reachable through owner-only
round history (`canViewAcceptanceHistory = isOwner`) and absent from the shared
aggregate. Corrected to describe the real surface, with an explicit warning not
to promise a reviewer they will see it.

Amends generic mistake M1 with its mirror case: proving a UI section is ABSENT by
grepping page text is worse than the positive case, because an acceptance whose
own goal sentence names the feature matches its own prose. Assert presence or
absence against structured state instead — this run hit exactly that false
positive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(acceptance): show interaction cost to the reviewer, not just the owner

Two fixes for the same root problem — the KLM cost existed but nobody who needed
it could see it.

**The scaffolder wrote the null.** `report-init.sh` emitted
`"interactionCost": null` to document the field, which is what made the previous
commit's ingest guard misfire. Remove it at the source: a scaffolded report now
simply omits the key, and ingest prices its trace. The guard stays as defence in
depth for hand-written reports, but the scaffolder no longer arms it.

**The panel was in the wrong place.** `InteractionCostPanel` lived only in the
round report, which opens from owner-scoped round history
(`canViewAcceptanceHistory = isOwner`). So the one audience the number exists for
— a reviewer weighing whether a flow is worth accepting — could never see it,
while the owner who already knows the cost could. Surface it on the acceptance
page itself, collapsed by default, showing total/active/wait inline and the full
per-phase breakdown on expand. No server or permission change is needed: the cost
already ships in the shared bundle (only `origin` is stripped for visitors).

Phases attributed to a check (`--klm-check` → `phase.checkItemId`) now render
that check's title instead of an internal slug, so the cost reads against the
thing being judged.

Splits `ReportViewer` (1956 → 1537 lines) by extracting the panel, and its pure
pricing/reading helpers into `interactionCostModel.ts` so round selection is
unit-testable without mounting React — the repo does not take new component
tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 fix(acceptance): keep the phase name readable next to its check title

Caught by finally looking at the rendered panel on a seeded local stack: the
check title was `flex: 0 0 auto`, so in a narrow column it squeezed the phase
name out entirely and then clipped itself mid-word. Both phase rows ended up
reading as the same truncated check title, which is worse than no attribution —
the phase is the only thing telling two rows apart.

The phase name now holds its width (capped at 60% of the column) and the check
title is the part that gives way and truncates, with the full pair still in the
row's tooltip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 refactor(acceptance): make interaction cost a quiet disclosure, not a card

The first pass gave it a bordered block with a title, a round pill and three
inline metrics, sitting directly above the check list — the same visual weight as
the acceptance goal card. Supporting detail was competing with the checks for the
attention a reviewer should be spending on the decision itself.

Reduced to the disclosure pattern already used for "逐项验收": a small text button
carrying only the one number worth seeing at a glance, with the full breakdown one
click away. The round moves into the expanded panel's header beside the model id,
where it is read rather than scanned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 fix(acceptance): move interaction cost below the check list

Review feedback on the round-5 evidence, circled on the collapsed row: it does
not belong inside the acceptance check list, it belongs after it.

Right — the list is the decision surface, and a row sitting at its head reads as
one more item to review even when it is styled quietly. Moved below the list,
between the checks and the decision bar, where supporting detail is read after
the thing it supports rather than before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Arvin Xu <arvin.x@lobehub.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:22:38 +08:00
YuTengjing 860bc7d60b 🐛 fix(artwork): normalize reference image sources (#18742) 2026-08-28 23:03:06 +08:00
YuTengjing 8e100aaa3b 🐛 fix: clarify Ox redirect and preserve model prices (#18727) 2026-08-27 00:33:11 +08:00
LiJian bc08dbe17d ♻️ refactor: make API key prefix and CLI product naming distribution slots (#18637)
* ♻️ refactor(cli): collect every product-naming string into one module

The CLI's identity was spread across a dozen files: bin names in
`utils/completion.ts`, the config directory duplicated in five places, the
systemd unit name defined twice, the product name in a handful of command
descriptions, and — least visibly — inside `buildNotifyProtocol`, a prompt
handed to heterogeneous agents that the model then repeats back to the
user.

That is fine for a single distribution and leaves a white-label build with
no seam. Several of these are reached at runtime along paths no UI gating
can intercept, and one of them is a prompt, so there is nothing to gate.

`src/constants/identity.ts` now holds all of them. Values are upstream's,
unchanged, and the default build's observable output is identical —
verified by building with this repo's own config and diffing what the CLI
prints:

    Usage: lh [options] [command]
    LobeHub CLI - manage and connect to LobeHub services
    complete -o nosort -F _lobehub_completion lh lobe lobehub
    lh - LobeHub CLI - manage and connect to LobeHub services

Two things that were already wrong, fixed on the way:

  - `skillLocator.ts` compared `pkg.name` against a hardcoded
    `'@lobehub/cli'`, so the package-root walk breaks for any fork or
    rename. It reads `cliPackageName` now, which is the same value from
    the same package.json.
  - `constants/auth.ts` exposed a single API-key env var name. It is a
    list now, most-preferred first, so a distribution can introduce its
    own prefix without breaking a variable a user already exported. Only
    USER-set variables moved; `LOBEHUB_SERVER`, `LOBEHUB_JWT` and the
    agent-run ids are injected by the desktop app and the server, so
    renaming those needs all three sides to move together and is left
    alone.

`man/roff.ts` also stops hardcoding `LH` and `@lobehub/cli` in the roff
header, taking them from the bin name and `pkg.ts` instead — the man page
described a command that would not exist under any other name.

* ♻️ refactor(api-key): make the key prefix a slot instead of a literal

`sk-lh-` was hardcoded in four places and asserted in four test files. It
is the one brand string a deployment's end users actually handle — they
copy it out of the UI, paste it into CI config, quote it in support
threads — so on a white-label distribution it undoes the rebranding
everywhere else.

It is also load-bearing rather than cosmetic. `validateApiKeyFormat` is
what the OpenAPI auth middleware uses to tell an API key from an OIDC
JWT, so generation and validation must read the same value or every
issued key fails auth as a malformed JWT. That is why the prefix is one
constant now, used by both.

`API_KEY_PREFIX` defaults to `sk-lh-`, so upstream's behaviour is
unchanged — verified by generating a key and round-tripping it through
the validator.

Two deliberate choices in the implementation:

  - Validation compares the prefix with `startsWith` and tests only the
    16-character suffix against a static pattern, rather than
    interpolating the prefix into a RegExp. The prefix is data flowing
    into a pattern, and escaping it correctly is a thing to get wrong;
    this way there is nothing to escape and the check stays correct for
    whatever prefix a distribution picks.
  - The tests derive their fixtures from the constant instead of
    hardcoding a spelling, so they pass for any prefix. Several of them
    fed a literal straight into `validateApiKeyFormat`, so they would
    have gone red the moment anyone overrode it — failing on the
    override rather than on a real defect.

`spec.ts`'s `bearerFormat` follows the constant too; it is the public
API document, so it was advertising a format the deployment no longer
issues.

* ♻️ refactor(cli): collect the completion word-index env var into identity.ts

CLI_COMPLETION_CWORD_ENV joins the other product-naming strings in
identity.ts. `LOBEHUB_COMP_CWORD` was the one string the earlier
identity refactor (a65fa6a9) missed — it's set by the generated
completion script and read right back by `__complete` in the same
process, so it never surfaces in help text and slipped past every
rendered-output check.

Purely internal plumbing (unlike CLI_API_KEY_ENV_NAMES), so no
back-compat list is needed: both sides always come from the one
installed binary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🛡️ fix(api-key): fall back to sk-lh- when a distribution override omits the slot

A downstream `@lobechat/business-const` override replaces the whole module
instead of extending it, so one written before this slot existed simply
omits API_KEY_PREFIX rather than inheriting upstream's default — that
either fails typecheck or, if typecheck is skipped, crashes key generation
and validation on undefined.length.

Resolve it once in packages/utils/apiKey.ts with an explicit runtime
fallback to 'sk-lh-', and have every other consumer (openapi spec, tests)
import the resolved constant from there instead of the raw slot.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:43:24 +08:00
Innei 2a3b27a76d 🐛 fix(mcp): tolerate prototype keys in desktop MCP IPC and parse pasted stdio commands (#18617) 2026-08-23 16:00:14 +08:00
Arvin Xu 5072403ae0 🐛 fix(agent-runtime): stop sanitizeNullBytes from corrupting escape text (#18507)
* 🐛 fix(agent-runtime): stop sanitizeNullBytes from corrupting escape text

sanitizeNullBytes round-tripped objects through JSON.stringify -> string
surgery -> JSON.parse. On any payload whose TEXT spells out a unicode
escape for U+0000 -- regex character classes, GROUP_KEY_SEP constants,
smali / Kotlin metadata, base64 dumps, i.e. everything a coding agent
reads out of real source files -- stringify doubles that text's leading
backslash, so the six-character needle matches one character late.
Stripping it eats the escape's own backslash and leaves an orphan
trailing backslash, and the re-parse dies with
`SyntaxError: Bad escaped character in JSON at position N`.

Thrown from the tool-result persist path (messagePlugins.state), that
killed the whole operation: 316 failed agent_operations across 116 users
since 2026-04-30, provider-agnostic, burning entire multi-minute runs.

Walk the value and rewrite decoded strings instead, the way stripNulDeep
in @lobechat/heterogeneous-agents already does. Decoded values can never
reach an escape sequence, so literal escape text survives untouched. The
corrupted-Unicode recovery (NUL + two hex digits -> the character it
meant) is preserved, just applied to string values rather than JSON text.

Also persist a bounded `stack` on formatErrorForState's unclassified
Error branch. `name` + `message` alone made this class of 500 impossible
to locate from a stored operation -- the pg branch already keeps its
extra context, this one dropped everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(model-runtime): classify harness JSON.parse throws as E7008

A `SyntaxError` out of a harness-side `JSON.parse` landed on the bare
`InternalServerError` fallback: no attribution, no category, no
severity, no `countAsFailure`, no stable id. 316 such operations over
four months were indistinguishable from any other 500 on the dashboards.

Add `HarnessJsonParseError` (E7008, category `stream`, attribution
`harness`, `countAsFailure: true`, `retryable: false`) and register the
two V8 phrasings — "… in JSON at position N" and "Unexpected end of JSON
input" — in the pattern registry. Placed after every provider section so
an upstream body quoting a parse failure keeps its provider code, and
before the `AgentRuntimeError` crash fallbacks so this class does not
dissolve into that catch-all bucket.

`retryable: false` states the fact rather than changing behavior: the
same corrupt payload re-parses to the same failure, so retrying only
re-burns the run's tokens. Nothing in the agent runtime acts on
`retryable` today — it is classification metadata.

Ships the en-US source string plus the hand-written zh-CN mirror; the
remaining locales fall back to English until the daily i18n workflow
picks them up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:35:17 +08:00
Innei eff0740fb4 ✨ feat(portal): publish workspace HTML as a public Artifact (#18424)
* ✨ feat(portal): publish workspace HTML as a public Artifact

Add a Publish action on Local File HTML preview. Collect local static
assets from HTML, CSS, and JS (including Vite import.meta.url hops),
inline files under 32 KB, and keep larger or JS-resolved files as a
small static site.

* 🐛 fix(portal): keep workspace HTML publish identifiers and assets intact

Rewrite unquoted local hrefs, hash the original path into the artifact
id, and read sandbox binaries as bytes instead of text.

* 🐛 fix(portal): pack Files-tree HTML publish and keep local assets

Treat macOS /private/tmp as the same workspace as /tmp, refuse leftover
relative hrefs, and emit SVG data URIs with image/svg+xml. Also add a
Files-tree publish action and collapse packing details in the confirm.

* 🐛 fix(portal): rewrite every href spelling and scope publish state

Keep each spelling of a deduplicated asset and rewrite HTML refs through
the base-resolved path collection already produced, treat root-relative
refs as remote under an absolute <base>, and enforce the file-count and
byte limits while reading instead of after. Look up deployments with the
same canonical workspace-relative identifier gathering publishes, and
drop a publish result that finishes after switching file or topic.

The OSS business slot now reports the publisher unavailable; the cloud
override carries the market-deployment implementation.

* ♻️ refactor(portal): dedupe workspace HTML publish pipeline and pack once

Carry the prepared pack result through the publish input so the site is
packed once per publish, and share one executor plus error mapping across
the preview button and Files-tree entry points. Extract the HTML tag
scanner shared with HtmlPreview, reuse getMimeType / base64 / escape
helpers from shared packages (fixing sidecar content types for
extensions the local table missed), route desktop byte reads through
projectFileService, batch leaf asset reads, and fold the slot predicates
and duplicated identifier derivation into single homes.
2026-08-18 16:46:04 +08:00
AmAzing- 3fc02f5501 ✨ feat(desktop): complete Codex app-server session lifecycle (#18275) 2026-08-15 11:50:47 +08:00
Tsuki a58d181309 ✨ feat(chat): support web voice messages (#17744)
* ✨ feat(chat): support web voice messages

* 💄 style(chat): align voice message composer

* 💄 style(chat): match voice recorder input scale

* 🐛 fix(chat): animate voice message waveform

* 💄 style(chat): preserve composer height during recording

* 🐛 fix(chat): ignore inherited voice send hover

* ✨ feat(chat): normalize voice messages across providers

* 🐛 fix(chat): make voice message retries cancellation-safe

* 🐛 fix(chat): restore LobeHub voice capability

* 🐛 fix(chat): make voice messages optimistic and context-safe

* 🐛 fix(chat): drop obsolete topic loading cleanup

* 🐛 fix(chat): align voice capability gating

* ✅ test(chat): fix voice context typing

* 🔨 chore: resolve canary rebase conflicts

* ✅ test(database): align audio metadata query expectations

* 🐛 fix: block voice recording while generating
2026-08-09 19:42:32 -07:00
Tsuki cd474dfcc3 ✨ feat(model-runtime): estimate audio input costs (#17949)
* ✨ feat(model-runtime): estimate audio input costs

* 📝 docs(model-runtime): clarify audio usage comments

* 🐛 fix(model-runtime): avoid ambiguous cached audio billing

* 🐛 fix(file): persist uploaded audio duration
2026-08-09 12:52:21 -07:00
Rylan Cai 86991beb75 ♻️ refactor: inject graph runtime context continuously (#17761)
* ♻️ refactor: inject graph runtime context continuously

* ♻️ refactor: clarify graph node tool guidance

* ♻️ refactor: generalize runtime additional context

* ♻️ refactor: simplify additional context injection

* ♻️ refactor: tighten runtime context provider

* ♻️ refactor: group graph agent files

* ♻️ refactor: separate graph context hooks

* ♻️ refactor: compact graph prompt hook application

* ♻️ refactor: share JSON-safe conversion utility

* ♻️ refactor: keep runtime context at virtual tail

* ✨ refactor runtime additional context placement

* ♻️ remove unused virtual tail override

* 📝 rename runtime additional context types

* 🧹 clean up runtime executor context fixture

* ♻️ tighten runtime additional context contract

* 🧹 clean up runtime executor test diff

* 🧹 drop unnecessary context phase comment diff
2026-08-10 00:30:39 +08:00
Arvin Xu 0334243690 ♻️ refactor(topic): remove legacy paused status and hash icon (#18036)
* ♻️ refactor(topic): remove legacy paused status and hash icon

* 📝 docs(topic): remove paused status references
2026-08-07 16:37:56 -07:00
Arvin Xu 4230dcdddc ✨ feat(chat): let the client mint topic and message ids (#17889)
* ✨ feat(chat): let the client mint topic and message ids

Ids are plain `nanoid/non-secure` values behind a namespace prefix — no
sequence, no server secret, no database state — so the client can name a row
before it exists and the server can honour it verbatim. Doing so removes the
placeholder-id → real-id swap that the whole send flow is currently built
around.

Server (accepts, does not require):
- `newTopic.id`, `newUserMessage.id` and `newAssistantMessage.id` on
  `AiSendMessageServerSchema`, each validated against the namespace's id shape.
  An unvalidated client primary key would let a caller submit look-alike ids,
  wrong namespaces, oversized values, or strings that leak into logs and URLs.
- `MessageModel.createUserAndAssistantMessages` takes an `ids` option;
  `TopicModel.create` already accepted one. Both still mint when none is given,
  so an older client keeps working and the two sides deploy independently.
- A primary-key collision now surfaces as CONFLICT rather than a 500. The
  realistic trigger is a retried send replaying its ids, which is
  client-correctable; the message is generic so a caller cannot probe for rows
  it may not read.

Client:
- `sendMessage` mints the user, assistant and topic ids up front and sends
  them, so the optimistic rows are created under their final ids.
- The post-response temp-message cleanup is gone: the rows the server persisted
  are the rows already on screen, so deleting them would delete the real ones.
  Rollback on cancel/failure is kept.

`generateEntityId` mirrors `idGenerator` (same alphabet, same prefixes); the
prefix table and validator live in `@lobechat/types` so the edge schema and the
client generator share one definition without a dependency cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(topic): track optimistic topic ids instead of sniffing their prefix

`#reconcileFetchedTopics` preserved in-flight first-send rows by testing
`id.startsWith('tmp_topic_')`. Client-minted ids made that dead code: the
placeholder now carries a real `tpc_…` id, so the guard silently stopped
matching and a topic-list refetch landing mid-send wiped the row.

The consequences went past a missing sidebar row. `topicLoadingIds` is keyed by
that id, so the send's spinner vanished with it; and `replaceTopicId` bails on
`topicIndex === -1`, so once the row was gone `resolveOptimisticTopic` had
nothing to reconcile onto — the metadata carried on the placeholder
(`workingDirectory` / `repos` for heterogeneous runs, the pinned model snapshot,
the title) was dropped.

Gateway mode is the worst case: `execAgentTask` takes no topic id, so the server
still mints its own there. The client id and the server id never converge, which
means the placeholder is never in a fetched list and was unprotected for the
whole creation window.

Track the ids explicitly in the topic slice instead. Bookkeeping lives in
`internal_dispatchTopic` — the single funnel every add / replace / delete goes
through — so a caller cannot register a placeholder and forget to clear it, and
so the guard cannot silently rot the next time the id format changes.

Regression tests pin all three transitions (preserved across a refetch, released
once the server id is known, released on rollback); the first one fails against
the prefix test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(gateway): forward client-minted ids through execAgent

The gateway send path (`executeGatewayAgent` → `aiAgent.execAgent`) was the one
send route left minting its own ids: `ExecAgentTaskParams` had no id fields, so
the server created the topic and the user/assistant pair under fresh ids and the
client's optimistic rows never converged with the server rows. That kept the
whole placeholder-reconciliation machinery (`internal_replaceTopicId`, temp-row
refetch) load-bearing for gateway mode, and left the optimistic topic row
protected only by the explicit-id tracking fix for its entire creation window.

Bring it to parity with `sendMessageInServer`:

- `ExecAgentParams` / `ExecAgentSchema` gain an optional `clientIds` block
  (`topicId` / `userMessageId` / `assistantMessageId`), validated per namespace
  at the edge — the same gate as the aiChat schema, for the same reason: an
  unvalidated client primary key is an attack surface.
- `execAgent` forwards them to `topicModel.create` and the two
  `messageModel.create` calls. Absent → the models mint, so older clients and
  every non-chat caller (cron, bot, task) are untouched.
- Resume-like replays (`resume`, `resumeApproval`, `resumeToolResult`,
  `parentMessageId`) drop `clientIds` defensively in the service: a replayed id
  would collide with the rows the original send already created, and trusting
  every caller to omit them is exactly the kind of contract that rots.
- A 23505 on those ids surfaces as CONFLICT (retried send replaying its ids —
  client-correctable), not a 500; generic message so ids can't be probed.
- `sendMessage`'s gateway branch now passes the same three ids its optimistic
  rows already render under.

Contract tests pin all three behaviours (forwarded on fresh send, absent →
server mints, dropped on resume-like replay). Existing single-argument
`toHaveBeenCalledWith` assertions on the create spies gained the new id
argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(chat): adopt the minted topic id for the whole first send

The final piece of the client-minted-id migration: the conversation no longer
starts at `topicId: null` and switches after the server responds. `sendMessage`
mints the topic id up front and the ENTIRE send adopts it — the operation
context, the optimistic message bucket, `activeTopicId` and the sidebar row all
carry the final id from the first painted frame.

That removes the `_new` → topic transition, which was the root of the
first-send flicker: the context key changed, `ConversationProvider` re-keyed,
the subtree remounted, and virtua repainted from an empty viewport (~50ms of
zero rows). With the key stable end to end there is nothing left to remount.
Measured in a real browser, the first send's DOM sequence is now
`0 → 2 (optimistic ids) → …` with no empty frame and no id swap, in both the
client and gateway runtimes.

How the pieces fit:

- `willCreateNewTopic` replaces every `!operationContext.topicId` test — the
  context now carries an id for a topic that has no server row yet, so "is
  this send creating the topic" must be an explicit flag.
- The server keeps seeing the OLD shape: no `topicId` while creating, the
  minted id rides in `newTopic.id` / `clientIds.topicId`. For gateway this
  splits the call's contexts: execution context (server: create) vs message
  context (client: the bucket already on screen).
- `creatingTopicIds` (promoted from the action-private set) names the window
  where the topic exists only client-side. Message fetch/prefetch/revalidate
  skip those ids — the query would legitimately return an empty list and wipe
  the optimistic messages. Cleared on `replaceTopicId` / `deleteTopic`, so
  fetching resumes the moment the server confirms.
- The queue check probes the pre-mint `_new` key and the creating buckets in
  addition to the send's own key: a fast second Enter must queue behind the
  send still creating its topic, whose op is registered under ITS minted
  bucket. Queue moves keep a legacy `_new` sweep for the same reason.
- Rollback is unchanged: cancel/failure deletes the optimistic rows (now from
  the minted bucket) and `rollbackOptimisticTopic` already switches away.

The old flow's tests moved with the contract: pending-intervention dismissal
now lives in a real topic bucket (the `_new` bucket can no longer hold
messages), and the queued-follow-up move asserts the legacy `_new` sweep. A new
regression test pins the core invariant: mid-send, `activeTopicId` already
equals the minted id sent as `newTopic.id`, the optimistic pair lives in the
minted bucket, `_new` stays empty, and the id leaves `creatingTopicIds` once
the server confirms.

Verified end to end in a browser (MutationObserver over data-message-id):
gateway first send, client first send, and the existing-topic second send show
zero empty frames; the two e2e suites that caught the earlier remount
regression (agent-scroll, agent-conversation-mgmt) pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:35:24 +08:00
Rdmclin2 c19240488a ✨ feat: support proactive messenger push and notification channel settings (#17791)
✨ feat: support proactive IM notifications
2026-08-03 11:29:41 -07:00
Innei 68f4e84fe9 ⚡️ perf(desktop): reduce cold-start critical path (#17811)
* ⚡️ perf(desktop): optimize cold startup

* ♻️ refactor(desktop): rely on workspace package exports

* ♻️ refactor(spa): isolate web-only routes

* 🐛 fix(desktop): satisfy CI type checks
2026-08-02 02:40:16 +08:00
Innei d5eeb8e5f0 ✨ feat(home): refine dashboard layout and rail (#17756)
* ✨ feat(home): refine dashboard layout and rail

* 💬 chore(home): clarify task mode description

* ✅ test: align local storage mocks with sync reader

* 🐛 fix(home): restore main scrollbar gutter

* 💄 style(home): refine activity rail control
2026-07-31 16:55:19 +08:00
Arvin Xu 03d4b88a2b chore: remove LOBE-XXX annotation markers from source code (#17689) 2026-07-29 13:09:30 -07:00
Innei 93213a51fb ⚡️ perf: make SPA startup demand-aware (#17577)
* ⚡️ perf(vite): make route chunk warmup demand-aware

* ⚡️ perf(spa): preload routes through shared lazy loaders

* ⬆️ chore(deps): align UI editor and Shiki versions

* ⚡️ perf(pwa): cache lazy code on demand

* ⚡️ perf(spa): reduce initial JavaScript dependency closure

* ⚡️ perf(desktop): simplify renderer startup

* ⚡️ perf: refine imperative modal loading boundaries

* 🐛 fix(ci): restore production SPA startup

* ♻️ refactor: share deferred PWA cache rules

* 🐛 fix(ci): execute SPA copy entry with Node
2026-07-29 15:00:26 +08:00
René Wang bd73f6b532 💄 style(settings): polish workspace settings pages (#17696) 2026-07-29 14:54:51 +08:00
Arvin Xu 0a697e80b5 🐛 fix: restore image paste in browser runtime (#17644) 2026-07-27 17:40:59 +08:00
Innei 0b5af01cef ✨ feat(desktop): preview workspace HTML files (#17545)
* ✨ feat(desktop): preview workspace HTML files

* ♻️ refactor(mime): unify MIME detection via @lobechat/utils/mimeType

Fold the ad-hoc extension→MIME maps in desktop, CLI, and device-control
into a single buffer-aware resolver. `resolveMimeType(path, buffer)`
trusts file-type's magic-byte detection first, then falls back to
mime.getType with a text-sniff downgrade so ambiguous extensions like
`.ts` (video/mp2t vs TypeScript source) resolve correctly everywhere.

* fix: resolve html preview ci failures
2026-07-27 14:13:47 +08:00
AmAzing- 3384fe491e ✨ feat: add workspace topic comments (#17537) 2026-07-25 18:21:41 +08:00
Rdmclin2 c8e589b98f ✨ feat: support workspace-scoped notifications (#17587)
* ✨ feat: support workspace notification

* 🐛 fix: scope single-notification mutations and context-key the inbox caches

* 🐛 fix: mirror the notification deep merge in the optimistic store cache
2026-07-25 10:34:39 +08:00
YuTengjing 41c7bc948f ✨ feat: expose client version metadata (#17571) 2026-07-24 22:10:07 +08:00
愛美 b23b3f737c ⚡️ perf(utils): reduce punctuation truncation probes (#17158)
Co-authored-by: aimi <298268675+alienslime@users.noreply.github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-07-24 11:42:36 +08:00
AmAzing- f945c00d50 ♻️ refactor(lint): clean up eslint-disable directives (#17206) 2026-07-15 05:28:23 +08:00
Arvin Xu 1ced57852a ✨ feat(topic): schedule a hetero agent run for later ("send in N hours") (#17127)
* ✨ feat(topic): schedule a hetero agent run for later ("send in N hours")

Generalize the existing scheduled-topic machinery — until now a single-purpose
mechanism for resuming a heterogeneous turn after a provider rate limit — into a
kind-discriminated "deferred execAgent call", then add a user-facing deferred send
on top of it.

`topic.metadata.scheduledRun` now separates three concerns that were tangled into
one rate-limit-shaped payload:

- WHEN: a single top-level `runAt` gate, replacing the rate-limit `resetsAt` that
  doubled as the due check. It is required — an absent gate used to read as "due
  immediately", which is fatal for a user-chosen time.
- LEASE: the `claim` lease, already kind-agnostic, is untouched.
- WHAT: a `kind` discriminated union. `resume_after_rate_limit` keeps the old
  behavior; `delayed_start` is the new one. The dispatcher's scan / claim / clear
  loop is kind-agnostic; only "which execAgent call to make" is per-kind, behind a
  handler registry that the type forces you to extend when adding a kind.

Continuations parked by the shipped version have neither field, so the reader
upgrades them (`parseTopicScheduledRun`) and the due query keeps their old
`resetsAt` gate alongside the new one. A migration would work too, but a
scheduled run is cleared the moment it dispatches — the legacy shape drains on
its own, and reading both costs less than backfilling live jsonb.

A `delayed_start` persists its prompt as a real user message and points
`scheduledRun.userMessageId` at it, so the pending turn reads as the user's own
words in the topic instead of hiding in metadata — and that message stays the
single source of truth for the prompt (the dispatcher reads it back, so editing a
pending run is just editing the message). At dispatch it re-enters `execAgent` with
`suppressUserMessage` and anchors the reply on that message: with no user row
written, an absent `parentMessageId` would persist the reply as a second root and
render it above the prompt it answers (LOBE-11489).

UI (hetero composer only — a CLI agent is where deferring a run makes sense):

- A hetero-only `+` menu holding "Send later" and the formatting-toolbar toggle.
  The agent Plus is not reused: attachments / web search / skills / gateway mode
  are all inapplicable here, and a menu of mostly-grey rows is worse than a small one.
- Picking a time only *arms* the send; nothing is created until Send is pressed, so
  the send button stays the single commit action. The armed state shows as a chip
  next to the `+` that produced it, and times land on the hour.
- The composer is cleared only once the schedule is persisted. Scheduling is one
  rejectable round-trip (a time that just went past, a failed request) and leaves
  no message row to recover the text from, so an up-front clear would lose the
  user's draft and attachments outright.
- The pending turn carries a footer with the run time and a cancel action.
  Cancelling hands the text back to the composer and drops the topic, which existed
  only to hold that turn.

Also fixes a latent bug in the existing dispatcher: a scheduled topic whose payload
could not be dispatched was skipped without clearing, so it re-surfaced on every
tick forever. It is now discarded back to `active`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(db): stop null-testing jsonb paths in WHERE — it crashes the prod engine

`(metadata #>> '{a,b}') IS NULL` in a WHERE clause takes the engine backing
production down outright: `rt_fetch used out-of-bounds`, SQLSTATE XX000, thrown
before a single row is read. It is not a data problem — a table with zero
matching rows crashes just the same — it is the query *shape*. `IS NOT NULL` and
`IS DISTINCT FROM` on an arrow/path expression die the same way. Drizzle reports
it as a bare `Failed query:`, with the real cause buried in the driver's
`[cause]`.

Same engine defect that took out `getLatestSpineMessageId` (LOBE-11376, #16693),
now hit by a fresh query on a different table. `getDueScheduledTopics` (#17077,
on canary since 07-12) has therefore crashed on EVERY cron tick since the day it
shipped: the scheduled-run dispatcher died at step one, so no rate-limited
heterogeneous turn has ever actually resumed. Only two topics ever entered
`scheduledRun` and both have since moved on, so there is nothing stranded to
recover.

COALESCE the extracted value to a sentinel instead — equivalent semantics, a
shape the engine survives. Three sites:

- `getDueScheduledTopics`: the live crash. Both the runAt/legacy due gate and the
  claim-lease check are now COALESCE'd, and the claim's two-branch `or` collapses
  into one comparison ('' sorts before every ISO timestamp).
- `getCronTopicsGroupedByCronJob`: `->>'cronJobId' IS NOT NULL`. No production
  caller today, so it has never fired — fixed before something connects to it.
- `listTopicsForMemoryExtractor` / `countTopicsForMemoryExtractor`:
  `->>'userMemoryExtractStatus' IS DISTINCT FROM 'completed'`. These DO have
  production callers (the hourly user-memory extraction).

Stock Postgres runs every one of these predicates happily, so the model tests
were green throughout and no test on a real database can ever catch the next one.
`jsonbNullTest.test.ts` guards the source shape instead — the only place this is
catchable before prod.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 02:37:31 +08:00
Arvin Xu fda97c698e ✨ feat(topic): schedule resumable hetero continuation (#17077)
* ✨ feat(topic): schedule resumable hetero continuation

* ♻️ refactor(topic): share metadata update schema

* 📝 docs: sync Next.js agent rules

* ♻️ refactor(device): consume shared working directory schema

* 🐛 fix(topic): preserve failed scheduled continuations

* ✅ test(hetero): mock base UI rate-limit actions

* ✅ test(hetero): remove status guide component tests

* 🐛 fix(topic): add scheduled status visual mapping
2026-07-12 18:47:09 +08:00
mengnanjiugaipeifense 94325a6311 🐛 fix(utils): handle non-ASCII text in browser base64 helpers (#16880)
encodeToBase64/decodeFromBase64 are documented as cross-platform, but the browser branch called btoa/atob directly. btoa throws InvalidCharacterError on any code point > U+00FF and atob returns a Latin1 string, so non-ASCII input (e.g. a UTF-8 ComfyUI Basic-auth credential built via createBasicAuthCredentials) failed in the browser while the Node path worked. UTF-8-encode before btoa and UTF-8-decode after atob so both environments behave identically.

Co-authored-by: xiaweiwei67-stack <293320877+xiaweiwei67-stack@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-07-10 17:25:58 +08:00
mengnanjiugaipeifense a92e46e03d 🐛 fix(utils): preserve valid surrogate pairs in sanitizeUTF8 (#16879)
sanitizeUTF8 stripped every UTF-16 surrogate code unit, destroying valid emoji and astral-plane characters (e.g. U+1F600, U+1D54F) rather than only the unpaired surrogates the comment describes. Add the regex 'u' flag so valid pairs match as one code point and are kept, while lone surrogates are still removed. This runs on RAG document chunk text in the async file router, so such characters were silently dropped from indexed content.

Co-authored-by: xiaweiwei67-stack <293320877+xiaweiwei67-stack@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-07-10 17:24:08 +08:00
YuTengjing 7e0993ecd5 🐛 fix(topic): sort sidebar by activity time to stop list jumping (#16868)
* 🐛 fix(topic): return activity time as updatedAt so sidebar sort matches server order

The sidebar sorts topics client-side by the returned `updatedAt`, but the
server ORDER BY uses `topicActivityAt` (latest message time, COALESCE fallback
to the row's own updatedAt) while the SELECT returned the raw
`topics.updatedAt` column. The two sort keys disagree, so newly-messaged
topics fail to rise and status flips that bump the row updatedAt jump around
before a refetch snaps them back. Return `topicActivityAt` from the three
query branches so the client sort key matches the server order.

* 🐛 fix(topic): sort sidebar by dedicated sortUpdatedAt to stop list jumping

Return the latest message-activity time as a separate `sortUpdatedAt`
field instead of overwriting the row's `updatedAt`. The sidebar sorts and
groups by `sortUpdatedAt ?? updatedAt`, matching the server ORDER BY, so
the two sort keys can no longer disagree and reorder the list.

- server: query returns raw `updatedAt` + `sortUpdatedAt` (topicActivityAt)
- client: sortTopics / groupTopicsBy* key on getTopicSortTime()
- optimistic: send/regenerate + tmp-topic paths bump `sortUpdatedAt`
- revert the isStatusOnlyWrite guard — status flips can't reorder anymore
  since the sort no longer keys on `updatedAt`

LOBE-11543
2026-07-08 11:47:32 +08:00
Arvin Xu 310960d6a8 ✨ feat(chat): persist working directory metadata (#16675)
* ✨ feat(chat): persist working directory metadata

* ✨ feat(chat): wire topic worktree context

* ✨ feat(chat): show worktree id and linked PR in topic sidebar

Surface a compact second line under each topic row (by-project / flat /
time / search views) carrying the topic's worktree directory name paired
with its branch, plus the linked PR number chip. Renders only for topics
on a linked worktree or with a linked PR, so plain source-checkout rows
stay clean. Data comes from the persisted workingDirectoryConfig.git — no
extra probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 💄 style(chat): enlarge worktree/PR meta line to 12px with clearer spacing

Bump the topic-row worktree id + linked-PR second line from 11px to 12px,
widen the icon/label and segment gaps, lift icon size to 13px, and add a
small top margin so the line reads comfortably instead of cramped.

* ✨ feat(chat): topic worktree/PR hover detail card + GitHub PR state markers

Replace the inline worktree/PR second line on topic rows with a Codex-style
hover detail card: hovering a topic reveals a card on the right with repo /
branch / worktree / linked PR / CI status plus a chat-branch note. The row
itself stays clean (single line).

Mark GitHub PR lifecycle state (open=green, merged=purple, closed=red) in two
places: the topic row's leading icon (below running/failed/waiting, above the
idle default) and the card's PR row (colored state word + title + number).
State derives from mergedAt/state (merged checked first). CI status is colored
too (passed green / failed red / running amber / none grey).

Data comes from the persisted workingDirectoryConfig — no extra probe. Adds
topic i18n (metaCard.*) in en source + en-US + zh-CN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(chat): anchor hetero CLI session cwd to the source repo, not the worktree

Claude Code / Codex store sessions per-cwd. Previously a hetero run used the
effective (worktree) path as its cwd + resume key, so switching worktree
started a brand-new CLI session and silently dropped the conversation context.

Anchor the session cwd to the SOURCE repo instead: switching worktree now only
records `git.activeWorktree` in metadata and never changes cwd or resets the
session — so cwd and sessionId stay consistent and the context is preserved.
Only a change of repo invalidates the session (keeps the reset warning for
that). Non-hetero agents keep running in the effective/worktree cwd.

Robust against production too: resume rides the existing scalar `heteroSessionId`
+ source `workingDirectory`, so it works even before the new metadata fields are
persisted server-side. Verified end-to-end with real Claude Code (a worktree
switch mid-conversation resumed the same session and remembered prior context).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(chat): fix two working-directory metadata edge cases

- hetero completion write: prefer the topic's CURRENT workingDirectoryConfig
  over the run-start captured one, so GitStatus's freshly-persisted git.github
  (branch/PR/CI) isn't dropped on completion.
- workspace scan: upsert on the matched source entry's path, not boundCwd. The
  cache lookup can match a source entry by its effective (worktree) path, so a
  worktree-bound scan was prepending a bare worktree recent and losing the
  source/worktree metadata the picker relies on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 01:46:29 +08:00
Arvin Xu 4f894c6de6 💄 style: token B rollover, ux skill restructure, agent breadcrumb & approval card (#16587)
* 💄 style(usage): roll token usage values over to B beyond 1000M

formatUsageValue capped at M, so large token counts rendered as
"10285.7M" instead of "10.3B". Add a B tier at the 1000M boundary and
unify the duplicated formatToken helper in TokenProgress onto it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(tool-execution): return a recoverable error for unknown builtin API

A hallucinated apiName threw outside the try/catch, surfacing as an
uncaught failure instead of a tool result. Return a structured
UNKNOWN_API error listing the tool's real APIs so the model can recover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ⏪️ revert(skill): restore the ux skill and add a number-abbreviation rule

#16560 deleted the ux skill (SKILL.md + references/design-values.md) and
stripped its pointers from the react skill. Restore all three (latest
pre-deletion content, still pointing at DESIGN.md for Voice & Content),
and add §1.5 "Abbreviate large numbers, roll the unit over" documenting
the K/M/B/T convention behind the formatUsageValue B-tier fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ✨ feat(agent): unify agent page headers with a shared breadcrumb

Extract the topic header's inline breadcrumb into a reusable AgentBreadcrumb
(`<AgentName> › <Section>`, agent name links home) and reuse it across the
agent profile (助理档案) and usage & cost (用量与成本) pages so sibling pages
read consistently. Identity/location (breadcrumb) leads the profile header,
separated from the state tags that follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(approval): widen the approval card and pin the ask-user footer

Widen the global approval card from 520px to 640px so option-heavy requests
(e.g. PR-scope questions) have room. Wire actionsPortalTarget through to
custom interventions and have AskUserQuestion portal its countdown + skip /
submit row into the card's fixed footer slot, so the actions stay pinned
below the scroll area instead of scrolling away with the content.

Also migrate its Button / Tabs imports to @lobehub/ui/base-ui.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(skill): split ux checklists into per-module references & clarify DESIGN.md boundary

- Split the ~385-line ux SKILL.md into per-interaction-module reference
  files (read / edit / act / feedback / grow); SKILL.md is now a lean
  index (values + principles + module links + one-screen quick checklist).
- Reformat each section into prose (rationale + ✅/❌ examples) followed by
  a concise, one-line-per-item checklist, instead of fat bullets that
  bundled rule + explanation + example in a single item.
- Add an explicit "DESIGN.md vs ux skill" boundary to both files:
  DESIGN.md = design system (tokens, components, voice); ux skill =
  interaction behavior. DESIGN.md now points interaction-detail work at
  the ux skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(tool-execution): source UNKNOWN_API hint from the manifest

The recovery hint listed available APIs via `Object.keys(runtime)`, which
only sees own enumerable properties. Almost every server runtime declares
its APIs as class prototype methods (`async sendMessage() {}`), so the hint
collapsed to `Available APIs: .` — actively telling the model the tool has
no usable APIs, the opposite of the intended self-correction.

Read the declared API names from the tool manifest (the authoritative
source), falling back to a prototype-chain walk when no manifest is
available so the list is never empty when the runtime has callable methods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 📝 docs(skill): add ux rule for pinning actions outside the scroll region

Capture the approval-card footer-pinning behavior as ux §3.3 "Pin actions
& status outside the scroll region" (entity lifecycle shifts to §3.4), and
mirror it in the Quick review checklist under Act.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(skill): require a terminal failure path for every loading state

Add ux §4.2 "Loading must be able to fail — timeout → error + retry":
every loading state needs a bounded-wait/error terminal state with a
Reload/Retry action, never an infinite spinner. Mirror it in the Quick
review checklist under Feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(skill): add ux-audit skill and land homepage audit findings

Add a new `ux-audit` skill: a repeatable, Designing-Interfaces-benchmarked
audit of one surface at a time, with a pattern catalog, a severity rubric, an
output template, and the home-page audit as a worked example. Cross-link it
from the ux skill.

Land the reusable findings from that audit into the ux checklists:
- read.md §1.7 Live / polling streams (new-item indicator, manual refresh, no
  reorder under the user, failed-refresh ≠ empty)
- edit.md §2.1 strengthened (durable draft, survives reload) + §2.2 stable
  input affordances (static placeholder, no hidden clickable content)
- feedback.md §4.2 strengthened (init-flag-gated-on-success → permanent
  skeleton failure mode)
- mirror the new rules into the SKILL.md Quick review checklist

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(approval): pass rendered icons to base-ui Button in AskUserQuestion

base-ui Button's `icon` prop is a ReactNode (not a component ref like the old
antd-based wrapper accepted), so `icon={X}` / `icon={Send}` failed type-check
(TS2322). Wrap them in `<Icon icon={…} />` to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ✅ test(profile): stub AgentBreadcrumb in the profile Header test

The profile Header now renders AgentBreadcrumb, which reads
agentSelectors.getAgentMetaById / builtinAgentSelectors.inboxAgentId. The
test mocks the agent store with only a subset of selectors, so the breadcrumb
crashed with "getAgentMetaById is not a function". Stub the breadcrumb like
the header's other child components — the test covers the export menu, not the
breadcrumb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:54:29 +08:00
YuTengjing be0255b746 🔨 chore: update dependency baseline (#16465)
* 🔨 chore: update dependency baseline

* 🔨 chore(cli): update dependency baseline

* 🔨 chore(desktop): update dependency baseline

* 🔨 chore: fix dependency baseline blockers

* 🔨 chore: update TypeScript and Next baseline
2026-06-30 23:18:08 +08:00
Arvin Xu dee95f5419 💄 style(tool): compact single-row Linear list render (#16359)
Render Linear list_* entities as one tight row each: title on the left,
relative "updated" time on the right. Drop the chunky per-field grid,
hide meaningless UUID ids (human ids like LOBE-123 stay), and show the
update time as locale-aware relative time (e.g. "更新于 3 天前") with the
full timestamp in the title tooltip.

Adds a `fromNow` util, the `builtins.linear.render.updatedAt` i18n key
(en-US + zh-CN), and a devtools RenderGallery fixture so the Linear
render is previewable.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:10:56 +08:00
YuTengjing 479514d0ff ✨ feat: support Bedrock API key auth (#16284)
* ✨ feat: support Bedrock API key auth

* 🐛 fix: guard Bedrock auth mode changes for read-only users

* ✅ test: type MessageContent markdown mock args

* 🐛 fix: preserve Bedrock API keys without random selection

* ✅ test: type Bedrock runtime inspection in ModelRuntime tests

* 🐛 fix: support Bedrock API key rotation securely
2026-06-25 19:21:16 +08:00
YuTengjing 724659f10d ✨ feat: support contextual model pricing (#16135) 2026-06-22 18:01:11 +08:00
Arvin Xu ef09457e63 ✨ feat(topic): persist topic unread as a backend status (#16056)
* ✨ feat(topic): persist topic unread as a backend status

Move the "unread completed generation" indicator from a client-only
Zustand Set to a persisted `topics.status = 'unread'`, so it survives
reload and syncs across devices.

- add 'unread' to the topic status enum / type / updateTopic zod / sort rank
- compute cross-agent unread counts server-side on the sidebar agent list
  (SidebarAgentItem.unreadCount) so the home badge stays accurate for
  agents whose topics aren't loaded on the client
- markTopicUnread (formerly markUnreadCompleted) writes status 'unread';
  markTopicRead (formerly clearUnreadCompletedTopic) flips it back to
  'active' when the user opens the topic
- partition the terminal status write by (viewing, succeeded) so the
  background completion's 'unread' write never races the 'active' write
- derive unread selectors + sidebar status bucketing from topic.status

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(topic): clear unread on route hydration; don't mark parked/cancelled runs unread

Address two review findings on the persisted unread status:

- Route hydration (reload / deep link / notification) sets activeTopicId
  directly instead of going through switchTopic, so the persisted
  'unread' was never cleared while the user was already reading. Add
  useClearActiveTopicUnread — once the active topic appears in a loaded
  bucket as 'unread', mark it read. Wired into agent + group ChatHydration.
- A gateway agent_runtime_end with reason 'interrupted' / 'waiting_for_async_tool'
  is a cancel / park, not a completion. Gate markTopicUnread on
  isCompletedRuntimeEnd(reason) and derive onSessionComplete's `succeeded`
  from the same predicate, so aborts/parks clear back to 'active' instead
  of persisting as an unread completion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(topic): hoist topic status enum into @lobechat/types + add TopicModel tests

- extract the inline status z.enum in the topic router into a shared
  chatTopicStatusSchema / TOPIC_STATUSES source of truth in @lobechat/types
- derive ChatTopicStatus from the schema so the type and validator stay in sync
- add TopicModel unit tests covering CRUD, status filtering/ordering, triggers,
  metadata merge, duplicate, batchMoveToAgent and memory-extractor queries

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 23:55:04 +08:00
Arvin Xu f6db1361ee ✨ feat(agent): show topic sidebar status indicators (#15739) 2026-06-13 13:32:56 +08:00