Commit graph

1793 commits

Author SHA1 Message Date
ethernet
6ecd335aa8
Merge pull request #75037 from NousResearch/sec-fixes
fix(sec): patch vulnerable deps + add publication-age floors and npm script allow-list

Co-authored-by: Kingsley Wong <7207924+datanerdie@users.noreply.github.com>
Co-authored-by: viky <vikyw89@gmail.com>
Co-authored-by: FT_IOxCS <237263164+ft-ioxcs@users.noreply.github.com>
Co-authored-by: 方明元 <fmy3@qq.com>
Co-authored-by: Yorkstone Supplies <58149681+sycamoregroupltd@users.noreply.github.com>
Co-authored-by: Steven Cuz Leath <Steven.Leath@gmail.com>
Co-authored-by: Kyle French <248366920+Dadmin88@users.noreply.github.com>
Co-authored-by: Eugeniusz Gilewski <egilewski@egilewski.com>
Co-authored-by: Christopher Gara <79837758+christopherrobin88@users.noreply.github.com>
Co-authored-by: LironTTG <147833337+LironTTG@users.noreply.github.com>
Co-authored-by: Austin Porada <bbasketballer75@gmail.com>
Co-authored-by: cresslank <9219265+cresslank@users.noreply.github.com>
Co-authored-by: Ion Mudreac <mudreac@gmail.com>
Co-authored-by: martinramos002 <262243228+martinramos002-bot@users.noreply.github.com>
Co-authored-by: Sensie-Agents <agents@joinsensie.com>
Co-authored-by: alexwill87 <173086651+alexwill87@users.noreply.github.com>
Co-authored-by: BullishMomentum56 <218643122+BullishMomentum56@users.noreply.github.com>
Co-authored-by: pintadoai <240097310+pintadoai@users.noreply.github.com>
Co-authored-by: Alfred Sahlberg <dinmail@gmail.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Richard Ham <richard.ham@live.com>
Co-authored-by: jrcrittenden <jrcrittenden@gmail.com>
Co-authored-by: 峯岸 亮 <1920071390@campus.ouj.ac.jp>
Co-authored-by: Marcus Martini <6473852+napoleonmm83@users.noreply.github.com>
2026-07-31 14:07:32 -04:00
brooklyn!
0324849fe4
Merge pull request #61173 from NousResearch/bb/desktop-kanban
feat(desktop): Kanban — the founding plugin on the desktop SDK
2026-07-31 13:00:10 -05:00
ethernet
390a1771b2 fix(sec): update npm deps to resolve npm audit warnings
pin brace-expansion to 5.0.8
update concurrently to 10.0.4
update electron-builder to 26.15.3
update eslint to 10.8.0
update eslint-plugin-perfectionist to 5.10.0
update @assistant-ui/react to 0.15.0
update @assistant-ui/react-streamdown to 0.3.8
update radix-ui to 1.6.7
update react-router-dom to react-router@8.3.0 - react-router-dom is no longer a standalone package, it just reexports react-router
remove @radix-ui/react-slot: we import this from `radix-ui`
remove eslint-plugin-react: we imported it, but never actually used it!
2026-07-31 13:42:03 -04:00
Brooklyn Nicholson
d399c1644b fix(desktop): statusbar off by default
The statusbar is now opt-in. Existing users with a stored preference
keep their choice; new users get a clean bottom edge. The way back is
the view.toggleStatusbar keybind or the ⌘K row, unchanged.
2026-07-31 12:11:23 -05:00
hermes-seaeye[bot]
daa1befaf6
fmt(js): npm run fix on merge (#75517)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-31 16:22:58 +00:00
Carbon
c31c27e03a fix(desktop): preserve voice stop across speech setup 2026-07-31 09:44:53 -04:00
Brooklyn Nicholson
044800e358 perf(desktop): stretch backstop polls while on battery
powerMonitor's AC/battery state is mirrored to the renderers
(store/power.ts) and visiblePoll quadruples its cadence on battery. Only
the safety-net refreshes slow down — event-driven refreshes and live
streaming are untouched.
2026-07-31 00:38:11 -05:00
Brooklyn Nicholson
8ccb4c2cee perf(desktop): scope background-throttling opt-out to live streaming
The process-wide disable-background-timer-throttling /
disable-backgrounding-occluded-windows switches plus a static
backgroundThrottling: false on every chat window pinned each renderer's
document.visibilityState to 'visible' for the life of the window. Every
visibility-gated backstop poll and clock tick in the renderer became an
always-on timer: an idle, minimized Hermes burned ~20% CPU around the
clock, on battery too.

Throttling is now a runtime dial. A small controller (stream-throttle.ts)
rides the merged hermes:active-work reports the quit guard already
receives: while any turn is in flight every chat window gets
setBackgroundThrottling(false) — a live answer keeps painting while
blurred, occluded, or minimized, exactly as before — and once all turns
settle (plus a 5s trailing window so the final flush lands at full
cadence) Chromium's default throttling returns and hidden windows go
quiet.

disable-renderer-backgrounding stays: process priority only, no timer
semantics, and it keeps hidden streaming fast.
2026-07-31 00:38:11 -05:00
brooklyn!
cdca247424
Merge pull request #75180 from NousResearch/bb/composer-cut-placeholder
The placeholder comes back when you clear the composer
2026-07-30 23:54:19 -05:00
Brooklyn Nicholson
0b4bd3c7c7 fix(desktop): the placeholder comes back when you clear the composer
Select-all + Cut emptied the text and left the composer blank — no draft,
no prompt. Delete had the same hole.

The placeholder is painted on `:empty`, and a cleared editor keeps a
scaffolding <br> so the contenteditable can't collapse to a sliver. Those
two facts collide: the moment the break lands the editor has a child,
`:empty` goes false, and the prompt never comes back.

CSS can't infer emptiness on its own either. A text node is invisible to
selectors, so `one<br>` and a lone `<br>` are the same shape — a structural
rule like `:has(> br:only-child)` paints the placeholder straight over the
user's text. The code that empties the editor is what knows, so it marks
the root and the condition reads `:is(:empty, [data-empty])`.

Both writers that reshape that root maintain the marker through one helper:
the normalizer, and renderComposerContents for a restored draft or an undo.
The message-edit composer shares the slot and the rule, so it takes the
same shared class instead of drifting on its own copy.

#74815 fixed the draft this stashed; the placeholder is a separate seam.
2026-07-30 22:54:20 -05:00
hermes-seaeye[bot]
b1858f33a1
fmt(js): npm run fix on merge (#75159)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-31 03:08:09 +00:00
brooklyn!
ab158e8088
Merge pull request #75127 from NousResearch/bb/close-last-tab
desktop: closing the last main tab lands on New session, and middle-click works on a real mouse
2026-07-30 21:58:43 -05:00
Brooklyn Nicholson
193e5f84f7 fix(desktop): the main tab can be closed by gesture and menu
The tab strip decided the close gesture from the `uncloseable` flag, which the
workspace sets to keep its pane in the tree — so the one tab whose close now
does something couldn't be ⌘-clicked or middle-clicked, and its right-click
menu had no Close.

Read the gesture off the pane's registered closer instead, with the workspace
registering closeWorkspaceTab. An atom rather than a lookup, since that closer
comes from a wiring effect that lands after the strip's first paint.
2026-07-30 21:43:15 -05:00
Brooklyn Nicholson
c7b021ca48 fix(desktop): closing the last main tab lands on New session
The workspace pane can't leave the tree, so "close the main tab" only ever had
one answer wired: shift the next stacked session in. With main as the only tab
there was nothing to shift and ⌘W dead-ended on the tab the user was looking
at.

closeWorkspaceTab is now the one answer for every entry point — stacked
session still wins, and with nothing stacked main drops to a fresh New session
draft. A blank draft and a full-page view stay no-ops: a blank draft already
IS the post-close state.
2026-07-30 21:43:15 -05:00
Brooklyn Nicholson
463fbf5b16 fix(desktop): middle-click works on a real three-button mouse
Chromium on Windows and Linux answers a middle press inside a scroller by
starting the autoscroll pan, and the mouseup that ends the pan never becomes
an auxclick. Every surface carrying the gesture — tab strips, the session
list, the terminal rail — is a scroller, so middle-click only ever worked on
macOS, where autoscroll doesn't exist.

Arm on pointerdown, spend on the pointerup over the same element (press one
tab, release on another and nothing happens), and cancel the middle mousedown
on every press so the pan widget can't appear on a surface that owns the
button. One helper, four call sites.
2026-07-30 21:43:14 -05:00
Brooklyn Nicholson
4d6589c69c fix(desktop): stop ⌥-click spraying cursor escapes into the terminal
⌥-drag is the app's force-selection gesture over mouse-mode TUIs, but
xterm's default alt-click-moves-cursor claims the same click and emits one
cursor left/right escape per column of travel. Shells that don't consume
them echo the raw `^[[D` burst into the buffer. One gesture, one meaning.
2026-07-30 20:51:40 -05:00
Brooklyn Nicholson
0cec9896a1 fix(desktop): open links clicked in the integrated terminal
Both of xterm's link paths activate through `window.open()`, which the
window's setWindowOpenHandler denies, so ⌘-clicking a URL did nothing but
log "Opening link blocked as opener could not be cleared" — and the OSC 8
path fronted that dead end with a raw confirm() dialog. Route both through
the desktop bridge, the path every other external link in the app takes.

⌘-click on macOS, Ctrl-click elsewhere, matching VS Code's integrated
terminal, Terminal.app, and iTerm2. A bare click stays with the selection so
a misclick on a URL can't launch a browser.
2026-07-30 20:51:09 -05:00
hermes-seaeye[bot]
5d6aae02bf
fmt(js): npm run fix on merge (#75055)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-30 22:06:42 +00:00
brooklyn!
3a2b332985
Merge pull request #74938 from NousResearch/bb/rail-own-worktree
fix(desktop): a session's coding rail follows its own worktree
2026-07-30 16:54:37 -05:00
Andrew Fiebert
c9de69c6d5 fix(desktop): keep queued prompts bound to their origin session
ChatView migrated tip-keyed composer queue/draft entries onto queueSessionKey
whenever the two ids differed. queueSessionKey is route-driven and can flip to
Session B a frame before the store selection leaves Session A, so migrate
re-homed A queue entries onto B and the idle ChatBar auto-drained them into
the wrong chat.

Gate migrate on same-conversation lineage only (tip to root). Also honor
lineage when background queue drain decides selected/busy, so a root queue key
is not treated as idle/offscreen while the compression tip is still working.
2026-07-30 15:08:31 -04:00
Brooklyn Nicholson
8f4ab7ad2c fix(desktop): coding rail reads only its own worktree
The row fell back to the global $repoStatus whenever repoPath was blank,
painting the main pane's branch and ± onto a tile whose cwd hadn't
resolved yet. The fallback bought nothing — the primary computed is keyed
to $currentCwd, which is empty in exactly that case — and cost a rail
showing a tree the session was never in.
2026-07-30 12:27:23 -05:00
Brooklyn Nicholson
c48d9a9c6d fix(desktop): mark tile and branch runtimes as background
A tile and a branched session each live in their own worktree and render
from their own SessionView slice, so neither is the main pane's session.
Pass foreground: false at both call sites.
2026-07-30 12:27:23 -05:00
Brooklyn Nicholson
dd762d07bb fix(desktop): only the foreground session may write the composer atoms
applyRuntimeInfo unconditionally mirrored a runtime's cwd, branch, model
and usage into the global composer atoms. Every tile create and session
branch called it, so opening a session in another worktree re-pointed the
MAIN pane's coding rail at that tile's repo — and persisted it, so the
wrong workspace cwd survived a restart.

Collect the patch first, then mirror it once behind a `foreground` gate.
Background callers still get the full patch for their own session state;
they just stop publishing into state they don't own.
2026-07-30 12:27:16 -05:00
brooklyn!
8defb9fd60
Merge pull request #74833 from NousResearch/bb/status-stack-seam
fix(desktop): fuse the status stack to the composer again
2026-07-30 12:06:36 -05:00
Austin Pickett
1fd7548b49 test(desktop): query the profile row kebab by its own label
main now labels each panel row's kebab with the row's name
(menuLabel={profile.name}), so the hardcoded "Actions" default this test
relied on no longer exists. The name alone is ambiguous — the row-select
button carries it too — so match the menu trigger via `expanded`.

Neither side conflicts textually, so this only surfaced once main merged in.
2026-07-30 12:31:38 -04:00
Austin Pickett
95571de9d7 fix(desktop): fold delete dialog into shared, level up name field, test the view
Addresses review on #73013.

1. Manage Profiles used a hand-rolled delete Dialog next to the shared
   DeleteProfileDialog in the same folder. That copy missed the active-
   profile re-home fix (f764b0400): deleting the profile the gateway is
   on stranded it on a dead backend. Switch to the shared dialog, which
   owns the deleteProfile call and re-homes to default. Drops
   handleConfirmDelete, the deleting state, and the now-unused Dialog*
   imports.

2. The name field regressed to a plain Input during the create-dialog
   dedup, losing live slugging. Level both shared dialogs up to
   SanitizedInput sanitize={slug} so every entry point gets the behavior
   Manage Profiles had — the sanitize primitive means callers never
   validate-then-reject.

3. Nothing rendered ProfilesView, which is how the drift got in. Add a
   behavior test: create dialog exposes SOUL.md, deleting the active
   profile re-homes to default, deleting a non-active one does not.
2026-07-30 12:31:38 -04:00
Austin Pickett
4d9b7718d9 fix(desktop): use shared create-profile dialog on Manage Profiles page
The Manage Profiles page had its own local CreateProfileDialog/
RenameProfileDialog copies that predated the shared dialogs in
create-profile-dialog.tsx / rename-profile-dialog.tsx. The local
create copy lacked the SOUL.md textarea, so New Profile from the
sidebar rail and New Profile from Manage Profiles rendered different
modals.

Delete both local duplicates and reuse the shared self-contained
dialogs (they own the createProfile/renameProfile/updateProfileSoul
calls), so both entry points show the same modal including SOUL.md.
2026-07-30 12:31:38 -04:00
Brooklyn Nicholson
77bdf932fc fix(desktop): fuse the status stack to the composer again
pb-2 on the in-flow stack wrapper opened an 8px gap under the card and
broke the shared seam the dock card is built for.
2026-07-30 07:47:02 -05:00
hermes-seaeye[bot]
b4f8c491d3
fmt(js): npm run fix on merge (#74827)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-30 12:46:25 +00:00
brooklyn!
fd90ef77be
Merge pull request #74815 from NousResearch/bb/composer-clear-parity
Clearing the composer empties it whichever way you do it
2026-07-30 07:36:28 -05:00
brooklyn!
466e6402f6
Merge pull request #74806 from NousResearch/bb/ref-parity
A sent reference renders as the chip the composer showed
2026-07-30 07:32:22 -05:00
hermes-seaeye[bot]
84d71fb88f
fmt(js): npm run fix on merge (#74814)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-30 12:31:53 +00:00
Brooklyn Nicholson
570337c099 fix(desktop): clearing the composer lands on the same empty state every way
Select-all + Delete cleared the composer; select-all + Cut left it holding
a draft. Both produce identical DOM, so the split was in the reader.

An emptied editor keeps a placeholder <br> \u2014 scaffolding
normalizeComposerEditorDom adds so the contenteditable doesn't collapse to
a sliver, not a line the user typed. composerPlainText read it as "\n",
and syncDraftFromEditor (session swap, pagehide) skipped the
normalize+sanitize its rAF twin runs. Cut's residue reached that reader;
Delete's went through the flush path and got cleaned.

Fixed where the two disagree rather than at each call site: an editor
holding nothing but its placeholder break reads as empty. A real
Shift+Enter break, a trailing break after text, and a nested lone <br>
are all unchanged \u2014 the exemption is scoped to the editor root by its slot
marker. syncDraftFromEditor now normalizes and sanitizes like the flush
path, so both readers see one truth.
2026-07-30 07:29:31 -05:00
brooklyn!
3eebb99ca8
Merge pull request #74804 from NousResearch/bb/tab-close-stay-right
Closing a tab leaves you on the one that fills its slot
2026-07-30 07:23:00 -05:00
Brooklyn Nicholson
1238efce13 fix(desktop): track kanban lane phase in state, not a mirrored ref
main landed a lint rule banning refs mirrored from reactive values in an
effect — they lag a render and cause stale reads. The lane-collapse
override tracker did exactly that with a counts ref.

Hold the empty/non-empty signature in state instead. React bails out
when it's unchanged, so a poll where no lane's emptiness moved costs no
extra render, and the comparison always sees the current value.
2026-07-30 07:18:45 -05:00
Brooklyn Nicholson
81e196f236 feat(desktop): kanban new-task hotkey — the plugin command pattern
Creating a task was mouse-only: the header button, the empty state, or a
per-column hover +. Kanban now ships a real command, wired the way any
plugin should wire one.

One action id (kanban.newTask) registered into two areas: KEYBINDS_AREA
gives it dispatch plus a rebindable row in the shortcuts panel, and a
palette row whose `action` field points back at the same id so the live
combo renders as its hotkey hint. The handler is route-independent — it
parks the lane in $newTaskLane and navigates, so the page picks the
request up whether it was already mounted or is mounting for the first
time, then clears it so a remount can't reopen a dismissed dialog.

Default is mod+alt+n (⌘⌥N). mod+n and mod+shift+n are core built-ins a
plugin can't shadow; core uses alt only for the mod+alt+1…9 profile
slots, never with a letter, which leaves ⌘⌥<letter> free as the natural
namespace for plugin commands.

Label ships in the plugin's own locale bundles (en/ja/zh/zh-hant) via
ctx.i18n, so it localizes without a core en.ts edit.
2026-07-30 07:18:45 -05:00
Brooklyn Nicholson
6a67a4e952 fix(desktop): resolve contributed keybinds through the fallback chain
$bindings is seeded at module init from the actions known then, so an
action a plugin contributes later is absent from it. Both hotkey-hint
call sites did a raw bindings[id] lookup, so a plugin command rendered
with no combo in the palette and no hint on its tooltip even though the
dispatcher (which goes through $comboIndex → bindingsFor) fired it fine.

Route both through bindingsFor, the resolver that already falls back to
the stored override and the action's shipped defaults, and subscribe the
hint hook to the registry version so a late registration repaints.

Covered by behavior tests over the contributed-action contract: dispatch,
combo resolution, panel row, teardown, and the no-shadowing guard.
2026-07-30 07:18:45 -05:00
Brooklyn Nicholson
eaa61d2aa6 i18n(desktop): move kanban to plugin-scoped ctx.i18n (per #67303)
Now that #67303 shipped the plugin-scoped i18n door, the kanban plugin ships
its OWN locale bundles via ctx.i18n.register instead of a core t.kanban
namespace — nothing added to core en.ts/ja/zh/zh-hant/types.ts. useKanban()
binds usePluginI18n('kanban') to the message SHAPE (one tiny generic) so
components keep their typed k.newTask / k.moveTo(label) access unchanged.
2026-07-30 07:18:08 -05:00
Brooklyn Nicholson
77f1e84a34 feat(desktop): plugin ctx.onDispose + self-disposing kanban bindApi
The plugin context only tracked contribution/socket disposers, so a plugin's
other side effects (store subscriptions) leaked across disable/re-enable. Add
ctx.onDispose(fn) — an arbitrary cleanup collected alongside the rest and run
on deactivate. bindApi now returns a disposer (unsubscribes its persisted-atom
listeners, closes the socket, drops the rest handle) and the kanban plugin
registers it via ctx.onDispose, so a toggle leaves nothing behind and never
duplicates listeners. Also DRYs the atom-persistence into one `persist` helper.
2026-07-30 07:18:08 -05:00
Brooklyn Nicholson
f6c8f35a4a i18n(desktop): localize the kanban plugin across all four locales
Every user-facing string in the kanban plugin now routes through useI18n
(new t.kanban namespace) instead of hardcoded English — en, ja, zh, zh-hant
in lockstep (typecheck enforces parity). Column labels/help move out of the
COLUMN_META const (visual-only now) into i18n via columnLabel/columnHelp;
LOCKED_COLUMNS/ARC_TITLES/complexity copy likewise. Matches the rest of the
desktop app, which is fully localized.
2026-07-30 07:18:08 -05:00
Brooklyn Nicholson
901205420f feat(kanban): talk to a running worker without a restart
A running worker now polls its comment thread and folds new operator notes
into the live turn via the OUT-OF-BAND steer channel (list_comments_after +
a heartbeat-driven bridge, watermarked so history isn't re-injected and the
worker's own notes are skipped). No block→comment→unblock dance. Desktop's
composer sends notes live ("delivered within a few seconds") with "Requeue
with note" as the restart option and a help tooltip.
2026-07-30 07:18:08 -05:00
Brooklyn Nicholson
346149c4f8 feat(kanban): task effort estimate via the auxiliary model
An "Estimate" action asks the auto-routed auxiliary model for a rough token
count + complexity band (S/M/L) with a one-line rationale — tokens, not
dollars, since providers don't report cost reliably. POST /estimate (typed
title/body, for the create dialog) and POST /tasks/{id}/estimate (existing
cards) share one core. Desktop renders it inline ("~15k tok · Medium") with a
"makes a model call" disclaimer; SDK exports compactNumber.
2026-07-30 07:18:08 -05:00
Brooklyn Nicholson
027ef381a4 feat(kanban): scope boards to a project
Boards gain an optional project_id. When set, the board's default_workdir
mirrors the project's primary repo and every new task inherits the project —
a deterministic worktree + branch per task — unless it names its own. New
GET /projects; board create/patch/list carry project_id + resolved name; the
create dialog defaults its workspace to the board's and allows a per-task
path override. Desktop: "Board settings…" gains a project picker.
2026-07-30 07:18:08 -05:00
Brooklyn Nicholson
9be67b7762 fix(desktop): roomier kanban create-task modal
The create form was cramped at max-w-md with a 60vh scroll cap. Widen to a
responsive w-[min(42rem,94vw)] and raise the scroll cap so the fields breathe.
2026-07-30 07:18:08 -05:00
Brooklyn Nicholson
5c4d1e1ea2 feat(desktop): SDK — useGrabScroll export + dogfood plugin touch-ups 2026-07-30 07:18:08 -05:00
Brooklyn Nicholson
79e7adae2d feat(desktop): Kanban — dashboard-parity board plugin on the SDK
The founding opt-in plugin (defaultEnabled: false): /kanban board + drawer,
live task_events via ctx.socket, ⌘-click bulk ops, auto-nudge dispatch,
collapsible lanes, board switcher, and prose activity — all pure SDK-consumer
work against plugins/kanban/dashboard/plugin_api.py. Backend: /boards totals
count live cards only.
2026-07-30 07:18:08 -05:00
brooklyn!
81aacdef4d
Merge pull request #74802 from NousResearch/bb/dismissed-projects-cmdk
Dismissed projects stay out of ⌘K
2026-07-30 07:17:01 -05:00
Brooklyn Nicholson
0c4a5d70f5 fix(desktop): a sent reference renders as the chip the composer showed
A reference whose value is backtick-quoted — `@url:` always, and any path
with a space — arrived in the sent bubble as a bare `@url:` followed by a
markdown code span. The composer showed a chip; sending it produced two
wrong things.

user-message-text scans inline code BEFORE handing the remaining text to
DirectiveContent, so it claimed the directive's quoting as a code span and
split the reference down the middle. Directives win that overlap: the
backticks are syntax the composer wrote, not something the user typed as
code.

The pattern itself lived in three identical copies (composer hydration,
sent bubble, and the one this fix needed), plus a fourth copy of the kind
list. They agreed today by luck. reference-kinds already owns what a
reference LOOKS like, so it now also owns what one IS: WIRE_REFERENCE_KINDS
and referenceRe(), a fresh matcher per call because a shared /g regex
carries lastIndex between callers.
2026-07-30 07:15:44 -05:00
brooklyn!
0567613497
Merge pull request #74790 from NousResearch/bb/worktree-diff-scope
fix(desktop): scope coding rail + review pane per worktree
2026-07-30 07:15:21 -05:00
Brooklyn Nicholson
ec1645a5ba fix(desktop): stay on the tab that fills a closed slot
Closing an active layout tab always selected the previous neighbor, so
focus jumped left every time. Prefer the right neighbor instead (left
only at the end) — same rule terminals and the preview rail already use.
2026-07-30 07:12:29 -05:00