Compare commits

...

138 Commits

Author SHA1 Message Date
libalpm64
071c95ab5c chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Closes #770
2026-04-19 16:38:44 +08:00
陈大猫
ec99875dec [codex] avoid main-process runtime crashes (#772)
* avoid main-process runtime crashes

* fix main-process startup error boundary

* tighten main-process startup readiness

* fix startup fallback window health checks

* exclude hidden windows from recovery checks
2026-04-19 16:31:00 +08:00
陈大猫
51a6b7efaa Preload compact history on first turn after app restart (#753 hedge) (#769)
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* Preload compact history on first turn after app restart (#753 hedge)

Symptom (confirmed on Copilot CLI, originally reported on Codex in
#753): after closing and reopening Netcatty, the AI chat UI still
shows the prior conversation but the agent responds "this is the
beginning of our conversation, no previous records". Earlier context
is lost entirely.

Root cause: the bridge relied on session/load throwing "not found" to
trigger the catch-block fallback that replays compact history. Some
ACP agents (Copilot CLI, some Codex builds) silently spawn a new
session when handed a stale id instead of erroring. The catch-block
never fires → historyReplayFallback stays false → the first turn
sends only the latest prompt → agent sees zero context.

Fix: when we're creating a new provider process AND telling it to
resume an existing session id AND the renderer gave us compact
history, preload historyReplayFallback=true as a hedge. If the agent
really did reload the session, the replay is ~3KB of redundant
context (small waste). If the agent silently started fresh, the
replay restores durable constraints + last few raw turns so the
first response is coherent.

After the first successful streamed turn clears the flag (the round-2
post-stream hook), steady state is back to sending only the latest
prompt. Cost is bounded to one replay per app-restart-and-prompt.

Test: "replays compact history on the first turn after app restart
even when session/load 'succeeds'" — mocks createACPProvider to
behave like Copilot CLI (no error thrown, no real resume), asserts
the first streamText call carries history+latest (length 2) and the
second only latest (length 1).

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

* Fix AI session resume and agent switching

* Preserve hidden draft when switching agents

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:44:41 +08:00
陈大猫
30f5346035 Classify AI proxy / size-limit errors instead of showing raw Zod output (#765) (#768)
Symptom: when an AI request is proxied through nginx (or any gateway)
and the request body exceeds client_max_body_size, the proxy returns a
413 HTML error page. The Vercel AI SDK then fails to parse the HTML
as a chat completion and surfaces a cryptic Zod validation error like
"Expected 'id' to be a string." through the UI — users have no idea
what's wrong.

Root cause: classifyError only did light sanitization and returned the
raw SDK message. It also string-coerced the error before inspection, so
the structured statusCode / responseBody fields that APICallError
attaches were thrown away.

Fix: classifyError now accepts `unknown` and inspects the full error
shape. Adds explicit branches for:

- HTTP 413 (from statusCode, cause.statusCode, or message text) →
  "Request too large — exceeded proxy size limit. Try shorter
  message, fewer attachments, or raise client_max_body_size."
- HTTP 502/503/504 → retryable upstream-gateway message
- HTML response body (starts with <!DOCTYPE/<html> or contains such
  tags anywhere) → "Server returned HTML error page, likely a proxy
  intercept."
- Zod/schema parse shapes ("Expected 'X' to be …", "Invalid JSON
  response", "Type validation failed") → "Response could not be
  parsed; proxy may have replaced/truncated the body."

In every classified case the raw SDK text is still appended ("Raw: …")
so users can report the underlying error verbatim.

useAIChatStreaming.ts callers now pass the raw error to classifyError
instead of `.message`, so the new structured branches actually fire.
Also wired infrastructure/ai/*.test.ts into the npm test glob.

Closes #765

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:50:25 +08:00
陈大猫
e0302e5f34 Batch Windows hidden-attribute detection in local FS listing (#766) (#767)
* Batch Windows hidden-attribute detection in local FS listing (#766)

Symptom: opening a local directory with ~800 files in the SFTP panel
hangs for ~30 s on Windows. Reported on netcatty 1.0.93.

Root cause: listLocalDir spawns attrib.exe once per entry inside the
worker pool to detect the Windows hidden flag. 800 subprocess spawns
× ~40 ms each is precisely the reported 30 s. fs.promises.stat and
readdir on their own are nearly free; the subprocess flood dominates.

Fix: replace the per-entry attrib call with a single
`attrib.exe "<dir>\*"` invocation up front, parse its output into a
Set<basename>, and have the workers do an O(1) set lookup. One
subprocess per directory listing instead of one per entry.

Expected speedup for the #766 case: ~30 s → <1 s. Behavior is
unchanged — hidden files keep their hidden flag, non-hidden files
stay not-hidden; only the mechanism is different. Broken-symlink
handling (lstat fallback) also uses the same set.

Tests:
- parseAttribOutput is extracted as a pure function and unit-tested
  against real attrib output shapes: drive-letter paths, UNC paths,
  the trailing [DIR] marker that some Windows versions emit, mixed
  flag columns (A/H/R), malformed "Parameter format not correct"
  lines, empty input.
- listWindowsHiddenBasenames short-circuits on non-Windows without
  spawning anything.
- Parser uses path.win32.basename explicitly so the tests pass under
  non-Windows CI.

I cannot reproduce or test on Windows directly. The diagnosis is
mechanical (we can count subprocess calls) and the fix is a local
rewrite that preserves behavior, but Windows verification is still
desirable before release.

Closes #766

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

* Address codex review on #767: pass /d so batched attrib includes hidden directories

Codex flagged that attrib.exe treats `<dir>\*` as file-centric by
default — without `/d`, hidden directories (node_modules, .git, etc.)
never appear in the output, so listWindowsHiddenBasenames misses them
and the SFTP browser shows those folders as not-hidden. This is a
behavior regression from the per-file path, which passed each entry's
full path directly and therefore covered both files and directories.

Added `/d` to the execFileAsync argv and a regression test that
module-mocks child_process.execFile to capture the argv and assert
`/d` is present. The parser-level [DIR] marker test is also still
there, so both the attrib call shape and the parser behavior are
locked down.

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

* Address codex round 2 on #767: tighten [DIR] strip to the literal marker

Codex flagged that /\s+\[[^\]]+\]\s*$/ also swallows legitimate trailing
bracketed text, so a hidden file named "Notes [old]" gets stored as
"Notes" in hiddenSet and hiddenSet.has("Notes [old]") returns false —
the entry is misclassified as not-hidden, a regression from the old
per-entry attrib path which never saw a "[DIR]" marker to strip.

Narrowed the regex to /\s+\[DIR\]\s*$/ — only the literal attrib/d
marker. Added a regression test covering "Notes [old]", "Draft [v2].md",
"archived [2024]" alongside the existing [DIR] case to lock down both
behaviors together.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:32:33 +08:00
Eric Chan
0425841032 Fix ACP history replay and compaction (#754)
* Fix ACP history replay and compaction

* Fix PR keyword importance matching

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address codex review on #754: preserve short constraints + cancel-clear

Two recovery-path regressions flagged by codex review:

1. Compact ACP history dropped short load-bearing user constraints
   (acpHistory.ts:55). The blanket length<10 rule treated short
   non-trivial messages like "Use ssh2" or "中文输出" as filler,
   while longer generic follow-ups still ate the budget. After
   stale-session recovery the fresh ACP session would resume without
   constraints that were present in the original chat. Removed the
   length heuristic; the TRIVIAL_USER_MESSAGE_PATTERNS regex already
   filters actual filler ("ok", "yes", "继续", "thanks").

2. historyReplayFallback was only cleared on non-aborted streams
   (aiBridge.cjs:2837). If the user stopped the first turn after
   stale-session recovery, the flag stayed set. The next turn would
   then trigger shouldResetProviderForHistoryReplay, discard the
   freshly recovered ACP session (resumeSessionId is forced to
   undefined in that path), and re-spend tokens on another compact
   replay — breaking the cancel-preserves-session contract. Now we
   also clear on abort; the empty-but-not-aborted retry path in the
   if-branch above is unchanged.

Tests:
- New test in acpHistory.test.ts asserts "Use ssh2" / "中文输出"
  survive when pushed outside the recent raw window
- New test asserts "ok" / "继续" still drop (sanity check that the
  trivial regex still does its job without the length backstop)
- Updated "does not treat pr inside ordinary words as important" to
  no longer assert that approach/improve/prepare are absent — the
  test's real intent (priority-2 line still wins) is preserved by
  the 不要提交 assertion
- New test in aiBridge.test.cjs simulates a user cancelling the first
  turn after recovery and verifies the next turn reuses the
  recovered session (no extra provider creation, no re-replay)

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

* Address codex re-review: preserve replay flag across orthogonal recreation + keep tool output in raw window

Two more P2 regressions flagged on the second review pass:

1. historyReplayFallback was only carried over in the reset-for-replay
   branch of the provider recreation path. An orthogonal change between
   an empty recovered turn and its retry — a permission-mode toggle,
   MCP scope/fingerprint flip, or auth rotation — would flip
   shouldReuseProvider to false, enter the !shouldReuseProvider branch,
   and drop the flag because preserveHistoryReplayFallback only covered
   the shouldResetProviderForHistoryReplay case. The next turn then
   sent only the latest prompt and lost the recovered conversation.
   Now the flag is preserved on any recreation where a replay is still
   pending.

2. Tool messages didn't flow through toRawHistoryMessage at all, so on
   stale-session recovery they only survived as the 500-char compact
   summary in summarizeToolMessage. Any follow-up referencing the last
   tool output ("use that output", "what did cat show?") lost the
   actual bytes when they exceeded the compact cap. Now tool results
   travel through the recent raw window up to MAX_RAW_MESSAGE_CHARS
   (2000), flattened to the "assistant" role since ACP only accepts
   user/assistant.

Tests:
- aiBridge.test.cjs: new "preserves history-replay across provider
  recreation caused by permission-mode / MCP / auth change" —
  exercises the gap via a permission-mode toggle between an empty
  recovered turn and its retry. Extends mock to support a dynamic
  getPermissionMode.
- acpHistory.test.ts: new "preserves recent tool results verbatim" —
  pushes a ~1500-char tool output through the pipeline and asserts the
  replay still contains enough bytes to exceed the 500-char compact
  cap.

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

* Address codex round 3: inline tool_call context + bound durable scan

Two findings from the third codex review pass, both legitimate:

1. [P2] When the raw window starts mid-tool-interaction, the preceding
   assistant tool_call message can fall outside the 6-item slice while
   the tool_result stays in. Without the call's name+arguments, the
   result was opaque bytes and follow-ups like "use that output" had
   no provenance. The compact pass only preserved calls that matched
   IMPORTANT_PATTERNS, so read_file / grep / terminal_exec were
   silently dropped.

   Fix: build a toolCallId → { name, arguments } index from every
   assistant message and inline a `[from <name>(<args>)]` label next
   to each Tool result line in the raw window. Args are truncated to
   MAX_TOOL_CALL_LABEL_CHARS (200) so a verbose JSON payload can't eat
   the entire raw budget.

2. [P3] buildCompactContext scanned messages.entries() over the full
   transcript for durable-user/assistant candidates, even though
   MAX_MESSAGES_TO_SCAN (20) suggested the path was meant to be
   bounded. On a long ACP chat, every send did O(N) regex work plus
   an O(N log N) sort — the very chat-length-dependent latency the
   token-compaction PR was meant to address.

   Fix: introduce MAX_DURABLE_SCAN_MESSAGES (200) and restrict the
   durable scan to that tail. 200 is large enough to cover realistic
   sessions (99th-percentile chats are << 200 turns) while giving a
   constant-time worst case. Constraints older than the window age
   out of the compact replay; the live ACP provider's own persisted
   session still carries them when it can resume, which is the
   common path.

Tests:
- "inlines tool_call name+args so tool_result is interpretable without
  the preceding assistant turn" — pushes the tool_call out of the raw
  window and asserts the result line carries [from <tool>(<args>)].
- "bounds the durable-candidate scan to avoid O(N) work per send on
  long chats" — builds a 600+ message chat with an ancient priority-2
  constraint outside the scan window and a recent one inside; asserts
  only the recent one survives.

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

* Address codex round 4: preserve short assistant decisions + provenance on older tool results

Two P2 findings from the fourth codex pass, both mirror-images of earlier
fixes on a different code path:

1. Short assistant decisions dropped from compact replay
   (acpHistory.ts:75-83). isSubstantiveAssistantMessage required length
   >= 40 OR a small English keyword match OR a numbered list. Short but
   load-bearing replies like "Use ssh2", "rebase instead", "中文输出"
   satisfied none of those and were silently dropped from the durable-
   assistant compact section. Once they fell outside the 6-item raw
   window, "do what you suggested earlier" would replay only the user
   question without the assistant's actual decision.

   Fix: mirror the user-side loosening — drop the length/keyword gate,
   rely on TRIVIAL_ASSISTANT_MESSAGE_PATTERNS to filter actual filler
   ("ok", "ack", "got it", "明白").

2. Older tool results lost provenance (acpHistory.ts:108-114). The
   raw-window fix (round 3) only covered the last 6 items. Once a tool
   result fell into the compact section via summarizeToolMessage, the
   paired assistant tool_call was usually gone too, so multiple older
   outputs surfaced as indistinguishable "Tool result (callN): ...".
   Follow-ups like "use the resolv.conf output" had no way to map to
   the right call.

   Fix: plumb the toolCallIndex through summarizeMessage →
   summarizeToolMessage and inline `[from <name>(<args>)]` labels in
   the compact section too, the same shape the raw window uses.

Tests:
- New: preserves short non-trivial assistant decisions that miss the
  keyword heuristic (Use ssh2 / 中文输出 / rebase instead)
- New: still drops trivial assistant filler like 'ack' / 'ok' / '明白'
- New: inlines tool_call context on OLDER summarized tool results
- Updated earlier raw-window tool regex tests to match the [from X(Y)]
  shape ([^)] was failing to cross the args JSON's closing paren)

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

* Address codex round 5: de-dup raw ∩ compact + wire userSkills test into npm test

[P2] The scanned loop (last 20) overlaps with recentRaw (last 6), so
without a raw-window skip in the summarizeMessage path the same last-6
turns were summarized into the compact section AND appended verbatim
in the raw section. Important user turns and large tool output paid
the budget twice — eating into the 3k compact cap and crowding out
older durable context the replay is meant to preserve. Added the
same recentRawSourceIds skip the durable-user / durable-assistant
passes already use, and a regression test that asserts markers inside
the raw window don't surface in compact while still appearing in raw.

[P3] electron/bridges/ai/userSkills.test.cjs (added by this PR) sat
in a subdirectory that the default "npm test" glob
(electron/bridges/*.test.cjs) didn't pick up. The new routing /
index-budget regressions would never run locally or in CI until
someone noticed. Extended the glob to also match
electron/bridges/*/*.test.cjs; the userSkills tests are now included
in the 148-test run.

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

* Address codex round 6: cancel+immediate-send race + tool-call id collision

Two P2 regressions in the recovery path:

1. If the user clicks Stop and immediately sends the next prompt, the
   new stream handler's existingRun path unconditionally called
   cleanupAcpProvider — destroying the fresh ACP session the cancel
   IPC had just promised to preserve. The round-2 clear-on-abort
   fix ran too late (in post-stream code) to help, because the new
   stream can arrive before the aborted stream fully unwinds. In
   that common timing window the follow-up still started from a
   bare provider and lost all recovered conversation state.

   Fix: (a) cancel IPC now synchronously clears
   historyReplayFallback on the preserved provider entry, so the
   next stream can't trigger shouldResetProviderForHistoryReplay
   and tear the session down via that path; (b) the existingRun
   path skips cleanupAcpProvider when the prior run was already
   cancelled via the cancel IPC (captured via existingRun.cancelRequested
   before we overwrite it). True interrupt-and-restart without an
   explicit cancel still falls back to the old clean-slate behavior.

2. The tool-call provenance index used raw toolCall.id as the key.
   Nothing in ChatMessage or the ACP event path enforces per-chat
   unique ids, so a provider reusing "call1" across turns would
   overwrite the older entry and mis-label older tool results
   (e.g., an /etc/hosts result annotated as /etc/resolv.conf in
   the compact summary). That makes stale-session recovery
   misleading whenever a follow-up refers back to an earlier tool
   output.

   Fix: key the index by `${toolResultMessageId}:${toolCallId}` and
   walk the message stream in order, resolving each tool_result to
   the most recent preceding assistant tool_call with matching id.
   Each result keeps its own historically-correct label regardless
   of later id reuse.

Tests:
- aiBridge: "preserves recovered ACP session when user cancels then
  immediately sends the next prompt" — fires the next stream request
  after cancel but BEFORE releasing the first stream's blocked read,
  asserts providerCreationArgs.length stays at 2 (no third creation)
  and the second turn sends only the latest prompt.
- acpHistory: "resolves tool_call provenance correctly when tool ids
  are reused across turns" — two interactions sharing id "call1",
  asserts each tool_result carries its own call's args label.

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

* Address codex round 7: turn-based scan bound + single-pass history build

Two P2 regressions in long-chat / tool-heavy recovery paths:

1. MAX_DURABLE_SCAN_MESSAGES (200) bounded the scan by raw message
   count. ACP tool interactions store the user turn, assistant
   tool_call turn, and each tool_result as separate messages, so a
   tool-heavy chat can produce 5+ messages per logical turn. 200
   messages could be only 30-40 user turns — early constraints
   like "不要提交" from turn 5 fell out of the compact replay long
   before the turn count justified aging them out.

   Fix: bound by MAX_DURABLE_SCAN_TURNS (100 user turns) instead.
   Walk backwards from the end and stop after seeing 100 user
   messages. Realistic tool-heavy 30-turn chats now keep their
   early constraints alive, while true 100+ turn chats still
   benefit from the bound.

2. buildToolCallIndex(messages) and messages.flatMap(...).slice(-6)
   both walked the entire transcript on every send, even after the
   bounded compaction window landed. Compaction's stated purpose
   was to remove chat-length-dependent latency, but these per-send
   linear passes kept it.

   Fix: compute the scan start once via computeDurableScanStart,
   then do all subsequent work over messages.slice(durableScanStart).
   buildToolCallIndex walks only the window; the raw-6 flatMap also
   runs over the window. On a 1000-message chat with 100-turn
   window, send-time cost drops from O(1000) to O(~window_size).

Acceptable trade: if a tool_call's matching tool_result straddles
the window boundary (result inside, call outside), the single
surviving result loses its [from X(Y)] label. Tool_calls and their
results are almost always adjacent, so this affects at most the
first 1-2 messages of the window.

Tests:
- "preserves an early constraint in a tool-heavy chat where message
  count balloons past the raw-count limit" — 35 turns × 6 msgs/turn =
  212 messages. The old bound would have dropped the early
  EARLY_CONSTRAINT_MARKER; with turn-based bound it survives.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:52:57 +08:00
陈大猫
156550f7eb Add Close All / Others / To-the-Right tab actions (#748) (#764)
Adds three bulk-close items to the right-click context menu on tabs:
- Close Others
- Close Tabs to the Right
- Close All

Anchor is the right-clicked tab (matches VSCode/JetBrains/FinalShell
UX), not the active tab. The "to the right" item is disabled when the
anchor is already the rightmost tab; "Close Others" is disabled when
it's the only tab.

To avoid spamming a busy-shell modal per tab, the new closeTabsBatch
helper in App.tsx expands workspace ids into their session ids, runs
ONE confirmIfBusyLocalTerminal probe across the whole batch, and only
proceeds when the user confirms. The probe + close path itself reuses
the existing PR #739 plumbing (ptyProcessTree + confirmCloseBusy).

Closes #748

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:40:11 +08:00
陈大猫
a1648adf12 Add opt-in setting to preserve mouse selection across keystrokes (#755) (#763)
* Add opt-in setting to preserve mouse selection across keystrokes

Closes #755.

xterm.js hardcodes a "clear selection on user input" listener
(SelectionService.ts: coreService.onUserInput → clearSelection) with
no public option to disable. The user-reported workflow this breaks:
select a path with the mouse, type a command prefix like `sz `, then
middle-click-paste the still-live selection — but the very first
keystroke wipes the selection, so there's nothing left to paste.

Modern terminals (iTerm2, GNOME Terminal, Windows Terminal) preserve
the selection across input by default. We expose this as an opt-in
toggle for now since the visual semantics are a behavior change.

Implementation is capture-and-restore via xterm.js public APIs
(getSelectionPosition / select); xterm clears the selection
synchronously, then a queueMicrotask reapplies it on the next tick.
A ref (isRestoringSelectionRef) gates copy-on-select so the restore
doesn't redundantly rewrite the clipboard and clobber whatever the
user copied elsewhere in between.

Defaults to false (opt-in); can flip to default-on later if reception
is positive. Selection still clears on:

- Mouse click in empty space (xterm's mouse-driven path is untouched)
- Terminal scroll past the selected rows (existing buffer-trim logic)
- Programmatic clearSelection() callers

Files:
- domain/models.ts — new field, default false
- application/syncPayload.ts — added to SYNCABLE_TERMINAL_KEYS
- components/terminal/runtime/createXTermRuntime.ts — capture in
  attachCustomKeyEventHandler, restore via queueMicrotask
- components/Terminal.tsx — owns isRestoringSelectionRef, passes it
  through context, checks in copy-on-select listener
- components/settings/tabs/SettingsTerminalTab.tsx — UI toggle
- application/i18n/locales/{en,zh-CN}.ts — labels

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

* Trim verbose i18n descriptions to match neighboring rows

Both clearWipesScrollback and preserveSelectionOnInput descriptions
were too long. Cut to one sentence each, matching the brevity of
adjacent rows like Bracketed paste and OSC-52. Historical context and
edge-case caveats belong in the changelog/PR, not the settings UI.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:22:48 +08:00
陈大猫
8182bd6b3c Fix invisible caret in settings window inputs on Windows (#760) (#762)
Symptom: in the Settings window (especially AI > Add Provider, but also
seen in Add Host), clicking an input occasionally shows no caret and
typed characters don't appear, yet select-all + delete still works on
the input's content.

Root cause: PR #502 introduced settings-window prewarming and
hide-on-close reuse. On Windows, calling `BrowserWindow.focus()` from
a non-foreground process is restricted by SetForegroundWindow rules —
the window is shown on top but never actually receives OS foreground
focus. With `document.hasFocus() === false`, Chromium deliberately
suppresses caret blink and keyboard routing, even though clicking an
input still moves activeElement to it (so non-keyboard interactions
like select-all-then-delete keep working — exactly the reported
symptom).

Fix: introduce `showAndFocusWindow(win)` and call it everywhere the
settings window is shown:

- Apply the alwaysOnTop toggle on win32 to bypass the
  SetForegroundWindow restriction (established Electron workaround)
- Always call `webContents.focus()` after `win.focus()` so the renderer
  marks the document as focused regardless of what the OS decided —
  this is what restores the caret + keyboard routing

Scope intentionally limited to the settings window (the path PR #502
introduced). Other windows use a different show path (ready-to-show
event) and were not reported to have the issue.

I cannot test this on Windows directly. The fix follows a
well-documented Electron pattern and the diagnosis matches the
reported symptoms (Windows-only, intermittent, post-1.0.81 only).

Closes #760

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:44:37 +08:00
陈大猫
484ac5f463 Honor CSI 3 J by default; add toggle to preserve scrollback on clear (#761)
* Honor CSI 3 J by default; add toggle to preserve scrollback on `clear`

Default `clear` (ncurses ≥ 2013) emits CSI 2 J + CSI 3 J to wipe both
visible screen and scrollback. PR #633 unconditionally intercepted CSI
3 J to keep history across `clear`, which broke POSIX semantics — users
running standard `clear` could not wipe scrollback at all (#757).

Restore the standard behavior as the default and expose a toggle for
the iTerm2-style "preserve history" preference (matches what #622
asked for):

- domain/models.ts: add `clearWipesScrollback: boolean` (default true)
- createXTermRuntime.ts: CSI 3 J handler now reads the setting and
  only intercepts when the user opts out
- SettingsTerminalTab.tsx + i18n: expose the toggle with a description
  explaining the tradeoff
- The right-click "Clear Buffer" menu action keeps its independent
  semantics (always preserves scrollback) regardless of this setting,
  since it goes through `clearTerminalViewport`, not the CSI path

Closes #757

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

* fix: include clearWipesScrollback in cloud-sync terminal keys

Codex review on PR #761 caught that the new toggle was added to
TerminalSettings but not to SYNCABLE_TERMINAL_KEYS, so it would never
travel across devices via cloud sync — users disabling it on one
device would silently get the default back on another after sync.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:17:33 +08:00
陈大猫
98e3a6b952 Let single Tab fall through to shell when only ghost text is shown (#745)
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Closes #741. Bash/zsh use Tab for native completion, but our ghost-text
accept on single Tab was swallowing the keystroke before it reached the
PTY. Ghost text is still accepted with →; Tab in popup-menu mode is
unchanged (popup is an explicit UI so intent is clear).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:44:57 +08:00
陈大猫
f6f3147afb Tab bar: duplicate-adjacent insertion + wheel-to-horizontal scroll (#743)
* Improve tab UX: insert duplicated tabs adjacent to source, enable wheel scroll on tab bar

Addresses #737.

- Duplicating a tab now inserts the new tab immediately after the source
  in the tab order, instead of appending it to the far right where it
  was hard to find with many tabs open.
- The top tab strip now translates vertical mouse-wheel deltas into
  horizontal scrolling, so users with many tabs can reach the ends of
  the strip without dragging. Trackpad gestures that already carry
  horizontal delta are left alone to preserve native two-finger swiping.

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

* Address Codex review: read source session inside functional updater

Codex flagged that reading `session` from the closure broke the atomicity
guarantee of the previous implementation — rapid repeated duplicates could
miss freshly queued state.

- Pre-allocate the new session id outside both setters so it stays stable
  across StrictMode double-invocations.
- Move the source lookup back into `setSessions`' functional updater so it
  always reads the freshest committed/queued state.
- Drop `sessions` from the useCallback dependency list now that we no
  longer read it.
- Fast-path tabOrder insertion when the source is already in tabOrder to
  avoid re-deriving the full effective order in the common case.

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

* Address Codex review: gate active-tab and tab-order updates on successful create

Codex flagged that `setActiveTabId(newSessionId)` and `setTabOrder(...)` ran
unconditionally even when `setSessions` bailed out (source tab was closed
before the duplicate handler ran). That left activeTabId pointing at an id
that was never appended to sessions, putting the terminal layer into an
invalid "no matching tab" state.

Move both nested setState calls inside the `setSessions` functional updater
so they only fire when the source is actually present. Mirrors the original
pre-PR pattern; nested updates are idempotent so StrictMode's
double-invocation is harmless.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 00:41:31 +08:00
陈大猫
54b26511a1 Cloud sync data-loss prevention (4-layer defense) (#742)
* feat(sync-guard): extend SyncState with BLOCKED + add shrink event variants

* feat(sync-guard): add detectSuspiciousShrink pure function with 12 unit tests

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

* polish(sync-guard): drop unnecessary cast, sharpen test naming, pin priority invariant

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

* fix(test): include domain/*.test.ts in npm test glob

* feat(sync-guard): gate syncToProvider with shrink detection + force-push override

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

* fix(sync-guard): reset overrideShrinkOnce before early return for invariant strictness

* fix(sync-guard): extend shrink guard to syncAllProviders (the actual sync entry point)

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

* feat(sync-guard): apply empty-vault guard uniformly to auto and manual sync

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

* feat(sync-guard): preserve merge base on same-account re-auth

Adds providerAccountId persistence; completePKCEAuth and completeGitHubAuth
now only clear syncBase/anchor when the authenticated account id differs from
the previously stored one, preventing zombie-entry resurrection on token
refresh. disconnectProvider clears the stored id so a reconnect starts fresh.

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

* feat(sync-guard): add i18n strings for sync-blocked banner + force-push modal

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

* feat(sync-guard): add SyncBlockedBanner showing shrink findings with restore/force-push actions

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

* fix(sync-guard): stable subscribeToEvents reference + type-safe finding narrowing

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

* feat(sync-guard): force-push confirmation modal + scroll restore button into view

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

* ux(local-backups): show version as title, demote reason+timestamp to meta line

* feat(local-backups): record + display sync data version (v5/v6...) on each backup

Each backup now captures the live CloudSyncManager.localVersion at creation
time. UI shows it as title (v5, v6, ...) with timestamp + reason demoted to
the meta line. Backups created before this field existed (or before any
successful cloud sync) fall back to timestamp as title.

Replaces the earlier app-version-transition title which conflated app
version with sync data version.

* fix(sync-guard): consume override flag at sync entry + restore provider status on block

- Snapshot+clear overrideShrinkOnce at top of syncToProvider and
  syncAllProviders so an early-return cannot leak the flag to a later
  unrelated sync (Codex P1).
- Restore provider status to 'connected' when shrink-block returns from
  syncToProvider; previously left provider stuck on 'syncing' in the
  UI (Codex P2).
- Process pre-existing check errors before returning from the
  shouldBlockAll branch in syncAllProviders so a check-failed provider
  isn't dropped from results (Codex P2).

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

* fix(sync-guard): refactor force-push to parameter passing + add credential-availability guard

The previous design used a one-shot boolean flag on CloudSyncManager set
by forcePushOverrideShrink(). Even with snapshot+clear at sync entry
points, the renderer wrapper's await ensureUnlocked() could throw before
the flag was consumed, leaving it armed for the next unrelated sync.

Fix: pass overrideShrink as a call-time parameter through the chain.
Eliminates the persistent flag and its leak surface.

Also: force-push now runs the same ensureSyncablePayload(...) guard the
other manual sync entry points use, so a vault with encrypted-credential
placeholders won't be uploaded via the force path either.

Addresses the latest two Codex P1/P2 findings on #742.

* fix(sync-guard): backfill account id from in-memory state for upgrade-path re-auth

Users upgrading to this PR have no netcatty.sync.accountId.* persisted yet.
On their first re-auth the guard saw previousId=null and cleared the
merge base anyway, defeating the point of the same-account preservation.

Snapshot the in-memory account id BEFORE overwriting providers[provider]
and use it as a fallback when the persisted id is missing. New users
(no prior connection at all) still get the clear-on-first-auth path.

Addresses Codex P1 on #742.

* fix(sync-guard): inspect force-push results + mark blocked single-provider as error

- Force-push handler now inspects syncNow result entries: applies any
  mergedPayload to local state, only clears the banner when all providers
  report success, surfaces a toast error otherwise. Previously the banner
  cleared unconditionally regardless of network/auth failures (Codex P1).

- syncToProvider shrink-block branches now mark provider status as
  'error' with a 'Sync blocked: would delete too much' message instead
  of 'connected'. Status aggregators treat 'connected' as healthy, so
  the blocked upload was surfacing as 'synced' in the UI (Codex P2).
  syncAllProviders already used this pattern; this brings the
  single-provider path in line.

* fix(sync-guard): exempt USE_LOCAL conflict + clear post-merge BLOCKED + expose 'blocked' status

- USE_LOCAL conflict resolution now passes { overrideShrink: true }: the
  conflict modal already served as user confirmation, and shrink-blocking
  it left users with a closed modal and an opaque banner (Review C-1).

- Post-merge round-trip in useAutoSync now detects shrink-blocked results
  and resets syncState to IDLE via new manager.clearShrinkBlockedState().
  The merged data is already applied locally; the next user-triggered
  sync will re-check, and we don't wedge the manager in BLOCKED with no
  visible banner outside the Settings tab (Review I-1).

- overallSyncStatus now reports 'blocked' as a distinct value from
  'error', so downstream UI (status icon, future badges) can offer
  shrink-block-specific affordances (Review I-2).

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

* fix(sync-guard): stabilize banner subscription dep + map 'blocked' status to error indicator

- The SyncBlockedBanner subscription useEffect depended on [sync] (the
  whole hook return object), which gets a new reference every render.
  This caused the listener to be unsubscribed+resubscribed on every
  render, opening a tiny race window where a SYNC_BLOCKED_SHRINK event
  could be missed and the banner would never appear. Destructure
  subscribeToEvents (already useCallback-stable) and depend on it
  directly, so the effect runs exactly once on mount.

- SyncStatusButton's status mapping had no arm for the new 'blocked'
  value, falling through to 'none' (idle). The global status indicator
  said healthy while the in-page banner said paused. Map 'blocked' to
  the same error indicator used for 'conflict' so the UI is consistent.

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

* fix(sync-guard): only clear banner on actual success + hydrate from manager state

- Banner subscription now clears only on SYNC_COMPLETED with result.success.
  SYNC_STARTED (auto-sync timer ticks) and SYNC_FORCED (fires BEFORE upload)
  could clear the banner prematurely, removing the user's recovery affordance
  while the underlying issue was unresolved (Codex P2).

- Manager now persists the last shrink finding in state.lastShrinkFinding
  alongside the SYNC_BLOCKED_SHRINK emission. New public getter
  getShrinkBlockedFinding() returns it when syncState is BLOCKED. Renderer
  hydrates the banner on mount so a block that happened off-screen
  (auto-sync while user was on another tab) is still visible when they
  open Sync Settings (Codex P2).

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

* fix(sync-guard): unified BLOCKED-cleared event + USE_LOCAL inspects results

- USE_LOCAL conflict resolution now inspects syncNow() results, applies
  any mergedPayload to local state, surfaces a toast error and KEEPS the
  modal open on failure (so user can switch to USE_REMOTE). Mirrors the
  force-push handler pattern. Without this, USE_LOCAL silently 'succeeded'
  even when providers failed (Codex CLI P1).

- New SYNC_BLOCKED_CLEARED event emitted on every BLOCKED -> non-BLOCKED
  transition via a private exitBlockedState() helper. Banner subscribes to
  this single signal instead of guessing from per-provider SYNC_COMPLETED
  events. Fixes:
    - Multi-provider scenarios where first SYNC_COMPLETED clears the banner
      while a later provider was still going to fail (Codex CLI P1).
    - clearShrinkBlockedState() (post-merge self-heal) silently leaving
      the banner stuck because no event was emitted (Codex CLI P2).

- disconnectProvider() now also exits BLOCKED state. Disconnecting
  implicitly resolves any pending shrink-block warning, otherwise the
  stale alert carried over to the next-account reconnect (Codex CLI P2).

- All BLOCKED -> non-BLOCKED transitions consolidated through
  exitBlockedState() so lastShrinkFinding cleanup + event emission are
  always paired (Codex CLI P3 #6 covered).

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

* fix(sync-guard): only clear BLOCKED on actual success, not on transient ERROR/SYNCING/CONFLICT

Previous patch called exitBlockedState() at every BLOCKED -> non-BLOCKED
transition, but this clears the banner on transitions that don't actually
resolve the shrink concern:

- SYNCING (sync just started — about to try, may fail)
- ERROR (transient transport failure, shrink concern still real)
- CONFLICT (separate concern; doesn't resolve the shrink)

If a user was in BLOCKED then triggered a sync that failed for an unrelated
reason (network, auth), the banner cleared and they lost the warning.

Restrict exitBlockedState() to terminal-success transitions:
- IDLE on successful upload (data made it to cloud — concern resolved)
- explicit clears (disconnectProvider, clearShrinkBlockedState)
- conflict resolution (USE_REMOTE/USE_LOCAL also end in IDLE)

Found by Codex CLI review of commit 12d7fa7b.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:43:19 +08:00
陈大猫
8ef91e1266 Ctrl+W close priority + local shell busy confirmation (#739)
* feat(ctrl-w): add ps-node + windows-process-tree + tsx deps for close-priority feature

* fix(ctrl-w): drop ps-node dep and add windows-process-tree to asarUnpack

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

* feat(ctrl-w): add ptyProcessTree bridge with per-platform child-process enumeration

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

* fix(ctrl-w): ptyProcessTree uses args= for full command + warns on pid overwrite

- Replace `comm=` with `args=` in defaultListPosix so the full command
  line is captured on both macOS (BSD ps) and Linux (GNU ps), avoiding
  the 15-char TASK_COMM_LEN truncation.
- Add console.warn in registerPid when the same sessionId is overwritten
  with a different pid, making the race condition visible in logs.
- Add test: registerPid warns exactly once on a pid change, not on a
  same-pid re-registration.

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

* feat(ctrl-w): register local PTY pid with ptyProcessTree on spawn/exit

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

* fix(ctrl-w): unregister pids in cleanupAllSessions to match per-delete invariant

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

* feat(ctrl-w): add IPC handlers for pty child processes and confirm-close dialog

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

* fix(ctrl-w): guard BrowserWindow.fromWebContents null and document dialog dismiss contract

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

* feat(ctrl-w): expose ptyGetChildProcesses and confirmCloseBusy on window.netcatty

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

* feat(ctrl-w): add i18n strings for close-busy-terminal dialog

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

* feat(ctrl-w): add resolveCloseIntent pure function with 8 unit tests

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

* feat(ctrl-w): expose handleCloseSidePanel via ref to App.tsx

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

* feat(ctrl-w): wire resolveCloseIntent + local-shell busy confirmation into closeTab hotkey

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

* fix(ctrl-w): add re-entrancy guard, aggregate busy count, sync sidebar ref, dedupe intent branches

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

* feat(ctrl-w): auto-close workspace when its last session is closed

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

* fix(ctrl-w): sidebar close wins over focused terminal in priority chain

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

* fix(ctrl-w): sidebar priority applies to single-session tabs too, not just workspaces

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

* fix(ctrl-w): compute empty-workspace auto-close outside setSessions updater

Addresses Codex P2 on #739: React 18+ does not guarantee updater
execution timing under concurrent scheduling. Moving the decision
outside the updater makes the microtask queue deterministic.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:30:11 +08:00
Eric Chan
b2689f96a4 Clarify Netcatty CLI launcher guidance (#738) 2026-04-16 14:59:24 +08:00
陈大猫
1b23bdcf15 [codex] Preserve terminal focus when clicking the toolbar overlay (#734)
* fix terminal toolbar focus loss

* restore focus after closing side panels

* fix terminal side panel focus helper order
2026-04-16 11:08:09 +08:00
陈大猫
2e63848e0e fix empty ssh identification banners (#733) 2026-04-16 10:34:51 +08:00
陈大猫
3a748aa1aa fix serial duplicate host save (#732) 2026-04-16 10:15:37 +08:00
Eric Chan
4574f1e2b2 fix: stabilize scoped AI draft/session transitions (#724)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: correct terminal AI history resume behavior

The previous implementation plan mistakenly treated reopening an old terminal AI session in a fresh or reconnected SSH tab as a scope-retargeting feature.

The intended rule is draft-first:
- a fresh or reconnected terminal opens on a blank draft
- older chats remain available in history for manual access
- selecting history does not imply automatic scope transfer into the new tab

This change is a rule correction, not a conflict between product rules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: harden ai draft transitions

* fix ai session continuation from history

* fix: clear stale activeSessionIdMap entry when view resolves to draft

Addresses the Codex P2 review on aiPanelViewState.ts:38. When a terminal
scope mounts with a persisted activeSessionIdMap entry but no explicit
panelView and no draft, resolveDisplayedPanelView now returns the
default draft view (terminal fresh-start behavior). The sync effect
that writes into activeSessionIdMap is guarded by `if (!activeSession)
return`, so the old entry stays put. That stale entry then leaks into
activeTerminalTargetIds in every other scope, and
getSessionScopeMatchRank uses it to suppress host-matched history that
is actually resumable — so valid sessions vanish from the history
drawer until another action rewrites the map.

Add a dedicated effect that clears the scope's activeSessionIdMap
entry whenever the resolved panel view is draft but a persisted
session id is still present. This keeps the map an accurate record of
"which session each scope is currently showing" instead of a lagging
snapshot.

Also extend sessionScopeMatch.test.ts to cover the rank=2 exact-match
branch and the scope-type mismatch short-circuit, which were missing
from the original suite.

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

* fix: track cross-terminal session ownership by session id, not targetId

Addresses the Codex follow-up review on commit 345244b2. When a user
resumes a session from history into a different terminal, the session's
`scope.targetId` still points at the original terminal. The previous
ownership tracking — which checked whether `session.scope.targetId`
appeared in `activeTerminalTargetIds` (derived from the keys of
`activeSessionIdMap`) — therefore:

- could not prevent the same session from being resumed in multiple
  terminals simultaneously, because the resumed session's targetId
  never matches the current scope's targetId; and
- let `pruneInactiveScopedSessions` treat a session as orphaned and
  clear its `externalSessionId` the moment the original terminal
  closed, even though another terminal was actively using it.

Switch ownership to be keyed on session id:

- `getSessionScopeMatchRank` now takes `activeTerminalSessionIds`
  (a Set of session ids currently displayed by other terminal scopes)
  and returns rank 0 when `session.id` is in that set.
- `AIChatSidePanel` derives `activeTerminalSessionIds` from the
  *values* of `activeSessionIdMap`, excluding the current scope's key.
- `pruneInactiveScopedSessions` gains an `activeSessionIds` parameter;
  sessions whose id is in this set are never reported as orphaned and
  never have their `externalSessionId` cleared, regardless of their
  stored `scope.targetId`.
- `cleanupOrphanedAISessions` computes the in-use set from the
  pre-cleanup `activeSessionIdMap`, filtered to live scopes, and
  passes it through. The map is read once and reused.

Tests cover the new id-based ownership, the rank-2 exact-match path,
the scope-type-mismatch short-circuit, and the
"resumed-elsewhere session must not be cleaned" invariant.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:16:10 +08:00
陈大猫
081b167172 feat(ai-chat): fit-to-content popovers + keyboard nav for @/slash menus (#726)
* feat(ai-chat): fit-to-content popovers and keyboard nav for @/slash menus

- Shrink the @ host and /skill popovers to their content width
  (auto width with min 220px, capped at the input width) instead of
  always filling the full input width, which left large empty gutters
  when the list was short.
- Add keyboard navigation: ArrowUp/ArrowDown cycle through items,
  Enter commits the highlighted item, Escape closes the menu. Mouse
  hover stays in sync with the active index so keyboard and pointer
  agree on which row is current. Enter does not fall through to
  submit while a menu is open.
- Expose aria-selected / aria-activedescendant for screen readers.

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

* style(ai-chat): tone down popover radius to match other menus

The @ and /skill popovers used rounded-[20px]/rounded-[16px] which
stood out against every other popover in this file (rounded-lg with
rounded-md items). Switch to the shared radii and drop shadow-2xl for
the standard shadow-lg so the surface feels consistent.

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

* style(ai-chat): tighten mention popover spacing

- Drop the redundant "Hosts" / "User Skills" header row — the @ or /
  trigger already makes the popover's purpose obvious, and the header
  added ~30px of vertical whitespace above a single-line list.
- Shrink wrapper and item padding (p-2.5/px-3 py-1.5 -> p-1/px-2 py-1)
  and remove the mt-0.5 gap between title and subtitle.
- Hide the hostname subline when the label already contains the
  hostname (common case: "Rainyun-114.66.26.174" as label and
  "114.66.26.174" as hostname — no need to repeat).
- Lower minWidth 220 -> 200 so short lists can shrink further.

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

* fix(ai-chat): address Codex review on PR #726

- Reset active menu index on any change to the *set* of visible items,
  not just its length. Watching only `.length` let Enter commit a
  different item when the slash query changed to a same-sized match
  set. Derive a stable identity key (sessionIds / skill ids) and use
  that as the effect dep instead.
- Clamp the popover's minWidth to the measured panel width so narrow
  layouts don't end up with minWidth > maxWidth, which CSS resolves
  by honoring min and clips the menu off-screen.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:25:51 +08:00
陈大猫
a818a7004f fix: remove invalid eval -- in fish shell wrapper (#725)
Fish's `eval` builtin does not recognize `--` as an end-of-options
marker, so the wrapper failed with `fish: Unknown command: --` for
every AI Agent command under fish. The `--` was unnecessary since
fish's `eval` has no options to terminate.

Fixes #721

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:58:26 +08:00
陈大猫
5bc5a6c8b2 fix: address Codex follow-up review on PR #720 (#723)
Some checks failed
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: address Codex follow-up review on PR #720

Two issues surfaced by Codex's post-merge review of PR #720:

P1 — useAutoSync.ts: startup retry exhaustion wedged auto-sync.
The retry effect previously returned at `attempt >= 4` without
opening `remoteCheckDoneRef`. A session with persistent inspect
failures (long network outage, provider rate-limit loop) left
auto-sync silently disabled for the rest of the session until
restart or provider/unlock transition. After exhaustion, open the
gate: the specific dangers we gate-closed against (empty-push,
partial-apply push) are now covered by independent guards
(`hasMeaningfulSyncData`, the apply-in-progress sentinel, and
`checkProviderConflict`'s inspect-failure throw at upload time).
This matches manual sync's existing semantic rather than silently
strict-gating auto-sync.

P2 — CloudSyncSettings.tsx: restore buttons were per-row disabled,
not globally. A user could click Row A, then Row B while A was
still applying — two concurrent `applyProtectedSyncPayload` calls
in the same window. `withRestoreBarrier` serializes across windows
but NOT same-window re-entry, so the second restore's
sentinel-clear could mask a still-partial first apply. Disable
every restore button while any restore is in flight.

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

* fix: keep auto-sync gate closed on retry exhaust; open on manual sync

Codex's re-review of PR #723 correctly flagged that opening the
auto-sync gate after startup retry exhaustion reintroduces the
destructive-clobber path the gate was supposed to prevent. Concrete
scenario: local vault is partially lost (non-empty, just missing
entries), remote has not changed since our last anchor, user edits a
field after a long outage → auto-sync pushes the partially-lost
vault over the intact remote. `checkProviderConflict` doesn't catch
this (anchor matches), `hasMeaningfulSyncData` doesn't catch this
(non-empty), and the empty-vault prompt doesn't fire.

Revert the retry-exhaust gate-open. The gate now stays closed until
either:

  1. A startup `checkRemoteVersion` succeeds (normal path), OR
  2. A `syncNow` completes successfully. A manual sync from Settings
     implicitly runs per-provider `checkProviderConflict` — the same
     inspect the startup path would have done — so a successful
     manual sync is equivalent to a successful startup reconciliation
     from the gate's point of view and opens the gate for the rest
     of the session.

This preserves Codex's safety ask (no auto-push without a confirmed
remote state) while giving the user a clear escape hatch (manual
sync) that doesn't require a restart.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:37:36 +08:00
陈大猫
6c8a39d269 feat: add stable CSS hooks to tab components (#714) (#722)
* feat: add stable CSS hooks to tab components (#714)

Expose stable attributes on every tab-like element so custom CSS can
target them reliably without chaining utility-class selectors or
relying on inline-style substring matches:

- data-tab-id: already present on session/workspace/logView/sftp tabs;
  now also added to the side-panel buttons (sftp/scripts/theme/ai)
  in TerminalLayer.tsx.
- data-tab-type: session | workspace | logView | sftp | sidepanel,
  lets a selector target one tab family without matching the rest.
- data-state: active | inactive, mirroring Radix Tabs' convention so
  users who already style Settings tabs can reuse the same idiom.
- .netcatty-tab class: a single, scope-free hook for "every tab,
  anywhere" — pairs with data-state="active" for the common "style
  the selected tab" recipe.

No visual changes. The existing inline-style / utility-class selectors
the issue reporter had to chain ([style*="var(--top-tabs-active-bg"],
.app-no-drag.relative.h-7.px-3, etc.) keep working, so no breakage
for people who've already written custom CSS.

Custom CSS can now be written as:

  .netcatty-tab[data-state="active"] { ... }
  [data-tab-type="sftp"][data-state="active"] { ... }
  [data-tab-id="ai"][data-state="active"] { ... }

Closes #714

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

* feat: add CSS hooks to the root Vaults/SFTP tabs (#714)

The fixed-left root tabs ("Vaults" and "SFTP") in TopTabs.tsx were
missed in the first pass — they don't go through the session /
workspace / logView branches, so their div rendered without the new
data-tab-id / data-tab-type / data-state attributes or the
.netcatty-tab class.

Add them so custom CSS can target the whole root tab row the same
way:

  [data-tab-type="root"][data-state="active"] { ... }
  [data-tab-id="vault"] { ... }
  [data-tab-id="sftp"] { ... }

No visual change.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:22:15 +08:00
陈大猫
db69d5ac39 [codex] Harden sync overwrite protection and add local restore history (#720)
* fix: harden sync overwrite recovery

* refactor: separate backup retention settings

* refactor: align backup retention controls

* refactor: simplify backup retention card

* fix: address PR #720 deep-review findings

- Close the cross-window restore race by holding a time-bounded barrier
  in localStorage during every destructive apply; useAutoSync skips
  pushes while it's set, preventing a pre-restore snapshot from
  clobbering just-restored cloud data.
- Round-trip startup three-way merges so merged-in local additions
  actually reach the cloud instead of living only on the device that
  ran the merge until the next edit.
- Upgrade sync signatures from a 64-char ciphertext prefix to full
  SHA-256 (v3), closing the tail-mutation replay weakness.
- Harden the vault-backup IPC: payload size cap, enum-validated reason,
  sanitized version strings, strict maxCount, concurrent-call mutex,
  monotonic createdAt to avoid same-ms ordering ties.
- Extract the anchor-change decision into a pure module with unit tests
  covering no-anchor, resource-id drift, and signature mismatch paths.
- Capture the protective backup from the pre-apply closure snapshot so
  it reflects what's being replaced rather than what was imported.

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

* fix: address PR #720 follow-up review findings

Make protective backup abort-on-failure (was best-effort console.error),
preserve nested syncedAt in fingerprint, use UTF-8 byte length for size
guard, throw on conflict-inspect failure so stale uploads can't leak
through, treat unreadable remote as changed, canonical-JSON signature
meta, and hold the version stamp on transient backup failures so the
retry path still fires.

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

* fix: address second-pass review findings on PR #720

- Hold version-change stamp when payload is non-meaningful (covers the
  startup vault-rehydrate race where a transient empty snapshot would
  permanently skip the upgrade backup).
- readBackupRecord stat-checks before readFile so an oversized file in
  the backup dir cannot OOM the renderer on enumeration.
- Reject maxBackups input outside 1..100 instead of silently clamping
  (matches the i18n error copy and the main-process sanitizer bound).
- Wrap USE_LOCAL conflict-resolution push in withRestoreBarrier so a
  concurrent auto-sync in another window cannot interleave.
- sha256Hex throws SyncSignatureUnavailableError on missing WebCrypto
  subtle; createSyncedFileSignature returns null, forcing the
  unreadable-remote → three-way-merge path instead of a weak
  length-only pseudo-signature.
- Document that array order in normalizePayloadForHash is an invariant
  enforced by producers, not the hash function.
- Drop three-way-merge completion logs from console.log to console.info.
- Comment the implicit restore → store-listener refresh chain so
  future refactors don't silently break the UI reload path.

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

* fix: address third-pass review findings on PR #720

Resolves I-3 through I-8 and related cleanup items identified in the
deep review. Highlights:

- replace setTimeout(0) post-merge round-trip with a direct
  syncAllProviders call using the already-computed merged payload,
  removing the React-commit race
- resolve the empty-vault confirmation promise on unmount so a
  mid-dialog window teardown doesn't leak the resolver
- retry the version-change backup as hosts/keys hydrate, instead of
  latching on the first (possibly empty) snapshot
- heartbeat-refresh the cross-window restore barrier so long applies
  cannot expose a post-60s window to concurrent auto-sync
- add a diagnostic warning when connected providers hold divergent
  bases (multi-account configurations)
- surface a user-visible "Sync paused" toast when startup inspect
  fails, replacing the previous silent gate-open
- tie-break backup list sort by id when createdAt collides
- extract applyProtectedSyncPayload so the main and settings windows
  cannot drift on restore-barrier / protective-backup handling

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

* fix: address deep-review findings on PR #720

Deep re-review surfaced six Important issues that survived the prior
four review rounds. All are hardened here:

- I1: fsync the protective backup file AND its directory before the
  rename completes, so a system crash between backup creation and the
  restore it guards cannot leave a torn/zero-length safety net.
- I3: persist an apply-in-progress sentinel across the non-atomic
  localStorage writes in applySyncPayload. A crash mid-apply now
  surfaces on the next startup (toast + refuse auto-push) instead of
  silently pushing the half-applied state over an intact cloud copy.
- I2: only open the auto-sync gate (remoteCheckDoneRef) when the
  startup inspect validated cleanly. Add a bounded exponential-backoff
  retry so a transient inspect failure self-heals instead of wedging
  auto-sync until restart.
- I5: save the sync base BEFORE advancing the per-provider anchor
  inside uploadToProvider. A renderer crash between the two writes
  now degrades to "stale anchor forces re-inspect on next run," which
  re-merges against the fresh base — eliminating the silent
  base-drift window where a 3rd-device race could misclassify
  entries.
- I6: main process broadcasts a vaultBackups:changed IPC event on
  every mutation; useLocalVaultBackups subscribes so protective
  backups created from the main window show up in the Settings
  backup list without manual refresh.
- I4: update PR description + code comment to match the actual
  (safer) design: auto-sync gate opens on vault init, with
  hasMeaningfulSyncData + restore barrier preventing empty-push; the
  version-change backup is best-effort and retries as data hydrates.

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

* fix: serialize startup checkRemoteVersion and stabilize its deps

Re-review flagged that checkRemoteVersion's useCallback depended on
`config` — a fresh object literal from App.tsx on every render — so
the retry effect restarted with attempt=0 on every vault edit and
could spawn overlapping in-flight inspect+apply runs. Two concurrent
commitRemoteInspection + onApplyPayload calls could race on the
apply-in-progress sentinel around interleaved writes.

Route `buildPayload`, `config.onApplyPayload`, and `config.startupReady`
through refs so checkRemoteVersion's identity no longer churns with
unrelated App state. Add an in-flight guard that returns early when a
previous invocation is still awaiting the network, closing the
same-window re-entry gap that withRestoreBarrier intentionally doesn't
cover.

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

* fix: release in-flight lock on no-connected-provider early return

Third-pass review caught that `checkRemoteInFlightRef` was acquired
before the `!connectedProvider` check, so that early return leaked
the lock and every subsequent retry-timer tick silently no-op'd.
Move the acquisition past the early return so the only path that
takes the lock reaches the finally-release.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:09:55 +08:00
陈大猫
ee400f424b Merge pull request #718 from binaricat/fix/mac-fullscreen-tray-hide-show-race
fix: stop cancelling mac fullscreen tray-hide on internal show event
2026-04-14 23:32:10 +08:00
bincxz
ba93e2fa35 fix: do not cancel pending close-to-tray hide on window show event
Follow-up to the trailing-show fix. Codex review on #718 flagged that
`focusMainWindow()` in main.cjs (called from `app.on("second-instance")`
and as the fallback path of `app.on("activate")`) still calls
`win.show()/focus()` without cancelling any in-flight close-to-tray
pending hide. A user who closes a fullscreen window to tray and then
relaunches the app via a second instance would see the window briefly
reappear and get hidden again when `leave-full-screen` lands.

Add `clearPendingFullscreenHide(win)` at the top of `focusMainWindow()`
so every reopen entry point (dock click, second-instance, activate
fallback) cancels the pending hide before showing the window.
2026-04-14 23:26:38 +08:00
bincxz
591b240d12 fix: wait for trailing show after leave-full-screen before hiding to tray
The previous fix (dropping the show cancellation listener) still left
close-to-tray on a fullscreen mac window with a window-pops-back bug.
Reproduced with main-process logging on macOS 26:

  T+0ms   handleWindowClose + setFullScreen(false) + pending armed
  T+56ms  win.hide (internal, from setFullScreen false)
  T+106ms our polling hid the window (isFullScreen() returned false)
  T+591ms leave-full-screen arrives (animation actually done)
  T+603ms win.show (macOS trailing event, finalizing space transition)

Two realisations:
 1. isFullScreen() flips to false BEFORE the animation is visually
    complete. Polling it and calling win.hide() at that moment caused
    the pop-back (macOS undoes the hide when the animation finishes).
 2. Even without (1), macOS emits a trailing `show` event ~12ms after
    leave-full-screen. Any prior hide gets reversed by that show.

New strategy in hideWindowRespectingMacFullscreen:

  - Do not hide from the polling timer; use polling only as a watchdog
    that gives up after 5s without leave-full-screen (forces the leave
    path anyway so at least the tray-hide is attempted).
  - On leave-full-screen, arm a `once("show")` listener plus a 300ms
    fallback timer. Whichever fires first runs the hide. This way the
    hide lands on top of macOS's trailing show, so the show cannot
    undo it.
  - clearPendingFullscreenHide teardown now covers the new timer and
    the trailing-show listener, so every cancel entry point stays
    correct.

Tests rewritten to match the new state machine (no more poll-based
hide): one for the happy path, one for the trailing-show fallback,
one for the watchdog. All 11 tests pass.
2026-04-14 22:51:21 +08:00
bincxz
880812f48d fix: do not cancel pending close-to-tray hide on window show event
macOS emits a `show` event on the BrowserWindow internally while the
native fullscreen exit animation lands the window back in its home
Space. PR #717's defensive `show` listener in
hideWindowRespectingMacFullscreen treated that as user intent and
cleared the pending hide, so clicking the red close button on a
fullscreen window left it visible on screen instead of going to the
tray.

Remove the `show` listener entirely. The other paths that legitimately
"bring the window back" during the exit animation (openMainWindow,
toggleWindowVisibility, setCloseToTray(false), the tray "Open Main
Window" menu) already call clearPendingFullscreenHide explicitly, so
the listener was only ever catching the internal transition emit.

Also wire app.on("activate") in main.cjs to call
clearPendingFullscreenHide so a dock-click during the exit animation
correctly cancels the pending hide as user intent.

Update the existing regression test to assert the new behavior
(`show` does not cancel; leave-full-screen still does), and add a
new test covering the app-activate path.
2026-04-14 19:04:04 +08:00
陈大猫
445ce92dbc Merge pull request #717 from binaricat/codex/fix-mac-fullscreen-close
[codex] Fix mac fullscreen close-to-tray behavior
2026-04-14 18:00:24 +08:00
bincxz
7f582bb355 tighten fullscreen tray close handling 2026-04-14 17:53:23 +08:00
bincxz
59f9a1443b fix mac fullscreen close-to-tray flow 2026-04-14 17:25:40 +08:00
陈大猫
bcb56d8229 Merge pull request #715 from binaricat/feat/paste-selection-shortcut
feat: add paste-selection terminal command (closes #637)
2026-04-14 16:30:12 +08:00
bincxz
1ca2cd8ec2 feat: add "paste selection" terminal command with bindable shortcut
Adds a new terminal action that pastes the terminal's current selection
at the cursor without going through the system clipboard — the equivalent
of X11 PRIMARY-selection paste. Default shortcut: ⌘ + Shift + X / Ctrl + Shift + X.

Also surfaces the action in the terminal right-click menu, disabled when
there is no selection. Does not change middle-click paste behavior.

Closes #637
2026-04-14 16:22:51 +08:00
陈大猫
717d8b718a Merge pull request #712 from tces1/dev
feat: scope AI draft and session resume state
2026-04-14 15:58:32 +08:00
Eric Chan
363f03a92d fix ai draft scope state updates 2026-04-14 14:57:45 +08:00
Eric Chan
c5d15a14c9 fix: avoid orphaned AI session storage churn
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 12:33:22 +08:00
Eric Chan
75dc3dd72b feat: scope AI draft and session resume state
- persist drafts, panel views, and active sessions per terminal/workspace scope
- restore scoped AI session selection on reconnect and cold mount
- prefer unsent drafts over implicit history fallback
- avoid redundant active session map rewrites during scoped cleanup

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 11:55:34 +08:00
陈大猫
110e050d20 Merge pull request #708 from binaricat/feat/claude-agent-dynamic-model-probe
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: dynamically probe claude-agent-acp for available models
2026-04-13 19:55:13 +08:00
bincxz
ebcfe49ed6 fix: clear stale model cache when ACP probe returns empty
Address Codex review feedback on #708: the previous guard silently
returned on an empty-but-ok probe response, which left any previously
cached runtimeAgentModelPresets[currentAgentId] in place. That kept
Claude/Copilot pickers showing stale model IDs (and skipped currentModelId
reconciliation) instead of falling back to the hardcoded presets when the
backend no longer advertised a catalog.

Now we explicitly drop the cache entry so the agentModelPresets memo falls
through to getAgentModelPresets(...) via the `?? ` branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:46:39 +08:00
bincxz
bc8ac08b9a feat: probe claude-agent-acp for available models instead of hardcoded presets
Claude agents now advertise their real model catalog via the ACP
initSession response, just like Copilot already does. Confirmed locally
that `claude-agent-acp` returns `models.availableModels` with full ids +
names + descriptions (default / sonnet / haiku on subscription; and would
return Bedrock/Vertex/custom-proxy ids when the user has configured those).

This closes the gap where the Claude picker was stuck on three hardcoded
entries from CLAUDE_MODEL_PRESETS regardless of what the underlying CLI
actually supports. If the probe fails or returns an empty list, we keep
the hardcoded presets as a fallback.

Codex keeps its existing path via `aiCodexGetIntegration` (reads
~/.codex/config.toml) — we deliberately do not probe codex-acp, since
probing would just return the stock OpenAI model list even when the
user has a custom model_provider set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:37:19 +08:00
陈大猫
309fbdbe7a Merge pull request #707 from binaricat/fix/claude-agent-independent-from-custom-provider
fix: decouple Claude agent auth from netcatty provider list
2026-04-13 19:28:24 +08:00
bincxz
11f831d820 fix: decouple Claude agent auth from netcatty provider list
Apply the same fix as #706 to the Claude Code agent. The `claude` CLI has
its own auth surface (`claude auth login/logout/status`) that manages
subscription-based logins (Claude Max / Pro via claude.ai) alongside
ANTHROPIC_API_KEY / settings-based configs. Silently forwarding a
netcatty-configured provider's API key to claude-agent-acp overrides that
login — the user's subscription gets bypassed and charges go to their API
balance without their knowledge.

Claude's settings card never surfaced the `claude auth status` so this
regression was more hidden than the Codex one, but the underlying coupling
is the same class of bug.

Changes:
- Stop forwarding any providerId for managed ACP agents from the renderer;
  claude-agent-acp now resolves auth purely from its own CLI config / login
  state / shell env.
- Remove ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL injection at all three
  codex-acp / claude-acp spawn sites in aiBridge.
- Drop Claude from the authFingerprint computation (it no longer has any
  netcatty-side input to hash).
- Delete the now-unused `findManagedAgentProvider` helper and its
  ProviderConfig import from managedAgents.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:22:58 +08:00
陈大猫
806fb6cf29 Merge pull request #706 from binaricat/fix/issue-705-codex-independent-from-custom-provider
fix: decouple Codex agent auth from netcatty provider list (#705)
2026-04-13 19:14:08 +08:00
bincxz
cc2702b825 fix: decouple Codex agent auth from netcatty provider list (#705)
Codex agent auth must be determined entirely by ~/.codex/auth.json or
~/.codex/config.toml. Before this change, if the user configured any
OpenAI-compatible API provider in netcatty settings (for Catty agent use),
useAIChatStreaming would silently hand that provider's apiKey to the Codex
agent too, causing aiBridge to spawn codex-acp with authMethodId
"codex-api-key" and completely override the user's ChatGPT login.

The regression was introduced in PR #702 (v1.0.89) when findManagedAgent
Provider started matching generic "custom" providers for Codex. Users who
logged into Codex via ChatGPT and also had a netcatty-configured custom
provider saw the UI flip to "API mode" on refresh and their ChatGPT
session get ignored.

Remove the codex branch from the agentProviderId resolver and from
findManagedAgentProvider itself. Also drop the now-meaningless
hasCompatibleProvider hint on the Codex settings card and its i18n copy.
Claude agent behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:08:18 +08:00
陈大猫
af2589e60b Merge pull request #704 from tces1/MoreSkills
feat: add Netcatty user skills scanning and chat selection flow
2026-04-13 13:33:12 +08:00
Eric Chan
971c8a4d8b fix: harden user skills prompt injection 2026-04-13 12:49:53 +08:00
Eric Chan
59364e0c75 fix: preserve user skill selections on refresh errors 2026-04-13 12:39:33 +08:00
Eric Chan
ac83c4c27d fix: keep user skills state in sync 2026-04-13 11:15:32 +08:00
Eric Chan
aa10f962ea fix: harden user skills scanning 2026-04-13 11:08:09 +08:00
Eric Chan
1f3e531d7b Fix AI skill selection handling 2026-04-13 11:03:43 +08:00
陈大猫
ca6ca3f477 Merge pull request #702 from binaricat/codex/issue-677-codex-provider-followup
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
[codex] finish Codex provider follow-up for #677
2026-04-13 02:34:25 +08:00
bincxz
1c9c4fcec3 fix: address second-round review feedback
- Extract fail-loud check to shared getCodexCustomConfigPreflightError so
  the list-models handler (aiBridge.cjs:2149) enforces the same up-front
  error as the stream handler. Previously a user whose config.toml
  env_key was unexported would get the targeted message on chat send but
  a generic "Missing env var" from model-list probes (once the probe was
  rewired for Codex in a future change).

- Wire Settings "Refresh Status" to also invalidate the shell-env cache.
  New invalidateShellEnvCache() helper in shellUtils; aiCodexGetIntegration
  now accepts an optional { refreshShellEnv } flag; the button passes it
  so a user who just exported OPENROUTER_API_KEY in their rc file can
  click Refresh instead of having to restart netcatty.

- Declare authHash in CodexCustomProviderConfig (types.ts + global.d.ts)
  so renderer TS actually sees the field instead of needing a cast.

- DRY the 360 magic number in ChatInput: extract
  MODEL_PICKER_MAX_WIDTH, use it in both the className max-width and the
  left-clamp math so the two can't drift.

- Move codexCustomConfigResolved useState declaration next to its
  companion codexConfigModel, above the effect that invokes its setter,
  and drop the duplicate declaration further down. Pure code-organization
  cleanup but removes a use-before-declaration nit.

No functional changes beyond the fail-loud parity and the refresh-shell-env
path. ACP behavior when authMethodId is omitted still requires a
real-world OpenRouter config.toml validation, which the user is running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:30:29 +08:00
bincxz
8f68e24057 fix: address review feedback on config.toml detection flow
Round of fixes driven by two parallel reviewers:

- i18n placeholder mismatch (P0). Locale strings used ${envKey} (literal
  dollar-sign) but the replace call passed '{envKey}', so the warning
  displayed a raw "${envKey}" instead of the real env var name. Align on
  the codebase-standard {envKey} form.

- Fingerprint now folds the hash of the actual auth material (P1).
  readCodexCustomProviderConfig computes a sha256 over the hardcoded
  api_key or the resolved env_key value and returns authHash. The ACP
  provider-reuse fingerprint includes it, so rotating the key in
  ~/.zshrc + restarting netcatty (which refreshes shellEnv) now
  invalidates the cached provider instance instead of keeping the stale
  key alive. Raw value never crosses the IPC boundary — we only send
  the hex digest.

- Fail loud when config.toml's env_key isn't exported (P1). Previously
  we'd sail into spawn and let codex-acp fail mid-request with a cryptic
  "Missing environment variable". Now the stream handler rejects up
  front with a targeted error naming the missing variable and pointing
  at ~/.zshrc.

- TOML parser: basic-string escape tracking (P1). findUnquotedHash now
  tracks an explicit `escaped` flag (and only honors escapes inside
  double-quoted strings, since literal single-quoted strings don't), so
  values like "C:\\path\\" close correctly instead of consuming the
  trailing `#` as part of the string.

- TOML parser: strip UTF-8 BOM (P2). Windows editors frequently prepend
  one and the first-key regex would silently fail to match, dropping
  everything before the first section header.

- Picker correctness when config.toml lacks a `model` field (P1).
  Instead of silently falling back to CODEX_MODEL_PRESETS (stock
  OpenAI IDs the user's custom endpoint can't serve), show an empty
  list so the picker disables. Track codexCustomConfigResolved so we
  distinguish "still loading" from "not a custom-config session" and
  only clear the preset list once the integration probe confirmed
  connected_custom_config.

- Logout handler isConnected also considers connected_custom_config
  (P2 consistency), matching get-integration.

- Model picker popover clamps its left position so max-w-[360px] can't
  push it past the right edge of a narrow AI side panel (P2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:21:36 +08:00
bincxz
2374f67ffc fix: skip ChatGPT auth validation when config.toml provides custom provider
On stream start, aiBridge ran validateCodexChatGptAuth() for any Codex
request without a netcatty-managed API key. That helper spawns a fresh
codex-acp with authMethodId:"chatgpt" and expects the ChatGPT auth.json
to be valid — which it never is for users who only have a custom
model_provider set up in ~/.codex/config.toml. The validation failed,
the main window got "Codex ChatGPT login is stale or invalid. Reconnect
Codex in Settings" over the error channel, and the UI flipped to the
login prompt — exactly the flow the config.toml path is meant to skip.

Move readCodexCustomProviderConfig up so we compute it before the
validation gate, and only run the ChatGPT validation when there's
neither a netcatty-managed API key nor a detected config.toml custom
provider. The rest of the spawn path already omits authMethodId for
the custom-config case, so codex-acp connects directly with the shell
env and config.toml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:13:21 +08:00
bincxz
fea8e8b305 fix: stop probing codex-acp for models; show config.toml model when custom
Two issues the user flagged with the previous round:

1. Probing codex-acp for available models returned the stock ChatGPT
   catalog (GPT 5.4, Codex 5.x, o3, o4-mini) regardless of the active
   provider. For a user with a custom model_provider in
   ~/.codex/config.toml (OpenRouter + Qwen), those IDs are meaningless
   on their endpoint. Roll back the managed-Codex probe hook and go
   back to static CODEX_MODEL_PRESETS for the stock / ChatGPT path.

2. The fixed w-[300px] popover left empty space on the right whenever
   the longest row was narrower than 300px.

Instead of the probe, teach readCodexCustomProviderConfig to also
return the top-level `model` from config.toml and expose it on the
integration response. In AIChatSidePanel, call aiCodexGetIntegration
when Codex is the active agent and, if customConfig.model is present,
override agentModelPresets with a single-entry list pinned to that
model. Otherwise fall back to the static presets as before — so
ChatGPT users see GPT 5.x / Codex 5.x etc. exactly like before, while
custom-config users see just the model their provider is actually
pinned to.

Popover switches from fixed width to `w-max min-w-[160px] max-w-[360px]`
so it hugs content (great for short single-model lists) while still
capping very long rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:10:35 +08:00
bincxz
79a7e460be fix: parse model ids that contain '/' correctly in ChatInput
The picker label was being derived by splitting selectedModelId on the
first '/'. That works for Codex's ChatGPT-preset format
("gpt-5.4/high" → model "gpt-5.4" + thinking level "high"), but breaks
for OpenRouter-style ids from config.toml ("qwen/qwen3.6-plus"):
selectedBaseModelId became "qwen", which doesn't match any preset, so
selectedPreset fell back to undefined and the chip displayed the
unrelated app-level modelName (e.g. "gemini-3-flash-preview") instead
of the actually selected Codex model.

Replace the naive split with a two-step lookup: first try a direct id
match; only if that fails, look for a preset whose declared
thinkingLevels make "${preset.id}/${level}" equal to selectedModelId,
and derive the thinking segment from that. Model ids that happen to
contain '/' now round-trip correctly through the picker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:07:05 +08:00
bincxz
f48db8ee4e fix: drop description from model picker to keep it compact
codex-acp's provider descriptions can be paragraphs ("Latest frontier
model with improvements across a wide range of capabilities..."), which
made each row of the picker feel bloated. The model id and (thinking
sub-menu's) thinking level already convey the relevant distinction —
drop the description render entirely. Keeps the dropdown tight regardless
of how verbose the upstream model catalog is.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:03:57 +08:00
bincxz
ba2a0389fa fix: stack model picker description below name (vertical layout)
Horizontal layout + truncate clipped too much of codex-acp's longer
descriptions ("Latest frontier model with improvements across a..." →
"Latest frontier model w..."). Reorganize each option as
checkmark | name-on-top, wrapped description below | chevron, so the
full description is readable across two lines without pushing the
popover width out. Fix popover to w-[300px] for a consistent column
width. Checkmark and chevron anchor to the first text line (self-start
with small top offset) so they stay visually aligned with the name
when the description wraps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:03:24 +08:00
bincxz
6309a49c37 fix: cap model picker width and truncate long descriptions
With dynamic models now pulled from codex-acp, preset descriptions can be
arbitrarily long ("Latest frontier model with improvements across a..."
from OpenAI's public model list). The popover had whitespace-nowrap on
each option and no max-w on the container, so long descriptions pushed
the dropdown off-screen.

Cap the popover at max-w-[360px], add min-w-0 + truncate to the name
span so flex children can actually shrink, and cap the description span
at max-w-[160px] with truncate so it ellipses rather than expanding the
row. ChevronRight gets shrink-0 so it can't be pushed out of view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:02:02 +08:00
bincxz
b1291d3ee2 fix: probe codex-acp for available models instead of using hardcoded preset
AIChatSidePanel gates dynamic model probing behind isCopilotExternalAgent,
so Codex always fell back to CODEX_MODEL_PRESETS — a hardcoded list of
OpenAI-specific IDs (GPT 5.4, Codex 5.x, o3, o4-mini). That's only correct
for the stock ChatGPT/OpenAI path. When the user has a custom
model_provider in ~/.codex/config.toml (OpenRouter, local inference, etc.),
none of those IDs exist on their endpoint and the model picker is useless.

Extend the condition to also trigger the aiAcpListModels probe for the
Codex managed agent (detected via matchesManagedAgentConfig). The probe
launches codex-acp the same way a real session does, so it now also goes
through getCodexAuthOverride and respects the user's config.toml — and
whatever availableModels codex-acp returns (typically at least the
`model` field from config.toml) shows up in the picker. Claude keeps its
curated presets to avoid regressing that path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:00:04 +08:00
bincxz
18c001e9c5 fix: show custom config even when env_key is not exported yet
The first pass required both a custom model_provider in ~/.codex/config.toml
AND the referenced env_key to already be present in the shell environment.
If a user had the config file set up but hadn't (yet) exported the key in
their shell, detection returned null and the UI fell back to "Not
connected" + "Connect ChatGPT" — which is the exact flow they were trying
to avoid.

The config.toml is a strong enough signal of intent on its own. Keep the
integration in the connected_custom_config state regardless of env_key
availability, but expose envKeyPresent on the response so the UI can
explicitly warn "Warning: $MY_KEY is not set in your shell — export it".
Status label and color also flip to amber ("Custom config detected — env
var missing") so the state is easy to spot without dropping back to the
login prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:53:33 +08:00
bincxz
c2c6b265d4 feat: detect user's ~/.codex/config.toml custom provider as ready state
Users who hand-configure ~/.codex/config.toml with a custom model_provider
and matching [model_providers.<name>] entry are fully functional from the
Codex CLI, but netcatty only looked at codex login status — which reports
on ~/.codex/auth.json alone — and would therefore push them into the
ChatGPT login flow even though the CLI works for them.

Add a minimal TOML parser for the narrow subset we need (top-level keys
plus [model_providers.<name>] string tables), and readCodexCustomProvider
Config() to detect a usable custom-provider setup: an active model_provider
that isn't the built-in openai preset, pointing at a provider entry whose
env_key is set in the shell env (or api_key is hardcoded).

Surface this as a new integration state "connected_custom_config", add a
customConfig summary on the IPC response, and tweak the Codex settings
card so it shows the custom-provider name, hides the Connect ChatGPT
button, and drops the stale "OpenAI-compatible provider" hint when this
path is active.

At Codex-ACP spawn time, introduce getCodexAuthOverride() so we only pass
authMethodId: "chatgpt" when we truly have no other option. When a
netcatty-managed API key is present we still use "codex-api-key"; when the
user has a custom config we omit authMethodId entirely so codex-acp
resolves auth from the shell env / config.toml itself. Fold the detected
custom config (provider name, base url, env key presence) into the
provider reuse fingerprint so edits to config.toml invalidate cached ACP
instances.

Fixes the Codex half of #677 for users who skip Settings → AI providers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:49:23 +08:00
bincxz
1e50b66407 fix: finish Codex provider follow-up for #677 2026-04-13 01:21:05 +08:00
陈大猫
2fb2155d79 Merge pull request #701 from binaricat/feat/issue-695-preserve-buffer-on-reconnect
feat: preserve terminal buffer across reconnect (#695)
2026-04-13 01:12:01 +08:00
bincxz
3429c498f9 fix: cancel pending retry when session is closed or cancelled
Per Codex P1 on #701: the nested term.write callbacks in handleRetry
kept a captured reference to startNewSession. If the user hit Cancel or
closed the tab while those writes were still queued, cleanupSession ran
first but the callback could still fire afterwards — opening a backend
session with no owning UI (a ghost connection that nothing would tear
down).

Introduce retryTokenRef. handleRetry stamps a fresh Symbol, captures it,
and the chained callbacks verify the token (plus termRef identity) is
still current before proceeding. Invalidate the token from every path
that ends the retry intent: handleCancelConnect, handleCloseDisconnected
Session, teardown. A subsequent handleRetry naturally invalidates the
prior one by overwriting the ref, so rapid double-clicks are also safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:06:08 +08:00
bincxz
dc7b14e323 fix: delay new session start until reset sequence has flushed
Per Codex P1 on #701: term.write is asynchronous, but handleRetry was
calling sessionStarters.start* synchronously right after scheduling
the soft-reset write. On fast reconnect paths (local and serial
especially, where the backend has no network round-trip), the new
session's first output bytes can reach xterm before the \x1b[!p...\x1b[H
reset has been applied. That means the reset/home runs mid-stream of
the first prompt, repositioning the cursor or flipping modes partway
through the shell's init and producing intermittent corrupted first
screens.

Extract the protocol dispatch into startNewSession and pass it as the
callback of the second term.write, so the new session only starts
once every preparation byte (alt-screen exit, viewport preserve,
DECSTR, xterm mode disables, cursor home) has actually been applied
to the terminal state. State updates that only drive the UI overlay
(status, progress logs) stay synchronous so users see "connecting..."
immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:48:22 +08:00
bincxz
5d675b9cef fix: exit alt-screen before preserving viewport; use DECSTR for mode reset
Addresses two Codex findings on #701:

P1 (alt-screen ordering) — preserveTerminalViewportInScrollback only
operates on the normal buffer. If the user disconnected while inside
vim/less/top, the alt buffer was active, preserve was a no-op, and
when \x1b[?1049l later switched back to normal, the new session wrote
over still-visible pre-disconnect content instead of a cleared
viewport. Send \x1b[?1049l first, then wait for the write to flush
(via xterm's write callback) before calling preserve, so it always
runs on the normal buffer.

P2 (DECCKM / keypad / other VT220 modes) — the previous reset sequence
only disabled xterm extensions (mouse tracking, bracketed paste) and
touched SGR / cursor visibility. Full-screen apps commonly enable
DECCKM (application cursor keys) and keypad application mode; those
would leak into the new session and break arrow-key history
navigation and numeric keypad input. Use DECSTR (\x1b[!p) — soft
terminal reset — to reset DECCKM, keypad mode, SGR, insert/replace,
origin mode, and cursor visibility in one shot without clearing the
buffer. Keep explicit disables for the xterm-specific modes DECSTR
doesn't cover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:42:36 +08:00
bincxz
bf9f0e1fc2 fix: reset bracketed-paste mode on reconnect
Per Codex P2 on #701: handleRetry previously removed term.reset() but
the replacement escape sequence didn't disable bracketed paste (DECSET
2004). If the disconnected session had turned it on, term.modes
.bracketedPasteMode stayed true into the next connection; the paste
and snippet paths in createXTermRuntime keep wrapping input with
\x1b[200~ ... \x1b[201~ markers. When the new session hasn't itself
enabled bracketed paste, the shell echoes those markers as literal
text and mangles pastes.

Add \x1b[?2004l to the retry reset sequence so bracketed-paste state
starts off for the new session; the new shell's init will re-enable
it normally if it wants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:34:55 +08:00
bincxz
02967d9258 fix: do not clear terminal buffer at the top of session starters
Each session starter (startSSH / startTelnet / startMosh / startLocal)
called term.clear() as its first step. In xterm.js, clear() wipes the
entire buffer including scrollback. On initial connect this is harmless
(the buffer is already empty), but on retry it undoes the viewport
preservation that handleRetry just performed — so #695 remained broken
for any protocol that went through these starters (i.e. all of them).

The clear call served no purpose: xterm mounts with an empty buffer and
nothing writes to it before the starter runs. Remove the four
try/catch(term.clear()) blocks so handleRetry's
preserveTerminalViewportInScrollback actually sticks across reconnect
on SSH reboots, telnet drops, mosh/local respawns, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:33:16 +08:00
bincxz
343176120e feat: preserve terminal buffer across reconnect (#695)
On disconnect + retry, handleRetry previously called term.reset(), which
wipes both the visible screen and the scrollback history — so users lost
every bit of context from the previous session the moment they hit
"Start Over".

Push the current viewport into scrollback via the existing
preserveTerminalViewportInScrollback utility, then explicitly disable
the modes we actually care about not leaking across sessions (mouse
tracking 1000/1002/1003/1006, alt-screen 1049, SGR attributes, hidden
cursor) and home the cursor. This keeps the full scrollback intact so
users can scroll up to read everything from before the disconnect,
while still preventing stale escape-sequence state from bleeding into
the new session.

Fixes #695

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:25:56 +08:00
陈大猫
c0b4dace87 Merge pull request #700 from binaricat/feat/issue-690-sftp-tab-toggle
feat: add setting to hide the standalone SFTP top tab (#690)
2026-04-13 00:21:20 +08:00
bincxz
b6e8d63fef fix: remove SFTP from QuickSwitcher when SFTP tab is hidden
Per Codex P2 review on #700: QuickSwitcher always listed an 'sftp' tab
item, but with showSftpTab off the App-level redirect bounces the user
straight back to Vault. That left a dead entry in quick-switch — selecting
it appeared broken.

Thread showSftpTab through QuickSwitcher and skip the SFTP item in both
the flat item list (used for keyboard selection indexing) and the
rendered built-in Tabs row when the top tab is hidden. Keeps every
SFTP navigation surface consistent with the visibility setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:13:31 +08:00
bincxz
60c07da140 fix: exclude hidden SFTP tab from keyboard tab cycling
Per Codex P1 review on #700: when showSftpTab is off, executeHotkeyAction
still built allTabs as ['vault', 'sftp', ...orderedTabs]. nextTab from
Vault would land on hidden 'sftp', the showSftpTab effect then redirected
back to 'vault', trapping tab cycling so Ctrl/Cmd+Tab could not advance
into terminal tabs. Number shortcuts (Ctrl+1..9) were also shifted, e.g.
tab 2 resolved to hidden SFTP and ping-ponged back to Vault.

Build allTabs conditionally so 'sftp' is only in the cycle when the tab
is visible. This keeps nextTab/prevTab/switchToTab consistent with what
the user sees in the top tab bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:05:36 +08:00
bincxz
f89afc0e05 feat: add setting to hide the standalone SFTP top tab
Adds a "Show SFTP tab" toggle in Settings → Appearance (under the
Vault section) that controls visibility of the standalone SFTP view
in the top tab bar. When disabled:

- The SFTP tab is removed from the top tab strip.
- The openSftp hotkey (Ctrl+Shift+O / ⌘⇧O) becomes a no-op.
- If the user is currently on the SFTP tab, the active tab auto-
  switches to Vaults.

The in-session SFTP side panel (opened from the terminal toolbar) is
unaffected — that is the surface users keep when they hide the
top-level tab.

Setting persists via localStorage, syncs across windows, and is
included in the cloud SyncPayload alongside the existing Vault
visibility toggles (showRecentHosts,
showOnlyUngroupedHostsInRoot). Default: on.

Addresses the first ask in #690.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:57:15 +08:00
陈大猫
ca0b1ed9ae Merge pull request #699 from binaricat/fix/issue-694-ctrl-f-hardcoded
fix: remove hardcoded Ctrl+F handler bypassing configurable shortcuts
2026-04-12 23:46:09 +08:00
bincxz
555438a02a fix: set Ctrl+F as the default PC shortcut for terminal search
Previously the documented default was Ctrl+Shift+F on PC, but a
hardcoded handler always captured plain Ctrl+F regardless of the
configured binding — so the effective default users experienced was
Ctrl+F. Now that the hardcoded handler is removed, align the declared
default with that historical behavior so existing users don't lose the
shortcut they were used to. Users who need plain Ctrl+F for the shell
(e.g. zsh forward-char) can remap or disable it in Settings → Shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:44:05 +08:00
bincxz
97e78624bb fix: remove hardcoded Ctrl+F handler that bypassed configurable shortcuts
The xterm custom key event handler intercepted plain Ctrl+F / Cmd+F to
open terminal search, ignoring the user's configured keybinding scheme.
This conflicted with zsh's forward-char (Ctrl+F) and gave users no way
to disable it via the Shortcuts settings tab.

The configurable keybinding system below already routes the
searchTerminal action via checkAppShortcut, with defaults of
Ctrl+Shift+F (PC) and Cmd+F (Mac). Dropping the hardcoded branch
lets the user's settings take effect. Also remove the stale
"(Ctrl+F)" label from the toolbar tooltip since the shortcut is
configurable and the default on PC is Ctrl+Shift+F.

Fixes #694

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:42:27 +08:00
陈大猫
eab1e8db67 Merge pull request #698 from binaricat/codex/issue-638-root-ungrouped-hosts
[codex] Add vault root ungrouped host filter toggle
2026-04-12 23:36:47 +08:00
bincxz
8e6392e503 persist vault root filter toggles immediately 2026-04-12 23:30:02 +08:00
bincxz
8b99f2411f fix vault root host filter sync and empty states 2026-04-12 23:27:36 +08:00
bincxz
98905b9c81 fix vault hosts section initialization order 2026-04-12 23:14:59 +08:00
bincxz
b7e1df9916 hide empty root hosts section 2026-04-12 23:13:44 +08:00
bincxz
3089cab88d add vault root ungrouped host toggle 2026-04-12 23:09:03 +08:00
Eric Chan
50b20eaa05 chore: triple-pass review and hardening of AI Skills logic 2026-04-12 17:25:45 +08:00
Eric Chan
3ab42bf588 chore: final hardening of User Skills logic and async IO 2026-04-12 17:14:49 +08:00
Eric Chan
84423a0096 fix: resolve TypeScript errors and optimize User Skills with async IO 2026-04-12 17:11:50 +08:00
陈大猫
98dda8a51b Merge pull request #693 from binaricat/fix/claude-acp-custom-model-provider
Some checks failed
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: Claude ACP agent now uses custom API key and base URL
2026-04-12 00:51:25 +08:00
bincxz
42baa5cb78 fix: include provider base URL in ACP reuse fingerprint for Claude
The ACP provider reuse gate only computed authFingerprint for Codex,
leaving it null for Claude. Changing the configured provider or base
URL mid-session would keep reusing the stale provider instance.

Now Claude computes an authFingerprint from apiKey + baseURL, so
changing either value invalidates the cached provider and forces
recreation with the new credentials/endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:37:34 +08:00
bincxz
11fd7fcd71 fix: prefer anthropic provider over generic custom for Claude ACP
A generic custom provider (OpenAI-compatible) could be selected for
Claude, passing wrong credentials. Now we prefer an explicit anthropic
provider and only fall back to a custom provider when it has a baseURL
configured (indicating intentional Anthropic-compatible gateway use).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:31:01 +08:00
bincxz
d6950948fa fix: also inject OPENAI_BASE_URL for Codex ACP agent
Codex reads OPENAI_BASE_URL to connect to custom API endpoints.
Without this, users with a custom baseURL on their OpenAI provider
config would still hit the default api.openai.com endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:29:14 +08:00
bincxz
9693793bba fix: allow Claude ACP agent to use custom API key and base URL
The renderer only resolved OpenAI providers (for Codex) when passing
provider IDs to the main process. Claude agent was never matched, so
no API key was injected. Additionally, the main process only injected
CODEX_API_KEY — never ANTHROPIC_API_KEY or ANTHROPIC_BASE_URL.

Changes:
- Renderer now resolves anthropic/custom provider for Claude agent,
  openai provider for Codex agent (via matchesManagedAgentConfig)
- Main process injects ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL into
  claude-agent-acp env when a provider is configured, across all three
  ACP provider creation paths (list-models, stream, fallback)

This enables users who configure an Anthropic provider with a custom
base URL (e.g. CC Switch proxy) to use Claude Code without being
redirected to the official OAuth flow.

Closes #677

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:26:24 +08:00
陈大猫
a72f012851 Merge pull request #692 from binaricat/fix/scrollback-zero-wheel-scroll
fix: mouse wheel scrolling broken when scrollback set to 0
2026-04-12 00:04:44 +08:00
bincxz
1368709f4e fix: map scrollback=0 to large value so mouse wheel scrolling works
xterm.js treats scrollback=0 as "no scrollback buffer", which makes
hasScrollback return false and converts wheel events into arrow-key
sequences. The UI uses 0 to mean "no limit", so map it to 999999
before passing to xterm.js.

Closes #689

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:00:18 +08:00
陈大猫
d1408b8050 Merge pull request #688 from binaricat/feat/ui-matched-terminal-themes
Some checks failed
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: add Follow Application Theme for terminal + 14 UI-matched themes
2026-04-10 22:01:59 +08:00
bincxz
9ca68561b3 fix: clean up stale inherited theme state 2026-04-10 21:49:01 +08:00
bincxz
c3c579b8a0 fix: close remaining theme sync gaps 2026-04-10 21:43:15 +08:00
bincxz
2784ecdf28 fix: sync inherited themes in editors 2026-04-10 21:30:25 +08:00
bincxz
75bbd1f300 fix: preserve theme inheritance and modal rollback 2026-04-10 21:21:51 +08:00
bincxz
4ee4ef7b60 fix: polish follow-app terminal theme UX 2026-04-10 21:03:14 +08:00
Eric Chan
58bc08a045 Add user skills injection and picker UI 2026-04-10 20:53:39 +08:00
bincxz
32f4aadab2 fix: follow-app-theme now overrides per-host theme settings
When followAppTerminalTheme is on, all terminals should use the
UI-matched theme — but three resolution points were still checking
per-host overrides:

1. App.tsx resolveTheme() in the activeTerminalTheme computation
2. Terminal.tsx effectiveTheme computation
3. TerminalLayer.tsx focusedThemeId computation

Added followAppTerminalTheme prop flowing from App → TerminalLayer
→ Terminal. When the flag is true, per-host theme resolution is
bypassed so all terminals consistently match the app chrome.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:25:21 +08:00
bincxz
fc32b44d8e fix: replace missing ToggleRow with SettingRow + Toggle
ToggleRow is a locally-defined component in HostDetailsPanel and
GroupDetailsPanel — it is NOT exported or available in the terminal
settings tab. Using it caused a white-screen crash. Replaced with
the existing SettingRow + Toggle pattern that's already used
throughout the terminal settings tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:19:54 +08:00
bincxz
76cd1f2883 fix: remove unused variables flagged by eslint
- App.tsx: remove unused followAppTerminalTheme/setFollowAppTerminalTheme
  from destructuring (they flow through settings object, not App props)
- createTerminalSessionStarters.ts: remove dead usedKey/usedPassword
  assignments left over from PR #680 which changed runDistroDetection
  to use the existing session's connection instead of auth credentials

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:17:15 +08:00
bincxz
76d37d982a fix: upgrade-safe default + cross-window broadcast for follow-theme
P1: Follow mode defaulted ON when the storage key was missing, which
is true for ALL existing users after upgrade (not just fresh
installs). Now checks whether a terminal theme was already stored —
if so, this is an upgrade and we default OFF to preserve the user's
manual choice. Only genuinely fresh installs (no terminal theme in
storage) default to ON.

P2: The follow-theme persist effect now calls notifySettingsChanged
and a matching branch in the cross-window storage event handler
syncs the toggle state across windows, matching the pattern used by
all other terminal settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:14:05 +08:00
bincxz
6d2f3f28c0 feat: add "Follow Application Theme" for terminal + 14 UI-matched terminal themes (#675)
When enabled (default for new users), the terminal theme automatically
switches to match the active app UI theme — so the terminal background
blends seamlessly with the app chrome, regardless of which UI theme
preset the user picks (Snow, Midnight, Forest, etc.).

## New terminal themes (14)

Each built-in UI theme preset now has a corresponding terminal theme
with an exactly matching background color:

Light: ui-snow, ui-pure-white, ui-ivory, ui-mist, ui-mint, ui-sand,
ui-lavender — ANSI palette based on netcatty-light with per-theme
cursor colors that complement the UI accent.

Dark: ui-pure-black, ui-midnight, ui-deep-blue, ui-vscode,
ui-graphite, ui-obsidian, ui-forest — ANSI palette based on
netcatty-dark with accent-matched cursors and selections.

## "Follow Application Theme" setting

- New toggle in Settings → Terminal → Theme section
- Default ON for new users, persisted in localStorage
- When ON: terminal theme auto-derived from the active UI theme via
  a mapping table in domain/terminalAppearance.ts
- When OFF: manual theme selector shown (existing behavior)
- Switching the app between light/dark (or changing the UI theme
  preset) instantly updates the terminal theme

## Files changed (9)

- terminalThemes.ts: +14 theme definitions
- terminalAppearance.ts: UI→terminal mapping table +
  getTerminalThemeForUiTheme()
- useSettingsState.ts: followAppTerminalTheme state + persist +
  currentTerminalTheme derivation
- storageKeys.ts: new storage key
- SettingsTerminalTab.tsx: toggle UI + conditional theme selector
- SettingsPage.tsx: pass new props
- App.tsx: destructure new state
- en.ts + zh-CN.ts: 2 new i18n keys

Closes #675

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:06:13 +08:00
陈大猫
a1c9f5fbd0 fix: normalize CRLF to LF when saving text files via SFTP (#681) (#687)
On Windows, the built-in text editor produces CRLF line endings.
When saved to a Linux host via SFTP, the \r characters break shell
scripts ("command not found", syntax errors) because Linux treats
\r as part of the command.

Normalize \r\n → \n in writeSftp() before writing. LF is universally
supported — even Windows 10+ notepad handles LF-only files — so this
is safe for all target platforms.

Closes #681

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:51:01 +08:00
陈大猫
ce5cb2afec feat: add Windows portable build target (#668) (#686)
Add a `portable` target alongside the existing `nsis` installer for
Windows builds. The portable version produces a single .exe that
runs without installation — just download and double-click.

The artifact is named with a `-portable-` infix to distinguish it
from the installer in the release assets.

Closes #668

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:49:32 +08:00
Eric Chan
c771979178 Add Skills + CLI mode for external agents (#599)
* Add Skills + CLI external agent workflow

* feat: add Skills + CLI transport for ACP agents

* chore: remove branch-local compatibility shims
2026-04-10 18:41:53 +08:00
陈大猫
58c651500e feat: add Gist revision history UI for vault restore (#685)
* feat: add Gist revision history UI for vault restore (#679)

Adds a "History" button on the GitHub Gist provider card in
Settings → Sync & Cloud. Clicking it opens a modal that lists all
Gist revisions (newest first) and lets the user preview and restore
any historical version with one click.

## How it works

1. The GitHub API already returns a `history` array when fetching a
   Gist (`GET /gists/{id}`). The existing `getGistHistory()` reads
   this. A new `downloadGistRevision(sha)` function fetches a
   specific revision via `GET /gists/{id}/{sha}`.

2. CloudSyncManager exposes `getGistRevisionHistory()` (metadata
   only, no decryption) and `downloadGistRevision(sha)` (decrypt
   + return payload and preview counts).

3. useCloudSync threads both methods through to the UI.

4. CloudSyncSettings renders a three-state modal:
   - **Loading**: spinner while fetching revision list
   - **Revision list**: clickable rows with SHA prefix + date,
     "Current" badge on the latest
   - **Preview**: after clicking a revision, shows entity counts
     (hosts, keys, snippets, identities) and a "Restore This
     Version" button

5. Decryption uses the current master password. If the revision
   was encrypted with a different password (user changed it since
   then), a clear error message is shown instead of a crash.

## Changes

- `GitHubAdapter.ts`: add `downloadGistRevision()` standalone
  function + `getHistory()` / `downloadRevision()` class methods
- `CloudSyncManager.ts`: add `getGistRevisionHistory()` and
  `downloadGistRevision(sha)` with decrypt + preview
- `useCloudSync.ts`: expose both methods
- `CloudSyncSettings.tsx`: add `extraActions` slot to ProviderCard,
  render "History" button on GitHub card, revision history modal
  with list → preview → restore flow
- `en.ts` + `zh-CN.ts`: 18 new i18n keys for the modal

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

* fix: use getConnectedAdapter and lazy gist discovery for history APIs

P1: CloudSyncManager's history methods accessed this.adapters directly
instead of getConnectedAdapter(), which lazily initializes adapters.
After an app restart the adapter map is empty even though the provider
is persisted as connected, making history fail until another sync
path initializes it.

P2: GitHubAdapter.getHistory() and downloadRevision() bailed early
when gistId was missing, unlike download() which calls findSyncGist()
to lazily discover it. Users whose gist was created after initial
setup would see no revisions.

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

* fix: address round-2 codex review on PR #685

P1: Renamed cloudSync.history.* keys to cloudSync.revisionHistory.*
to avoid duplicate key collision with the existing "Sync History"
section title.

P2: Added getGistRevisionHistory and downloadGistRevision to the
CloudSyncHook type interface so the hook contract matches reality.

P2: Simplified decrypt error handling — any error from the decrypt
path now shows the friendly "cannot decrypt" message rather than
relying on fragile substring matching.

P2: Clear historyRevisions on each handleOpenHistory call so stale
data doesn't linger under error banners on retry.

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

* fix: restore correct i18n key for Sync History section title

The sed rename pass accidentally changed the Sync History panel
heading (line 1290) from cloudSync.history.title to
cloudSync.revisionHistory.title. Restored the original key so the
two sections have distinct titles. Also removed unused err parameter
in the catch block.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:47:16 +08:00
陈大猫
bcf653dd2e fix: prevent empty vault from overwriting cloud data on startup (#683)
* fix: prevent empty vault from overwriting cloud data on startup (#679)

Fixes a data-loss scenario where an empty local vault (caused by an
update, storage corruption, or import failure) silently overwrites
a non-empty cloud vault on startup via auto-sync.

The root cause is a startup timing race: the debounced auto-sync
effect (3s after data change) can fire before checkRemoteVersion
(1s delay + async download) completes its remote pull. When the
local vault is empty, this pushes an empty payload to the Gist,
permanently erasing the user's data.

Four complementary fixes:

A. Empty vault push guard (useAutoSync syncNow):
   Auto-sync refuses to push a payload where hosts, keys, snippets,
   and identities are ALL empty. Manual sync from Settings is still
   allowed for the rare case where the user intentionally emptied
   everything. Prevents the most dangerous path.

B. Skip redundant post-merge push (useAutoSync checkRemoteVersion):
   After applying a three-way merge result from the remote, set
   skipNextSyncRef so the data-change effect does not immediately
   re-upload the same payload. Removes one unnecessary API call per
   startup sync.

C. Gate auto-sync on remote check completion (useAutoSync effect):
   Added remoteCheckDoneRef — the debounced auto-sync effect will
   not fire until checkRemoteVersion has completed (success or
   failure). This closes the timing window entirely: an empty vault
   can no longer race ahead of the remote pull.

D. Empty-vault-vs-cloud confirmation dialog (App.tsx + useAutoSync):
   When checkRemoteVersion detects local is empty but cloud has
   data, it pauses and shows a root-level dialog with two options:
   - "Restore from Cloud" (recommended) — applies the remote payload
   - "Keep Empty" — starts fresh with an empty vault
   The dialog blocks the sync flow via a Promise that resolves when
   the user picks an option. This gives users explicit control over
   a situation that previously happened silently behind their backs.

Also adds en + zh-CN i18n strings for the new dialog and toast
messages.

Closes #679

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

* fix: address codex review on PR #683

P1-1: Unified isPayloadEffectivelyEmpty helper covering all synced
entity arrays (hosts, keys, snippets, identities, customGroups,
snippetPackages, portForwardingRules, knownHosts, groupConfigs).
Replaces the three inline checks in syncNow and checkRemoteVersion
that only covered hosts/keys/snippets/identities.

P1-2: Replaced hand-rolled overlay div with the project's existing
Dialog/DialogContent/DialogHeader/DialogFooter components. This adds
role="dialog", aria-modal, focus trap, and ESC-key dismiss for free.
Used lucide-react AlertTriangle/Download/Trash2 icons instead of
inline SVGs.

P2-1: Guard against double-resolve in resolveEmptyVaultConflict by
nulling the ref immediately on first call.

P2-2: Replaced hardcoded "N hosts, N keys, N snippets" with an i18n
key using interpolation (cloudSummary) so the count text is properly
translated in zh-CN.

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

* fix: address round-2 codex review on PR #683

P1: isPayloadEffectivelyEmpty now also checks the settings object.
A vault with only settings (e.g. custom theme, font size) and zero
hosts/keys/snippets is no longer treated as empty.

P1: Dialog accessibility — use hideCloseButton to remove the non-
functional close button, onEscapeKeyDown + onOpenChange prevent
dismiss (the user MUST choose an option), and wrap the description
in DialogDescription so aria-describedby is properly linked.

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

* fix: use single-brace interpolation syntax for cloudSummary i18n key

The project's i18n system uses single-brace placeholders ({var}),
not double-brace ({{var}}). The double-brace syntax was rendering
as raw text instead of being interpolated.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:20:27 +08:00
陈大猫
0caf19af7e fix: pass legacyAlgorithms to port forwarding SSH connections (#682)
* fix: pass legacyAlgorithms to port forwarding SSH connections (#678)

Port forwarding connections always used modern-only algorithms because
the legacyAlgorithms host setting was never threaded through to the
port forwarding bridge. When the jump server or target host runs an
older SSH implementation (e.g. OpenSSH 7.4) that only supports legacy
key exchange algorithms like diffie-hellman-group14-sha1, the
handshake fails with "Connection lost before handshake".

The SSH terminal path already handles this correctly via
buildAlgorithms(options.legacyAlgorithms) — the port forwarding path
was simply missing the same plumbing.

Changes:
- sshBridge.cjs: export buildAlgorithms so portForwardingBridge can
  reuse it (avoids duplicating the algorithm list)
- portForwardingBridge.cjs: destructure legacyAlgorithms from the
  payload, pass it to connectOpts.algorithms via buildAlgorithms(),
  and thread it through to connectThroughChain for jump host
  connections
- portForwardingService.ts: include host.legacyAlgorithms in the
  startPortForward bridge call

Closes #678

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

* fix: add legacyAlgorithms to PortForwardOptions type contract

Per Codex review: the new legacyAlgorithms field was being passed
in the startPortForward call but was not declared in the
PortForwardOptions interface in global.d.ts, causing a TS2353 type
error in strict type-checking environments.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:31:52 +08:00
陈大猫
e8b9122270 feat: auto-detect network devices from SSH banner and skip stats polling (#680)
* feat: auto-detect network devices from SSH banner and skip stats polling (#674)

Fixes rapid AAA session churn reported on Cisco/HPE/similar network
devices running Netcatty. The root cause was two separate polls that
both open fresh exec channels (each counted as its own AAA session on
many network devices):

- runDistroDetection() opens a brand new SSH connection every time a
  host connects to run `cat /etc/os-release || uname -a`
- useServerStats polls `conn.exec(statsCommand)` every 5 seconds

Both commands fail on non-POSIX CLIs, but the channels still hit AAA.

This change avoids both by reading the SSH server identification
string that ssh2 already captures during the handshake
(`conn._remoteVer`). No extra network round-trips, zero additional
AAA entries.

## Changes

**sshBridge.cjs**
- Store `conn._remoteVer` on the session object at connect time as
  `session.remoteSshVersion`
- New IPC handler `netcatty:ssh:remoteInfo` (`getSessionRemoteInfo`)
  returning the captured SSH server software string

**preload.cjs / global.d.ts / useTerminalBackend.ts**
- Thread `getSessionRemoteInfo(sessionId)` through to the renderer

**domain/host.ts**
- `NETWORK_DEVICE_OPTIONS` constant listing the vendor IDs we can
  recognize (cisco, juniper, huawei, hpe, mikrotik, fortinet,
  paloalto, zyxel)
- `detectVendorFromSshVersion()` — pure function that parses an SSH
  server software string and returns a vendor ID or ''. Pattern set
  is sourced from Nmap nmap-service-probes (authoritative), the
  ssh-audit software.py reference, and vendor docs; see code
  comments for the exact matches used.
- `classifyDistroId()` returns `linux-like | network-device | other`
  so features that require a POSIX shell can gate on the result.

**createTerminalSessionStarters.ts (runDistroDetection)**
- Before running the /etc/os-release probe, call
  `getSessionRemoteInfo` on the already-connected session and feed
  the banner into `detectVendorFromSshVersion`. If the vendor maps
  to a known network device, emit the vendor ID via the existing
  `onOsDetected` callback and skip the shell probe entirely. For
  unknown or generic OpenSSH/Dropbear banners the existing behavior
  is preserved.

**Terminal.tsx**
- `isSupportedOs` now derives from `classifyDistroId(effectiveDistro)`
  combined with `host.deviceType !== 'network'`, so neither explicit
  network-device hosts nor banner-detected ones trigger the stats
  polling loop.

**useServerStats.ts**
- Add a consecutive-failure counter. After 3 consecutive failed
  polls, stop the interval for this session (reset on disconnect /
  sessionId change / settings toggle). This is the fallback for
  hosts the banner classifier cannot identify (Juniper JUNOS,
  Cisco NX-OS, Arista EOS — all present as plain `OpenSSH_*` but
  do not support the POSIX stats pipeline).

**DistroAvatar.tsx / HostDetailsPanel.tsx**
- Add 8 network-device vendor icons (Cisco, Juniper, Huawei, HPE,
  MikroTik, Fortinet, Palo Alto, ZyXEL) alongside the existing
  Linux distro icons, with brand colors. Icons sourced from Simple
  Icons (CC0) where available; HPE and ZyXEL use simple
  abbreviation placeholders.
- Network device vendors are added to the manual distro override
  dropdown so users can pin an icon even if their device has an
  exotic banner we don't auto-detect.

**i18n**
- English + Chinese labels for the new vendor options in the
  Host Details distro selector.

Closes #674

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

* fix: gate network-device detection on raw host.distro, not manual icon override

Per Codex review on PR #680: the stats-polling gate was passing
`host` through getEffectiveHostDistro() before classifying, which
honors the manual distro override (`distroMode: 'manual'` +
`manualDistro`). That meant a user who previously pinned an
"ubuntu" icon on a host that later gets banner-detected as Cisco
would still be classified as linux-like and keep generating the
AAA session flood #674 is meant to eliminate.

Separate display from gating:
- Display (DistroAvatar, host cards): keeps using
  getEffectiveHostDistro so users can cosmetically override the
  icon.
- Gating (useServerStats via Terminal.tsx isSupportedOs): reads
  host.distro directly — the value populated by banner detection —
  alongside the explicit host.deviceType flag. Manual icon choice
  can no longer re-enable polling on a detected network device.

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

* fix: guard distro detection against stale session timers

Per Codex review on PR #680: runDistroDetection is scheduled on a
600ms setTimeout after connection and also makes async calls of its
own. A quick disconnect + reconnect on the same session slot could
fire the old timer against the new session, reading host B's SSH
banner via getSessionRemoteInfo and writing host B's vendor onto
host A's distro field — wrong icon and wrong stats-polling state.

Follow the same pattern already used for the startup-command timer
in this file (scheduledSessionId captured at schedule time, checked
inside the timer). Capture `id` at schedule time, bail out if
ctx.sessionRef.current no longer matches, and re-check after every
async await inside runDistroDetection so that a reconnect during
the banner fetch or the os-release probe also bails cleanly.

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

* fix: address local codex review on PR #680

Addresses three issues found in a local Codex review pass after the
remote reviewer gate was flaky:

## P0 — session tokens instead of sessionId for stale-timer guard

The previous guard captured `id` returned from startSSHSession and
compared against `ctx.sessionRef.current` inside the setTimeout and
the async runDistroDetection. But the renderer passes
`sessionId: ctx.sessionId` into startSSHSession (see
createTerminalSessionStarters.ts:543), meaning a tab reuses the
SAME sessionId across disconnect+reconnect. The comparison
`T1 === T1` always passed, so the guard was a no-op.

Replaced with a module-level Map<sessionId, object> that stores the
live "connection token" for each sessionId slot. Each call to
startSSH mints a fresh `{}` token and overwrites the entry. Timers
and async continuations compare their captured token against the
current map value by reference — a reconnect replaces the map entry
with a new token, so stale callbacks bail cleanly.

## P1 — run os-release probe on the existing SSH connection

The fallback /etc/os-release probe used `execCommand` which creates
a brand-new SSHClient() on every call. On network devices that
present as plain `OpenSSH_*` and fall through to this step
(JUNOS, NX-OS, EOS) it added one extra full-auth AAA session log
entry per connect, in addition to the failing stats polls.

Added `getSessionDistroInfo(sessionId)` as a new IPC handler that
runs the same probe via `session.conn.exec()` — an exec channel on
the already-open connection, no new handshake. Plumbed through
preload.cjs, global.d.ts, and useTerminalBackend.ts.
runDistroDetection uses this instead of execCommand in the fallback
path, also removing the unused auth-credentials argument (we are no
longer opening a new connection, so no credentials are needed).

## P2.1 — don't re-arm timers after giving up

After the consecutive-failure counter trips, useServerStats cleared
the interval but a subsequent effect rerun (visibility change,
settings tweak, etc.) would schedule a fresh `setTimeout` and
`setInterval` that would just call the early-return path forever.

The scheduling block now checks `givenUpRef.current` before arming
either timer. The flag is still cleared on the normal disconnect /
sessionId-change reset path so a reconnect gets a fresh attempt.

## P2.2 — drop the ambiguous IPSSH-* → cisco mapping

Nmap's `match ssh m|^SSH-([\d.]+)-IPSSH-` line is labelled as
`Cisco/3com IPSSHd` — it cannot identify a specific vendor from the
banner alone. Mapping it to `cisco` would risk showing the wrong
vendor icon on a 3Com device. Removed the rule entirely and
documented why with a code comment; users with such devices can
still use the Host Details manual distro override.

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

* fix: address remaining gaps from local codex follow-up review

P0 gap — delete connection token on session exit. Previously the map
entry lingered after disconnect, so a very late-firing timer could
still pass the isConnectionTokenCurrent check even though the session
no longer existed. Functionally harmless (the IPC calls would fail)
but semantically wrong. Now connectionTokensBySessionId.delete() is
called in the onSessionExit handler.

P1 new — exec channel leak on timeout in getSessionDistroInfo. The
timeout branch resolved the promise but didn't close the stream, so
a hanging remote command would leave the exec channel open until the
SSH connection itself dropped. Added a settled guard (resolve-once)
and stream.close() on timeout.

P2.1 gap — givenUpRef not reset on sessionId change. The failure
counter reset only happened in the !isConnected branch of the main
effect, so a sessionId swap while still connected (rare, but
possible if the tab reconnects without toggling connected state)
would permanently suppress polling. Added a small dedicated effect
that resets both counters when sessionId changes.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:02:24 +08:00
陈大猫
60071424d0 fix: prevent crash when clicking external links with no default browser (#676)
* fix: prevent crash when clicking external links with no default browser (#663)

On systems like Tiny11 where no default browser is associated with
http/https URLs, shell.openExternal() rejects with Windows error 0x483
("No application is associated..."). The main process treated that
rejection as an unhandledRejection, which the global handler re-throws
as fatal, crashing the entire app.

Root cause: windowManager.cjs used `void shell?.openExternal?.(url)`
inside a try/catch, assuming the try would cover the call. `void` only
discards the returned Promise — it does not catch async rejections,
so when openExternal rejected, the error escaped as a floating
unhandledRejection.

The IPC handler in main.cjs (`netcatty:openExternal`) also awaited
shell.openExternal() without any try/catch. Electron's ipcMain.handle
forwards rejections to the renderer over IPC, but the renderer-side
fallback called `window.open()`, which re-entered the same buggy
windowManager path — and that is where the process actually died.

Changes:
- windowManager.cjs: attach an explicit `.catch` on the openExternal
  Promise in both createExternalOnlyWindowOpenHandler and
  createAppWindowOpenHandler so rejections cannot propagate.
- main.cjs: wrap the IPC handler in try/catch and return a structured
  { success, error } result instead of throwing. This lets the
  renderer render an informative message.
- global.d.ts: update the openExternal return type to match.
- useApplicationBackend.ts: read the structured result and throw on
  failure so callers can react; drop the now-redundant window.open()
  fallback for the Electron branch (kept only for non-Electron envs).
- SettingsApplicationTab.tsx: show a friendly toast ("No default
  browser configured — please set one in system settings") when
  openExternal fails, instead of the previous silent failure.
- i18n: add en + zh-CN strings for the toast.

Closes #663

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

* feat: fall back to in-app browser window when system has no default browser

Instead of showing a toast when shell.openExternal() fails (e.g. Tiny11
with no default browser), open the URL in a minimal in-app BrowserWindow
so users can still read the linked page.

windowManager.cjs now exposes:
- openFallbackBrowser(url, opts): creates a stripped-down BrowserWindow
  that loads the URL. No preload script (remote content must never
  touch contextBridge), contextIsolation/nodeIntegration/sandbox all
  set to safe defaults, and an isolated persist:netcatty-fallback-browser
  session so cookies and storage do not leak into the main app.
  Basic Alt+Left / Alt+Right / Ctrl-or-Cmd+R shortcuts for navigation
  and reload.
- tryOpenExternalWithFallback(shell, url, opts): tries
  shell.openExternal first; on rejection, falls back to
  openFallbackBrowser. Returns { success, fallback?: "in-app-browser" }.

All three external-URL call paths now route through this helper:
- main.cjs netcatty:openExternal IPC handler
- createExternalOnlyWindowOpenHandler (popup blocker for child windows)
- createAppWindowOpenHandler (main/settings window window-open handler)

The renderer-side toast is retained as a last-resort for the rare case
that both system and in-app browsers fail (e.g. BrowserWindow creation
error). Copy updated to reflect the new behavior.

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

* fix: preserve rejection semantics for failed external opens

Per Codex review on PR #676: returning { success, error } from
bridge.openExternal changed the contract from "reject on failure" to
"resolve with a failure object on failure", which silently broke
callers that rely on rejection to abort flows.

useCloudSync's OAuth path is the clearest example: it wraps
bridge.openExternal in a try/catch and rejects browserPromise inside
the catch. With the resolved-failure contract, that catch never fires,
so Promise.race([callbackPromise, browserPromise]) can hang
indefinitely when no browser is available.

Revert the contract:
- tryOpenExternalWithFallback resolves void on success (system browser
  or in-app fallback) and throws on total failure
- main.cjs IPC handler awaits and lets rejections propagate
- global.d.ts openExternal is Promise<void> again
- useApplicationBackend just awaits — rejections propagate naturally
- SettingsApplicationTab's existing try/catch + toast continues to
  work as before

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

* fix: propagate fallback browser loadURL failures

Per Codex P2: openFallbackBrowser swallowed loadURL rejections by
attaching a .catch that only logged, so any caller using
tryOpenExternalWithFallback as a success signal saw an opened window
as success even when the page failed to load. OAuth flows would then
wait for the downstream callback timeout instead of canceling early
on malformed or unreachable URLs.

openFallbackBrowser now returns { window, loaded } where `loaded` is
the raw loadURL Promise, and tryOpenExternalWithFallback awaits it in
the fallback path. On initial load failure, the broken window is
closed and the original shell.openExternal error is re-thrown.

The internal popup handler inside the fallback window keeps its
fire-and-forget behavior (it must return synchronously) but now
explicitly catches the loaded rejection to avoid unhandledRejection.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:39:37 +08:00
陈大猫
51abe7da63 fix: send SSH keepalive on idle SFTP sessions to prevent NAT drop (#669) (#671)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The main openSftp() connection path was building ssh2 connect options
without setting keepaliveInterval at all, so no SSH-level keepalive
packets were sent on the SFTP channel. When the SFTP panel sits idle
(the common case while a user browses files), NAT/firewall state
tables reap the idle TCP connection after ~30-60s, causing the panel
to disconnect while the SSH terminal next to it — which has its own
keepalive config via sshBridge — stays connected. That matches the
exact symptom reported in #669.

Default to a 10s keepalive interval, matching the existing SFTP jump
host path (sftpBridge.cjs:466-467). Honor an explicitly configured
positive options.keepaliveInterval (in seconds) if one is passed in,
so the frontend can thread the user setting through later without
another bridge change.

Closes #669

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:45:51 +08:00
yuzifu
9667c03ddc fix: pin toolbar above content on KeychainManager page (#666)
* fix: pin toolbar above content on KeychainManager page

* fix: apply panel offset to outer wrapper so toolbar is not covered

The aside panel is rendered as an absolute overlay (right-0, w-[380px]),
so any container covered by the overlay needs mr-[380px] to avoid
having its right-side controls obscured. Previously only the inner
scroll area had the offset, which left the toolbar at full width —
its right-side controls (view-mode dropdown, etc.) would be covered
by the panel and become unclickable when it opened.

Move both the margin and the transition to the outer flex wrapper so
the toolbar and the scroll area shift together when the panel opens.

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

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:14:41 +08:00
陈大猫
9935eb2ed1 fix: preserve file permissions when saving edited file via SFTP (#667)
* fix: preserve file permissions when saving edited file via SFTP (#665)

ssh2-sftp-client's put() overwrites existing files with the server's
default mode (typically 0o666 after umask), so a 0o755 file edited
through the built-in text editor would silently become 0o666 after
save.

Stat the file before writing to capture its existing mode, then
chmod it back to that mode after put() completes. For new files,
stat fails and we fall through to let the server apply defaults,
preserving existing behavior for file creation.

Closes #665

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

* fix: also preserve setuid/setgid/sticky bits when restoring mode

Use 0o7777 mask instead of 0o777 so special permission bits are
preserved alongside the regular rwx bits — otherwise a 4755
executable would still be restored as 0755 after editing.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:04:10 +08:00
Eric Chan
268b698a39 Follow up #640 for the Snippets page (#662)
* Update snippets page to use inline aside panels

* Fix nested host editor overflow in selector panel
2026-04-09 15:21:55 +08:00
Eric Chan
2491d1a177 Shorten MCP approval timeout (#659) 2026-04-09 09:56:19 +08:00
陈大猫
2bf2220d0b fix: open quick-add snippet modal in place instead of navigating (#657)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The previous "+" flow in ScriptsSidePanel switched the active tab to
Vault and jumped to the Snippets section, which ripped the user out
of their current terminal context — exactly what the feature was
supposed to avoid.

Replace the cross-panel navigation flow with a lightweight modal
dialog mounted at the App root:

- New component QuickAddSnippetDialog renders over everything and
  owns its own form state. Fields: label, command (multi-line), and
  package (combobox with allowCreate).
- App.tsx mounts the dialog globally and wires it to updateSnippets /
  updateSnippetPackages. No prop drilling through TerminalLayer.
- ScriptsSidePanel still dispatches the same netcatty:snippets:add
  window event; the dialog listens for it and opens in place.
- Reverted the navigateToSection / pendingSnippetAdd / openAddTrigger
  plumbing in App.tsx, VaultView, and SnippetsManager.

Advanced fields (targets, shortkey, tags) can still be set later
via the full Snippets manager. Cmd/Ctrl+Enter saves from any field.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:10:34 +08:00
陈大猫
683756324e feat: add "new snippet" button in terminal ScriptsSidePanel (#641) (#656)
* feat: add "new snippet" button in terminal ScriptsSidePanel (#641)

Previously, adding a new snippet required navigating back to the main
Snippets section from the Vault view. This adds a "+" button in the
search header of the terminal-side ScriptsSidePanel that jumps
directly into the snippet edit flow.

Flow:
- ScriptsSidePanel "+" → dispatches window event `netcatty:snippets:add`
- App.tsx listens → switches activeTab to vault, navigates to Snippets
  section, and bumps a monotonic `openSnippetAddTrigger` state
- VaultView forwards the trigger to SnippetsManager
- SnippetsManager watches the trigger and opens its add panel when
  the value changes (uses a ref to ignore unrelated remounts)

Closes #641

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

* fix: switch add-snippet flow to one-shot pending flag

Codex review pointed out a real bug with the monotonic trigger approach:
when SnippetsManager mounts for the first time with openAddTrigger already
non-zero (the common "+ clicked from terminal while not on Snippets section"
path), the last-seen-trigger ref is initialized to the current value and
the useEffect immediately returns early, so the add panel never opens.

Switch to a cleaner one-shot pending flag:
- App.tsx holds pendingSnippetAdd: boolean + handlePendingSnippetAddHandled
- VaultView forwards pendingSnippetAdd + onPendingSnippetAddHandled
- SnippetsManager opens the add panel on every transition to pendingAdd=true,
  then clears the flag via onPendingAddHandled, so subsequent renders and
  plain remounts are no-ops

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

* fix: move useCallback above early return in ScriptsSidePanel

React's rules-of-hooks require all hooks to be called unconditionally.
The new handleAddSnippet useCallback was placed after the
`if (!isVisible) return null;` guard, which tripped eslint.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:58:34 +08:00
陈大猫
80fbf0da2f feat: add data-section hooks for Custom CSS targeting (#642) (#655)
Custom CSS already exists in Settings → Appearance, but major UI
components use only Tailwind utility classes, making it hard for
users to reliably target regions in their custom styles.

This adds stable `data-section="..."` attributes on the root element
of the most commonly customized UI regions so users can write selectors
like `[data-section="snippets-panel"] { font-size: 14px !important; }`
without depending on implementation details.

Instrumented regions:
- snippets-panel (ScriptsSidePanel)
- host-details-panel (HostDetailsPanel via AsidePanel dataSection prop)
- group-details-panel (GroupDetailsPanel)
- serial-host-details-panel (SerialHostDetailsPanel)
- ai-chat-panel (AIChatSidePanel)
- vault-view / vault-sidebar / vault-main / vault-hosts-header / vault-host-list (VaultView)
- terminal-workspace / terminal-workspace-sidebar (TerminalLayer)
- top-tabs (TopTabs — also keeps existing data-top-tabs-root)

Also updated the Custom CSS description and placeholder in both
English and Chinese to list available hooks and show a working
example (snippet panel font-size override).

Closes #642

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:38:50 +08:00
陈大猫
556a14178c fix: prevent host details panel from being clipped on narrow windows (#653)
When the host details / new-host aside panel is open, narrow windows
could clip the panel content because the main area lacked min-w-0 and
the window had no minimum size.

- Add min-w-0 to the main area so flexbox can shrink the host list
  portion when the window narrows, keeping the 420px panel fully visible
- Set the BrowserWindow minWidth/minHeight to 1100x640 so the user
  cannot drag the window narrower than what the panel + sidebar +
  host list need to render comfortably
- Clamp previously saved window dimensions to the new minimum on launch
- Animate the New Host split button and the Terminal / Serial buttons
  to collapse with a 200ms transition when the host panel is open,
  freeing horizontal space and hiding controls that would be no-ops

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:04:55 +08:00
Eric Chan
7e566efe9c Add push-style host details panels (#649)
Refs: https://github.com/binaricat/Netcatty/issues/640
2026-04-08 16:42:32 +08:00
Eric Chan
1d2489b02c feat: support long-running AI terminal jobs (#647)
* Add background terminal jobs for long AI commands

* Bound background job output buffering

* Fix long-running terminal job polling and stop behavior

* Fix terminal job final output and stopping retention

* Wait for PTY stop confirmation before cancelling

* fix: address codex review findings in PTY job refactor

- [P1] Use last occurrence of start marker to skip echoed wrapper command,
  preventing control markers from leaking into stdout
- [P1] Add wall-clock timeout for foreground PTY execution so commands that
  print continuously still get terminated at the configured limit
- [P2] Add hard deadline for cancellation so jobs that ignore Ctrl+C are
  force-finished after 30s instead of staying stuck in "stopping" forever

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

* fix: address round-2 codex review findings

- [P1] Use visibleOutput for background job completion to keep offsets
  consistent with polling, preventing output loss when raw buffer
  (with ANSI codes) truncates earlier than the visible buffer
- [P2] Clarify system prompt that terminal_start requires PTY-backed
  sessions, so exec-only SSH sessions are not incorrectly routed

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

* fix: address round-3 codex review findings

- [P1] Always strip markers from visibleOutput in background job finish
  to prevent end-marker lines leaking into terminal_poll results
- [P2] Correct terminal_execute timeout guidance from ~2min to ~60s to
  match the actual default commandTimeoutMs (60000)

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

* fix: address round-4 codex review findings

- [P1] Delay session lock release when cancel is forced (process may
  still be running) to prevent sending commands into a busy shell
- [P2] Move scope validation before pendingSessionWriteApprovals so
  out-of-scope requests fail fast without blocking the write lock
- [P2] Add session scope checks to handleJobPoll and handleJobStop
  so chats that lose access cannot read output or cancel jobs

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

* fix: address round-5 codex review findings

- [P1] Strip marker lines before they enter the bounded visible buffer
  so they never occupy space or leak as partial fragments on truncation
- [P2] Never release session lock after forced cancellation since the
  previous process may still be attached to the PTY

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

* fix: address round-6 codex review findings

- [P2] Buffer incomplete marker lines across PTY chunks to prevent
  partial marker fragments from leaking into visible output
- [P1] Release session lock after 60s delay on forced cancel as
  compromise between safety and permanent lock
- [P2] Enforce session scope checks on jobPoll/jobStop for both
  dynamic (chatSessionId) and static (NETCATTY_MCP_SESSION_IDS) modes

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

* fix: address round-7 codex review findings

- [P2] validateSessionScope now accepts explicit scopedSessionIds so
  static MCP scope mode is enforced for jobPoll/jobStop too
- [P2] Apply per-session execution lock to netcatty:ai:exec IPC path
  so it cannot race with active background jobs on the same session

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

* fix: address round-8 codex review findings

- [P1] Make wall-clock timeout opt-in via enforceWallTimeout flag,
  enabled only for MCP terminal_execute path. Catty Agent's
  netcatty:ai:exec keeps the inactivity-based timeout since it has
  no terminal_start fallback for long-running streaming commands
- [P2] Always allow handleJobStop regardless of session scope so
  the per-session execution lock can always be released after
  workspace membership changes

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

* fix: address round-9 codex review findings

- [P1] Enable enforceWallTimeout for netcatty:ai:exec to match the
  pre-PR behavior (hard wall-clock deadline). Without this, tail -f
  or verbose builds would hold the session lock indefinitely
- [P2] Treat explicit scopedSessionIds=[] as no access rather than
  falling through to global scope, matching handleGetContext's
  documented behavior

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

* fix: address round-10 codex review findings

- [P2] Add bounded startup deadline (30s) for the start marker arrival
  even when wall-clock timeout is disabled. Prevents background jobs
  from hanging indefinitely on already-chatty PTY sessions
- [P3] Use job-specific marker (not generic __NCMCP_) when stripping
  marker lines, so user output containing __NCMCP_ is preserved

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

* fix: address round-11 codex review findings

- [P2] Skip the 30s startup timeout for foreground execViaPty paths.
  It now applies only when maxBufferedChars > 0 (background jobs),
  so foreground commands queued behind a busy shell can wait
- [P2] Return empty stdout from getSnapshot() before the start marker
  arrives, so an early poll cannot advance nextOffset past pre-start
  PTY noise that gets discarded once the real command begins

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

* fix: address round-12 codex review findings

- [P1] Treat empty chat scopes as no access in validateSessionScope:
  if a chat has explicit scoped metadata (even []), enforce strictly
  rather than falling through to fallback/global scope
- [P2] Re-add session scope check in handleJobStop for static MCP
  clients (scopedSessionIds), while still allowing dynamic chat-scoped
  callers to always stop their own jobs even after scope changes

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

* fix: address round-13 codex review findings

- [P2] getScopedJob now requires the caller to present the job's
  chatSessionId. Unscoped/static callers cannot reach into another
  chat's background jobs even if they learn the jobId
- [P2] Stop button no longer cancels terminal_start background jobs.
  They are intentionally long-running, so killing them on every
  per-response stop defeats the purpose of the feature. Cleanup on
  chat deletion (cleanupScopedMetadata) is preserved

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

* fix: address round-14 codex review findings

- [P1] terminal_start jobs no longer registered in activePtyExecs so
  ACP "Stop" / cancelPtyExecsForSession does not kill them. They are
  still managed via terminal_stop and the per-session execution lock
- [P1] Remove enforceWallTimeout from netcatty:ai:exec since Catty
  Agent has no terminal_start fallback for long-running commands.
  Inactivity timeout still catches genuinely hung processes
- [P2] Forced-cancelled jobs stay in "stopping" (completed=false)
  until the 60s lock grace period ends, so callers don't see the
  job as completed while the session is still locked

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

* fix: address round-15 codex review findings

- [P2] Allow netcatty/jobStop to bypass the chat-cancelled gate so
  users can stop terminal_start jobs even after ACP "Stop" was pressed
- [P2] Mark non-zero exit codes as failed (not completed) so callers
  don't have to special-case exitCode against status
- [P2] Pre-start cancel: clear startup timer in requestCancel and
  detect prompt return on preStartOutput so a queued job that gets
  cancelled resolves as "Cancelled", not "startup timed out"

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

* fix: address round-16 codex review findings

- [P2] Cap preStartOutput for background jobs at maxBufferedChars so
  noisy idle PTYs cannot accumulate megabytes before the start marker
  arrives or the startup timeout fires
- [P2] On forced cancel, immediately release the session lock and
  mark the job as cancelled. The error message clearly states that
  the process may still be running, and the caller sees completed=true
  exactly when the lock is no longer held — consistent semantics

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

* fix: address round-17 codex review findings

- [P2] Disable prompt-suffix completion fallback for background jobs.
  Long-running commands often print prompt-like text (nested shells,
  ssh, sudo -s, REPLs) and would otherwise be misdetected as completed.
  Background jobs rely strictly on the end marker
- [P2] consumeVisibleText now treats \\r as a carriage return that
  resets the current line, so progress bars (npm, docker pull, curl)
  collapse to the latest frame instead of accumulating every redraw

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

* fix: address round-18 codex review findings

- [P2] Pre-start cancel on sessions without a tracked idle prompt now
  gets a 2s fallback to finish as Cancelled, instead of waiting the
  full forced-cancel window for an end marker that will never arrive
- [P3] Move session-scope validation before the busy-session check so
  out-of-scope callers cannot probe the existence/activity of foreign
  sessions via busy-state error messages

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

* fix: address round-19 codex review findings

- [P1] Re-enable prompt-suffix completion fallback for background
  jobs but with a longer 10s delay so nested shells / REPLs have
  time to print past their initial prompt before the recheck
- [P2] Carriage returns now collapse progress redraws across PTY
  chunks: \\r is preserved through consumeVisibleText and
  applyCarriageReturns erases the trailing line of visibleOutput
  when a chunk starts with \\r. Verified with a fake PTY that
  emits "10%" then "\\r20%" then "\\r30%\\n" — final output is "30%"

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

* fix: address round-20 codex review findings

- [P1] Disable prompt-suffix completion fallback for background jobs.
  Commands that open child shells with the same prompt as the parent
  (bash, zsh, sudo -s, ssh) would otherwise be reported as completed
  while the child is still running. Background jobs rely strictly on
  the end marker, with their long timeout and explicit terminal_stop
- [P2] Track a monotonic visibleHighWatermark so polling nextOffset
  cannot move backwards across CR redraws. serializeBackgroundJob now
  returns the latest visible frame when the caller's offset has been
  passed by a redraw, instead of returning empty stdout permanently
- [P3] Buffer trailing lines that contain the constant __NCMCP_
  prefix (not just the full random marker token) so PTY chunk
  boundaries that split the marker mid-token cannot leak _E:0 noise

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

* fix: address round-21 codex review findings

- [P2] Foreground execs now also get a hard startup deadline (using
  the configured timeoutMs as the limit). Background jobs use a
  fixed 30s. Without this, an already-chatty PTY would let onData
  re-arm the inactivity timer forever before _S arrives
- [P2] finish() now uses the monotonic visibleHighWatermark for
  totalOutputChars on completion, so the final poll's nextOffset
  cannot regress relative to earlier polls after CR redraws

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

* fix: address round-22 codex review findings

- [P2] cleanupScopedMetadata now also calls clearPendingApprovals so
  in-flight approval requests resolve immediately. Otherwise a chat
  deleted while an approval was pending would leave the per-session
  write lock held until the 5-minute approval timeout expires
- [P2] Allow netcatty/jobStop in observer mode so users can stop
  long-running terminal_start jobs that were launched before they
  switched to observer mode

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

* fix: address round-23 codex review finding

- [P2] Apply \\r as a "deferred" carriage return: park the cursor at
  the start of the line but defer erasure until the next character
  arrives. This preserves the latest visible frame for commands like
  printf '10%%\\r'; sleep; printf '20%%\\r' that pause between
  redraws, while still collapsing continuous progress redraws to a
  single frame. Verified: snapshots now show '40%' and '50%' instead
  of empty stdout

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

* fix: address round-24 codex review findings

- [P1] Re-enable prompt fallback for background jobs with a 30s
  delay so commands open child shells / REPLs have time to print
  past their initial prompt before the recheck. This is the third
  time codex has flip-flopped on this — 30s is the compromise
- [P2] Pass chatSessionId to execViaChannel in handleExec so
  cancelPtyExecsForSession can interrupt SSH exec-channel commands
  scoped to the originating chat

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

* fix: address round-25 codex review finding

- [P1] Stop in-place CR collapsing in visibleOutput. The collapsed
  buffer made polling offsets non-monotonic and could drop finalized
  lines after a CR rewrite. Now visibleOutput stores raw bytes (with
  \\r dropped at consumeVisibleText to keep the buffer simple), the
  256KB cap naturally bounds progress-bar accumulation, and slice
  semantics work correctly across all redraw patterns. Consumers
  that want a "collapsed view" can post-process

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

* fix: address round-26 codex review findings

- [P2] Carriage returns are now preserved in the raw buffer and
  collapsed at serialize time in collapseCarriageReturns. This keeps
  monotonic offsets in the buffer while polled output shows the
  latest progress frame. A trailing \\r leaves existing content
  intact (deferred erasure semantics)
- [P2] netcatty/jobStop now bypasses the confirm-mode approval gate
  so a runaway terminal_start job can always be interrupted, even
  when the renderer is unavailable
- [P3] requestCancel's one-shot timers (2s pre-start, 150ms reinforce,
  30s force-finish) are now tracked and cleared in finish() so they
  cannot keep the Node event loop alive after the job has resolved

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

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:39:21 +08:00
陈大猫
5ad3d0ce32 fix: prevent crash when codex-acp binary is not found (#648)
* fix: prevent crash when codex-acp binary is not found (#645)

When codex-acp is not installed, resolveCodexAcpBinaryPath returned the
bare binary name as a fallback. This caused createACPProvider to spawn a
non-existent process, emitting an async ENOENT error that crashed the app.

Return null instead of the bare name and guard all createACPProvider call
sites so the error is handled gracefully.

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

* fix: install cross-platform codex-acp binaries in CI build

macOS and Windows CI builds produce both arm64 and x64 packages, but
npm ci only installs optional dependencies for the host platform. This
means the codex-acp native binary for the other architecture is missing
from the packaged app, causing ENOENT crashes for users on the
non-host architecture.

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

* fix: add --force to bypass cpu/os constraints for cross-arch install

The platform-specific codex-acp packages declare cpu/os constraints in
their package.json, so npm refuses to install the non-host-arch binary
with EBADPLATFORM. Use --force to bypass this check.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:53:27 +08:00
bincxz
edf013164b fix: limit recently connected hosts to 6
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:59:47 +08:00
陈大猫
504b576e1c fix: stop deduplicating pinned/recent hosts from main host list (#632) (#636)
Previously hosts shown in the pinned or recently-connected sections
were excluded from the main list and group view, causing incomplete
group counts and missing hosts under group sort mode.

Closes #632

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:53:46 +08:00
Leo Pan
890abd1c4c Fix/terminal clear preserve scrollback (#633)
* fixd:issure #622

* fix: use baseY instead of viewportY for active screen row count

When the user scrolls up to browse history, viewportY differs from
baseY (the active screen origin). _core.scroll always operates on
the active screen, so counting rows from viewportY preserves the
wrong number of lines and may evict older scrollback unexpectedly.

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

* fix: use term.clear() for local clear to preserve prompt line

The escape sequence \x1b[H\x1b[2J erases the entire display including
the current prompt/input line, which is a regression from term.clear()
that keeps the prompt as the first visible line. Remote CSI 2 J is
already handled separately by the CSI parser handler.

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

* fix: preserve both scrollback and prompt in local clear

term.clear() destroys scrollback (truncates buffer lines). The escape
sequence approach erases the prompt. This commit uses _core.scroll to
push lines above cursor into scrollback, then clears below the prompt
with CSI 0 J and repositions the cursor — preserving both history and
the current prompt line.

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

---------

Co-authored-by: panwk <panwk@88.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:03:39 +08:00
陈大猫
0827dd416f fix: truncate long command text in snippet list to prevent layout overflow (#628) (#630)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Use w-0 flex-1 pattern on text containers to enforce width constraint
- Add overflow-hidden on list item containers
- Add tooltip on snippet command text to show full content on hover

Closes #628

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:05:56 +08:00
陈大猫
24df4b6548 fix: support CSV password import and save password in keyboard-interactive auth (#629)
* fix: support CSV password import and save password in keyboard-interactive auth (#627)

- Add Password column support to CSV import/export/template
- Add isAPasswordPrompt detection (prompt contains "password" + echo=false)
- Auto-fill saved password in keyboard-interactive modal
- Add "Save password" checkbox for password prompts in keyboard-interactive modal
- Wire save callback through sessionId → host to persist password

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

* fix: address review feedback for keyboard-interactive and CSV changes

- Merge password field in dedupeHosts to avoid losing passwords from duplicate CSV rows
- Extract isAPasswordPrompt to module-level pure function
- Only render save-password checkbox at the first password prompt index
- Clean up orphaned i18n keys (useSaved, useSavedPassword, fill, fillSaved)

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

* fix: preserve whitespace in CSV imported passwords

Passwords may intentionally contain leading/trailing whitespace.
Removing .trim() ensures lossless CSV round-trip and correct auth.

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

* fix: exclude OTP prompts from password detection and guard jump host save

- Add negative patterns (one-time, otp, verification, token, code) to
  isAPasswordPrompt to avoid auto-filling SSH password into OTP fields
- Only save password when request hostname matches session hostname,
  preventing jump host passwords from overwriting the destination host

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

* fix: skip formula injection guard for password column in CSV export

Password values starting with =, +, -, @ were getting a ' prefix from
the CSV formula injection protection, breaking round-trip fidelity.
Now password column is escaped for CSV syntax only, preserving the
credential verbatim.

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

* fix: only skip formula guard for data rows, not header row

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:39:39 +08:00
陈大猫
7db4b18cce fix: add missing props destructuring in HostTreeView causing white screen (#625) (#626)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
getDropTargetClasses and setDragOverDropTarget were added to
HostTreeViewProps interface and used in JSX but never destructured
from the component's props parameter. TypeScript didn't catch it
because the interface defined them as optional, but at runtime the
bare variable references caused ReferenceError, crashing React and
producing a white screen on startup.

Closes #625

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:38:15 +08:00
陈大猫
844c55e99d fix: sync built-in editor theme with terminal theme in immersive mode (#623) (#624)
The Monaco editor only synced background color from CSS variables and missed
foreground, cursor, selection, line numbers, and widget colors. Additionally,
switching between terminal themes of the same type (e.g. two dark themes)
did not trigger an editor theme update because the MutationObserver only
watched class/style attributes on <html>.

- Read 6 CSS variables (bg, fg, primary, card, muted-fg, border) and map
  them to 14 Monaco theme color tokens
- Set data-immersive-theme attribute on <html> when immersive mode applies
  a theme, so the MutationObserver detects same-type theme switches
- Clean up the data attribute when immersive mode is removed

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:03:40 +08:00
陈大猫
778b43ceff fix: reset mouse tracking on start over to prevent escape sequence leak (#616) (#621)
When "Start Over" reconnects a session, the xterm instance retained
mouse tracking modes from the previous session. Mouse movements during
reconnection generated SGR mouse sequences (e.g. 35;XX;YYM) that were
sent to the new session as visible text input.

Fix: disable all mouse tracking modes (?1000l, ?1002l, ?1003l, ?1006l)
and reset the terminal before reconnecting.

Closes #616

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:03:04 +08:00
陈大猫
6b2e5041d2 fix: sort default shell to top in quick switcher (#613) (#620)
The local shell list was displayed in discovery order (alphabetical),
burying the default shell (e.g. Zsh) at the bottom. Now sorts
isDefault shells to the top of the list.

Closes #613

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:55:46 +08:00
陈大猫
1464cba6da feat: add xterm-container class for custom CSS bottom spacing (#614) (#619)
Add a stable .xterm-container CSS class to the terminal container div
so users can adjust bottom spacing via Custom CSS without color
mismatch issues.

Example custom CSS:
  .xterm-container { bottom: 10px !important; }

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:51:26 +08:00
陈大猫
d74d9e28a0 fix: split shortcut in workspace panes and host delete form freeze (#612) (#618)
* fix: split shortcut in workspace panes and host delete form freeze (#612)

Bug 1: Split-pane shortcuts (Ctrl+Shift+D/E) did nothing after the
first split because the workspace branch in executeHotkeyAction only
logged a message. Now uses workspace.focusedSessionId to split the
focused pane.

Bug 2: Deleting a host left editingHost state pointing to the removed
host, keeping HostDetailsPanel mounted as an overlay that blocked all
form interactions. Added a useEffect to close the panel when the
edited host is no longer in the hosts array.

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

* fix: Shift+right-click context menu and split content loss (#612)

Bug 4: When rightClickBehavior is 'paste' or 'select-word', the context
menu was completely disabled with no fallback. Now Shift+Right-Click
always opens the context menu regardless of the right-click behavior
setting.

Bug 5: Splitting a terminal occasionally caused the original pane's
content to disappear due to a race between layout reflow and xterm
fit(). Added a second delayed fit (350ms) after workspace layout
changes as a safety net for cases where the first fit (100ms) runs
before the container dimensions have settled.

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

* fix: guard host-deletion cleanup against unsaved duplicates

The cleanup effect that closes the host panel on deletion incorrectly
closed it for duplicated/new hosts whose IDs were never in the hosts
array. Track known host IDs via ref so the effect only fires when a
previously-saved host is actually removed.

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

* fix: check previous host IDs before updating ref in deletion cleanup

Merge the two effects into one so the deletion check reads from the
previous knownHostIdsRef before overwriting it with the current hosts.
Previously both effects ran in the same render cycle, causing the ref
to be updated before the check, making it impossible to detect deleted
hosts.

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

* fix: open context menu on first Shift+right-click

Replace state-based forceMenu approach with always-enabled
ContextMenuTrigger. The onContextMenu handler intercepts paste/
select-word actions unless Shift is held, so the Radix context menu
opens immediately on the first Shift+Right-Click without needing a
second click.

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

* fix: fallback to first live pane when workspace focus is stale

When the focused pane is closed, focusedSessionId may point to a
non-existent session. Split shortcuts now fall back to the first
session in the workspace tree via collectSessionIds() so the hotkey
never silently no-ops.

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

* fix: validate focusedSessionId against live workspace panes

focusedSessionId can be stale (non-null but pointing to a closed pane)
after pane closure. Now check it exists in collectSessionIds() before
using it, otherwise fall back to the first live pane.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:38:02 +08:00
陈大猫
32b74f4fea fix: persist sidebar appearance overrides for quick-connect hosts (#611)
* fix: persist sidebar appearance overrides for quick-connect hosts

Quick-connect hosts (id starting with `quick-`) are not in the saved
hosts array, so per-host overrides set via the sidebar (fontWeight,
theme, fontFamily, fontSize) were silently lost:

1. onUpdateHost only updated existing entries (map), never inserted —
   change to upsert so quick-connect hosts are added on first override.
2. fontWeight handlers guarded on rawHost from hostMap, which is
   undefined for quick-connect hosts — fall back to focusedHost.

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

* fix: only auto-add quick-connect hosts, never re-add deleted saved hosts

Restrict the onUpdateHost upsert to quick-connect hosts (id starts with
`quick-`). This prevents sidebar appearance changes from silently
re-adding a host that was intentionally deleted while its session was
still running.

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

* fix: use primary font only in document.fonts.check to fix bold weight fallback

document.fonts.check returns false when ANY listed font in the family
string is still loading. Our font family strings include a long CJK
fallback chain (Sarasa Mono SC, Noto Sans Mono CJK, PingFang SC, etc.)
that may not be loaded during early terminal creation. This caused
fontWeightBold to incorrectly fall back to the normal fontWeight,
making bold text (including shell prompts) render too thin in freshly
created terminals while live-updated terminals looked correct.

Fix: extract only the primary font family for the check, ignoring the
fallback chain that is irrelevant for bold weight availability.

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

* fix: normalize WebGL fontWeight rendering after terminal connection

Work around xterm.js WebGL renderer bug where glyphs rendered via the
constructor look visually different from those set dynamically. After
the terminal connects and text is on screen, force a fontWeight
round-trip (original → normal → original) so the WebGL texture atlas
rebuilds through the dynamic path, producing consistent rendering
that matches sidebar font weight changes.

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

* fix: use global settings for quick-connect host appearance changes

Quick-connect hosts have ephemeral IDs (quick-${Date.now()}-...) that
are never reused across connections. Auto-adding them to the hosts
array would accumulate orphaned entries over time.

Instead, treat quick-connect hosts like local terminals: sidebar
appearance changes (fontWeight, etc.) update the global terminal
settings rather than creating per-host overrides.

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

* fix: address code review findings

- Apply isFocusedHostEphemeral to theme, fontFamily, fontSize handlers
  (not just fontWeight) so all appearance changes on ephemeral hosts
  update global settings
- Use hostMap.has() instead of id.startsWith('quick-') to detect
  ephemeral hosts — saved hosts with quick- prefix are handled correctly
- Re-read fontWeight at timer fire time to avoid stale closure
- Handle quoted font names with commas in primaryFontFamily parser

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:52:26 +08:00
Eric Chan
f284fb0505 Refine host group drop feedback (#617) 2026-04-03 12:15:07 +08:00
174 changed files with 22525 additions and 2158 deletions

View File

@@ -43,6 +43,21 @@ jobs:
- name: Install deps
run: npm ci
- name: Install cross-platform native binaries
shell: bash
run: |
# npm ci only installs optional deps for the host platform, but
# electron-builder produces both arm64 and x64 binaries, so we
# need the native codex-acp binary for the other architecture too.
# Platform-specific codex-acp packages declare cpu/os constraints,
# so --force is needed to install the non-host-arch binary.
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
fi
- name: Set version
shell: bash
run: |

View File

@@ -18,7 +18,7 @@ This project is wired around three layers: domain (pure logic), application stat
- **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only.
## How Things Talk
- UI calls application hooks hooks call domain helpers persistence/config via infrastructure adapters.
- UI calls application hooks -> hooks call domain helpers -> persistence/config via infrastructure adapters.
- `App.tsx` wires hooks to components; no business logic should live in components beyond view glue.
- Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere.
@@ -44,6 +44,12 @@ This project is wired around three layers: domain (pure logic), application stat
- Avoid direct network/fetch in components; add a service/adaptor first.
- Maintain ASCII-only unless required by existing file content.
## Review Boundaries
- Treat `electron/cli/*`, `netcatty-tool-cli`, the CLI discovery file, and the local TCP bridge as internal Netcatty integration surfaces unless a task explicitly says otherwise.
- Do not review those surfaces as public APIs by default, and do not assume they must support third-party callers, manual launches, or non-Netcatty agents.
- On supported first-party paths, assume Netcatty's own launcher provides required integration environment such as `NETCATTY_TOOL_CLI_DISCOVERY_FILE`.
- If a review concern depends on external exposure, third-party compatibility, or public API stability, call it out as out of scope unless the task explicitly includes that contract.
---
## Aside Panel Design System
@@ -54,20 +60,20 @@ VaultView subpages (Hosts, Keychain, Port Forwarding, Snippets, Known Hosts) sha
Import from `./ui/aside-panel`:
```tsx
import {
AsidePanel,
AsidePanelHeader,
AsidePanelContent,
import {
AsidePanel,
AsidePanelHeader,
AsidePanelContent,
AsidePanelFooter,
AsideActionMenu,
AsideActionMenuItem
AsideActionMenuItem
} from "./ui/aside-panel";
```
### Basic Usage
```tsx
<AsidePanel
open={isOpen}
<AsidePanel
open={isOpen}
onClose={handleClose}
title="Panel Title"
subtitle="Optional subtitle"

462
App.tsx
View File

@@ -18,20 +18,32 @@ import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { resolveHostAuth } from './domain/sshAuth';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import { applySyncPayload } from './application/syncPayload';
import type { SyncPayload } from './domain/sync';
import { applySyncPayload, buildSyncPayload, hasMeaningfulSyncData } from './application/syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
} from './application/localVaultBackups';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import {
STORAGE_KEY_DEBUG_HOTKEYS,
STORAGE_KEY_PORT_FORWARDING,
} from './infrastructure/config/storageKeys';
import { getEffectiveKnownHosts } from './infrastructure/syncHelpers';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
import { Input } from './components/ui/input';
import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { VaultView, VaultSection } from './components/VaultView';
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
@@ -177,10 +189,12 @@ function App({ settings }: { settings: SettingsState }) {
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const {
theme,
setTheme,
resolvedTheme,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
@@ -218,6 +232,7 @@ function App({ settings }: { settings: SettingsState }) {
}, [workspaceFocusStyle]);
const {
isInitialized: isVaultInitialized,
hosts,
keys,
identities,
@@ -303,6 +318,12 @@ function App({ settings }: { settings: SettingsState }) {
const activeTabId = useActiveTabId();
const customThemes = useCustomThemes();
useEffect(() => {
if (!settings.showSftpTab && activeTabId === 'sftp') {
setActiveTabId('vault');
}
}, [settings.showSftpTab, activeTabId, setActiveTabId]);
// Resolve the effective TerminalTheme for the currently focused terminal tab
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
@@ -326,6 +347,11 @@ function App({ settings }: { settings: SettingsState }) {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
// When "Follow Application Theme" is on, the UI-matched terminal
// theme overrides everything — including per-host theme overrides.
// This ensures all terminals match the app chrome regardless of
// individual host settings.
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
return themeById.get(themeId) || currentTerminalTheme;
@@ -358,7 +384,7 @@ function App({ settings }: { settings: SettingsState }) {
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
}, [activeTabId, currentTerminalTheme, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
activeTabId,
@@ -380,8 +406,131 @@ function App({ settings }: { settings: SettingsState }) {
[portForwardingRules],
);
const buildCurrentSyncPayload = useCallback(() => {
let effectivePortForwardingRules = portForwardingRulesForSync;
if (effectivePortForwardingRules.length === 0) {
const stored = localStorageAdapter.read<typeof portForwardingRulesForSync>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePortForwardingRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
return buildSyncPayload(
{
hosts,
keys,
identities,
snippets,
customGroups,
snippetPackages,
knownHosts: getEffectiveKnownHosts(knownHosts),
groupConfigs,
},
effectivePortForwardingRules,
);
}, [
customGroups,
groupConfigs,
hosts,
identities,
keys,
knownHosts,
portForwardingRulesForSync,
snippetPackages,
snippets,
]);
const [startupSyncSafetyReady, setStartupSyncSafetyReady] = useState(false);
// buildCurrentSyncPayload's identity changes each time the vault
// settles. The retry effect below watches the underlying data arrays
// for hydration progress, and uses the ref to always read the latest
// builder without pulling buildCurrentSyncPayload itself into deps
// (its identity churns on unrelated state updates too).
const buildCurrentSyncPayloadRef = useRef(buildCurrentSyncPayload);
useEffect(() => {
buildCurrentSyncPayloadRef.current = buildCurrentSyncPayload;
}, [buildCurrentSyncPayload]);
const versionBackupAttemptedRef = useRef(false);
// Two-stage gate: once the vault has initialized we open the auto-sync
// gate immediately — the hook's own hasMeaningfulSyncData guard and
// the cross-window restore barrier prevent an empty-but-not-yet-
// hydrated snapshot from overwriting cloud data. The version-change
// backup itself is best-effort and retries below as vault data arrives.
useEffect(() => {
if (isVaultInitialized && !startupSyncSafetyReady) {
setStartupSyncSafetyReady(true);
}
}, [isVaultInitialized, startupSyncSafetyReady]);
// Retry the version-change backup as hosts/keys/snippets become
// available. ensureVersionChangeBackup refuses to advance the stored
// version stamp when the observed payload is empty, so running this
// effect repeatedly is safe and eventually latches once the vault has
// hydrated enough to be backed up (or the user genuinely stays empty,
// in which case the effect continues to no-op).
useEffect(() => {
if (!isVaultInitialized || versionBackupAttemptedRef.current) return;
const payload = buildCurrentSyncPayloadRef.current();
if (!hasMeaningfulSyncData(payload)) return;
versionBackupAttemptedRef.current = true;
let cancelled = false;
void (async () => {
try {
const info = await netcattyBridge.get()?.getAppInfo?.();
await ensureVersionChangeBackup(payload, info?.version ?? null);
} catch (error) {
if (!cancelled) {
// Reset the latch so a later data change (or the next mount)
// can retry. ensureVersionChangeBackup already leaves the
// version stamp untouched on failure, so retrying is safe.
versionBackupAttemptedRef.current = false;
}
console.error('[App] Failed to create version-change backup:', error);
}
})();
return () => {
cancelled = true;
};
}, [isVaultInitialized, hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts]);
// Memoized "apply a remote payload safely" callback. Stable identity
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
// on unrelated App-level state changes (which would churn the debounced
// auto-sync useEffect dep chain).
const handleApplySyncPayload = useCallback(
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: () => buildCurrentSyncPayload(),
applyPayload: () =>
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
}),
translateProtectiveBackupFailure: (message) =>
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
}),
[
buildCurrentSyncPayload,
importDataFromString,
importPortForwardingRules,
settings.rehydrateAllFromStorage,
t,
],
);
// Auto-sync hook for cloud sync
const { syncNow: handleSyncNow } = useAutoSync({
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
hosts,
keys,
identities,
@@ -392,13 +541,8 @@ function App({ settings }: { settings: SettingsState }) {
knownHosts,
groupConfigs,
settingsVersion: settings.settingsVersion,
onApplyPayload: (payload) => {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
});
},
startupReady: startupSyncSafetyReady,
onApplyPayload: handleApplySyncPayload,
});
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
@@ -544,7 +688,7 @@ function App({ settings }: { settings: SettingsState }) {
if (binding.category === 'sftp') {
continue;
}
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
const terminalActions = ['copy', 'paste', 'pasteSelection', 'selectAll', 'clearBuffer', 'searchTerminal'];
if (terminalActions.includes(binding.action)) {
if (isTerminalElement) {
return;
@@ -722,6 +866,7 @@ function App({ settings }: { settings: SettingsState }) {
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
sessionId: request.sessionId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
@@ -736,14 +881,29 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
// Save password to host if requested
if (savePassword) {
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
if (request?.sessionId) {
const session = sessions.find(s => s.id === request.sessionId);
// Only save when the prompting hostname matches the session's host,
// to avoid overwriting the destination host's password with a jump host's password
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
const host = hosts.find(h => h.id === session.hostId);
if (host) {
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
}
}
}
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
}, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
@@ -833,6 +993,10 @@ function App({ settings }: { settings: SettingsState }) {
const addConnectionLogRef = useRef(addConnectionLog);
addConnectionLogRef.current = addConnectionLog;
const closeSidePanelRef = useRef<(() => void) | null>(null);
const activeSidePanelTabRef = useRef<string | null>(null);
const closeTabInFlightRef = useRef(false);
const createLocalTerminalWithCurrentShell = useCallback(() => {
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
@@ -866,15 +1030,102 @@ function App({ settings }: { settings: SettingsState }) {
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
}, [hotkeyScheme, keyBindings]);
const confirmIfBusyLocalTerminal = useCallback(
async (sessionIds: string[]): Promise<boolean> => {
const bridge = netcattyBridge.get();
const localIds = sessionIds.filter((id) => {
const s = sessions.find((x) => x.id === id);
return s?.protocol === 'local';
});
const busyCommands: string[] = [];
for (const id of localIds) {
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
if (children.length > 0) {
busyCommands.push(children[0].command);
}
}
if (busyCommands.length === 0) return true;
const primary = busyCommands[0];
const extraCount = busyCommands.length - 1;
const message =
extraCount > 0
? t('confirm.closeBusyTerminal.messageWithMore', {
command: primary,
count: extraCount,
})
: t('confirm.closeBusyTerminal.message', { command: primary });
const ok = await bridge?.confirmCloseBusy?.({
command: primary,
title: t('confirm.closeBusyTerminal.title'),
message,
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
closeLabel: t('confirm.closeBusyTerminal.close'),
});
return ok === true;
},
[sessions, t],
);
const closeTabsInFlightRef = useRef(false);
// Close many tabs at once with a single batched busy-shell confirmation.
// Used by the "Close all / Close others / Close to the right" context-menu
// actions on tabs (#748).
const closeTabsBatch = useCallback(
async (targetIds: string[]) => {
if (targetIds.length === 0) return;
if (closeTabsInFlightRef.current) return;
// Expand workspace ids into their constituent session ids so the busy
// probe sees every local shell that's about to be killed.
const sessionIdsToProbe: string[] = [];
for (const tabId of targetIds) {
const ws = workspaces.find((w) => w.id === tabId);
if (ws) {
for (const s of sessions) {
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
}
} else if (sessions.find((s) => s.id === tabId)) {
sessionIdsToProbe.push(tabId);
}
}
closeTabsInFlightRef.current = true;
try {
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
if (!ok) return;
for (const tabId of targetIds) {
if (workspaces.find((w) => w.id === tabId)) {
closeWorkspace(tabId);
} else if (sessions.find((s) => s.id === tabId)) {
closeSession(tabId);
} else if (logViews.find((lv) => lv.id === tabId)) {
closeLogView(tabId);
}
}
} finally {
closeTabsInFlightRef.current = false;
}
},
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSession, closeLogView],
);
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
// doesn't land on a hidden tab (which would get redirected back) and so
// number shortcuts don't shift.
const allTabs = settings.showSftpTab
? ['vault', 'sftp', ...orderedTabs]
: ['vault', ...orderedTabs];
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
// Build complete tab list: vault + sftp + sessions/workspaces
const allTabs = ['vault', 'sftp', ...orderedTabs];
if (num <= allTabs.length) {
setActiveTabId(allTabs[num - 1]);
}
@@ -882,8 +1133,6 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
case 'nextTab': {
// Build complete tab list: vault + sftp + sessions/workspaces
const allTabs = ['vault', 'sftp', ...orderedTabs];
const currentId = activeTabStore.getActiveTabId();
const currentIdx = allTabs.indexOf(currentId);
if (currentIdx !== -1 && allTabs.length > 0) {
@@ -895,8 +1144,6 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
case 'prevTab': {
// Build complete tab list: vault + sftp + sessions/workspaces
const allTabs = ['vault', 'sftp', ...orderedTabs];
const currentId = activeTabStore.getActiveTabId();
const currentIdx = allTabs.indexOf(currentId);
if (currentIdx !== -1 && allTabs.length > 0) {
@@ -909,18 +1156,52 @@ function App({ settings }: { settings: SettingsState }) {
}
case 'closeTab': {
const currentId = activeTabStore.getActiveTabId();
if (currentId !== 'vault' && currentId !== 'sftp') {
// Find if it's a session or workspace
const session = sessions.find(s => s.id === currentId);
if (session) {
closeSession(currentId);
} else {
const workspace = workspaces.find(w => w.id === currentId);
if (workspace) {
closeWorkspace(currentId);
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
if (closeTabInFlightRef.current) break;
const session = sessions.find((s) => s.id === currentId) ?? null;
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
const activeSidePanel = activeSidePanelTabRef.current;
const intent = resolveCloseIntent({
activeTabId: currentId,
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
sessionForTab: session,
activeSidePanelTab: activeSidePanel,
focusIsInsideTerminal,
});
closeTabInFlightRef.current = true;
(async () => {
try {
switch (intent.kind) {
case 'closeTerminal':
case 'closeSingleTab': {
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
if (ok) closeSession(intent.sessionId);
return;
}
case 'closeSidePanel': {
closeSidePanelRef.current?.();
return;
}
case 'closeWorkspace': {
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
const ok = await confirmIfBusyLocalTerminal(ids);
if (ok) closeWorkspace(intent.workspaceId);
return;
}
case 'noop':
default:
return;
}
} finally {
closeTabInFlightRef.current = false;
}
}
})();
break;
}
case 'newTab':
@@ -943,7 +1224,9 @@ function App({ settings }: { settings: SettingsState }) {
setActiveTabId('vault');
break;
case 'openSftp':
setActiveTabId('sftp');
if (settings.showSftpTab) {
setActiveTabId('sftp');
}
break;
case 'quickSwitch':
case 'commandPalette':
@@ -969,32 +1252,32 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
case 'splitHorizontal': {
// Split current terminal horizontally (top/bottom)
const currentId = activeTabStore.getActiveTabId();
// Check if it's a standalone session or we're in a workspace
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
} else if (activeWs) {
// In a workspace - need to determine focused session
// For now, we'll need the terminal to handle this via context menu
if (IS_DEV) console.log('[Hotkey] Split horizontal in workspace - use context menu on specific terminal');
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
}
break;
}
case 'splitVertical': {
// Split current terminal vertically (left/right)
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSessionWithCurrentShell(activeSession.id, 'vertical');
} else if (activeWs) {
// In a workspace - need to determine focused session
if (IS_DEV) console.log('[Hotkey] Split vertical in workspace - use context menu on specific terminal');
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
}
break;
}
@@ -1031,7 +1314,7 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
}
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast]);
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
@@ -1285,10 +1568,24 @@ function App({ settings }: { settings: SettingsState }) {
}, [protocolSelectHost, handleConnectToHost]);
const handleToggleTheme = useCallback(() => {
// Toggle based on the actual rendered theme so clicking always produces a visible change,
// even when the stored preference is 'system'.
if (theme === 'system') {
toast.info(
t('topTabs.toggleTheme.systemExitMessage'),
{
title: t('topTabs.toggleTheme.systemExitTitle'),
actionLabel: t('topTabs.toggleTheme.openSettings'),
onClick: () => {
void (async () => {
const opened = await openSettingsWindow();
if (!opened) toast.error(t('toast.settingsUnavailable'), t('common.settings'));
})();
},
}
);
return;
}
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}, [resolvedTheme, setTheme]);
}, [openSettingsWindow, resolvedTheme, setTheme, t, theme]);
const handleOpenQuickSwitcher = useCallback(() => {
setIsQuickSwitcherOpen(true);
@@ -1362,6 +1659,7 @@ function App({ settings }: { settings: SettingsState }) {
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
followAppTerminalTheme={followAppTerminalTheme}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
@@ -1376,6 +1674,7 @@ function App({ settings }: { settings: SettingsState }) {
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
onCloseTabsBatch={closeTabsBatch}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
@@ -1384,6 +1683,7 @@ function App({ settings }: { settings: SettingsState }) {
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
showSftpTab={settings.showSftpTab}
/>
<div className="flex-1 relative min-h-0">
@@ -1429,6 +1729,8 @@ function App({ settings }: { settings: SettingsState }) {
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
onRunSnippet={runSnippet}
onOpenLogView={openLogView}
showRecentHosts={settings.showRecentHosts}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
/>
@@ -1463,6 +1765,7 @@ function App({ settings }: { settings: SettingsState }) {
knownHosts={knownHosts}
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
@@ -1503,6 +1806,8 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
closeSidePanelRef={closeSidePanelRef}
activeSidePanelTabRef={activeSidePanelTabRef}
/>
{/* Log Views - readonly terminal replays */}
@@ -1522,6 +1827,17 @@ function App({ settings }: { settings: SettingsState }) {
})}
</div>
{/* Global "quick add snippet" dialog, triggered by the
netcatty:snippets:add window event (from ScriptsSidePanel "+"). */}
<QuickAddSnippetDialog
snippets={snippets}
packages={snippetPackages}
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
onCreatePackage={(pkg) =>
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
}
/>
{isQuickSwitcherOpen && (
<Suspense fallback={null}>
<LazyQuickSwitcher
@@ -1530,6 +1846,7 @@ function App({ settings }: { settings: SettingsState }) {
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onSelectTab={(tabId) => {
@@ -1651,6 +1968,59 @@ function App({ settings }: { settings: SettingsState }) {
onCancel={handlePassphraseCancel}
onSkip={handlePassphraseSkip}
/>
{/* Empty vault vs cloud data confirmation dialog (#679).
This dialog intentionally cannot be dismissed — the user MUST
choose "Restore" or "Keep Empty" before the sync flow can
proceed. hideCloseButton removes the X button, onOpenChange
is a no-op so ESC also does nothing, and onInteractOutside
prevents click-away. */}
<Dialog open={!!emptyVaultConflict} onOpenChange={() => { /* intentionally non-dismissable */ }}>
<DialogContent className="max-w-md" hideCloseButton onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
{t('sync.autoSync.emptyVaultConflict.title')}
</DialogTitle>
<DialogDescription>
{t('sync.autoSync.emptyVaultConflict.description')}
</DialogDescription>
</DialogHeader>
{emptyVaultConflict && (
<div className="bg-muted/30 rounded-lg p-3 text-sm">
<div className="font-medium text-muted-foreground mb-1">{t('sync.autoSync.emptyVaultConflict.cloudLabel')}</div>
<div>{t('sync.autoSync.emptyVaultConflict.cloudSummary', {
hosts: emptyVaultConflict.hostCount,
keys: emptyVaultConflict.keyCount,
snippets: emptyVaultConflict.snippetCount,
})}</div>
</div>
)}
<DialogFooter className="flex-col gap-2 sm:flex-col">
<Button
onClick={() => resolveEmptyVaultConflict('restore')}
className="w-full justify-start gap-2"
>
<Download className="w-4 h-4" />
<span>
{t('sync.autoSync.emptyVaultConflict.restore')}
<span className="text-xs opacity-70 ml-1"> {t('sync.autoSync.emptyVaultConflict.restoreDesc')}</span>
</span>
</Button>
<Button
variant="outline"
onClick={() => resolveEmptyVaultConflict('keep-empty')}
className="w-full justify-start gap-2"
>
<Trash2 className="w-4 h-4" />
<span>
{t('sync.autoSync.emptyVaultConflict.keepEmpty')}
<span className="text-xs opacity-70 ml-1"> {t('sync.autoSync.emptyVaultConflict.keepEmptyDesc')}</span>
</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -56,6 +56,11 @@ const en: Messages = {
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'confirm.removeProvider': 'Remove provider "{name}"?',
'confirm.closeBusyTerminal.title': 'Confirm close',
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
'confirm.closeBusyTerminal.cancel': 'Cancel',
'confirm.closeBusyTerminal.close': 'Close',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
@@ -197,9 +202,15 @@ const en: Messages = {
'settings.application.github.subtitle': 'Source code',
'settings.application.whatsNew': "What's new",
'settings.application.whatsNew.subtitle': 'Show release notes',
'settings.application.openExternal.failedTitle': 'Cannot open link',
'settings.application.openExternal.failedBody': 'The link could not be opened in either the system browser or the built-in browser window.',
'settings.vault.title': 'Vault',
'settings.vault.showRecentHosts': 'Show recently connected hosts',
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.vault.showSftpTab': 'Show SFTP tab',
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
// Update notifications
'update.available.title': 'Update Available',
@@ -237,9 +248,9 @@ const en: Messages = {
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
@@ -251,6 +262,8 @@ const en: Messages = {
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
'settings.terminal.themeModal.lightThemes': 'Light Themes',
'settings.terminal.theme.selectButton': 'Select Theme',
'settings.terminal.theme.followApp': 'Follow Application Theme',
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
'settings.terminal.section.font': 'Font',
'settings.terminal.section.cursor': 'Cursor',
'settings.terminal.section.keyboard': 'Keyboard',
@@ -293,6 +306,12 @@ const en: Messages = {
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
@@ -435,13 +454,54 @@ const en: Messages = {
'sync.toast.completedMessage': 'Sync completed successfully',
'sync.toast.errorTitle': 'Sync Error',
'sync.autoSync.failedTitle': 'Sync failed',
'sync.autoSync.inspectFailedTitle': 'Sync paused',
'sync.autoSync.inspectFailedMessage': 'Could not reach the cloud to check for changes. Auto-sync will retry when data changes or the app is restarted.',
'sync.autoSync.syncedTitle': 'Synced from cloud',
'sync.autoSync.syncedMessage': 'Your data has been updated from the cloud.',
'sync.autoSync.noProvider': 'No cloud provider connected. Open Settings → Sync & Cloud to connect one.',
'sync.autoSync.alreadySyncing': 'Sync is already in progress.',
'sync.autoSync.restoreInProgress': 'A vault restore is in progress in another window. Please wait for it to finish.',
'sync.autoSync.interruptedApplyTitle': 'Sync paused — previous restore interrupted',
'sync.autoSync.interruptedApplyMessage': 'A previous restore did not finish cleanly, so the local vault may be inconsistent. Open Settings → Sync & Cloud → Restore and apply a protective backup before auto-sync resumes.',
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
'sync.autoSync.syncFailed': 'Sync failed',
'sync.autoSync.restoredTitle': 'Vault restored',
'sync.autoSync.restoredMessage': 'Your vault has been restored from the cloud.',
'sync.autoSync.keptLocalTitle': 'Kept local vault',
'sync.autoSync.keptLocalMessage': 'Your empty local vault was kept. Cloud data was not applied.',
'sync.autoSync.emptyVaultConflict.title': 'Empty Vault Detected',
'sync.autoSync.emptyVaultConflict.description': 'Your local vault is empty, but the cloud has data. This usually happens after an update or storage reset. What would you like to do?',
'sync.autoSync.emptyVaultConflict.cloudLabel': 'Cloud',
'sync.autoSync.emptyVaultConflict.restore': 'Restore from Cloud',
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
'sync.blocked.title': 'Sync paused',
'sync.blocked.reason.bulkShrink': 'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
'sync.blocked.reason.largeShrink': 'Would delete {lost} {entityType} from cloud.',
'sync.blocked.detail': 'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
'sync.blocked.restoreButton': 'Restore from local backup',
'sync.blocked.forcePushButton': 'Force push anyway',
'sync.forcePush.title': 'Confirm force push',
'sync.forcePush.body': 'You are about to remove {lost} {entityType} from the cloud. This cannot be undone. Proceed?',
'sync.forcePush.confirm': 'Yes, push anyway',
'sync.forcePush.cancel': 'Cancel',
'sync.entityType.hosts': 'hosts',
'sync.entityType.keys': 'keys',
'sync.entityType.identities': 'identities',
'sync.entityType.snippets': 'snippets',
'sync.entityType.customGroups': 'groups',
'sync.entityType.snippetPackages': 'snippet packages',
'sync.entityType.knownHosts': 'known-host entries',
'sync.entityType.portForwardingRules': 'port-forwarding rules',
'sync.entityType.groupConfigs': 'group configs',
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
'time.never': 'Never',
'time.justNow': 'Just now',
@@ -529,6 +589,7 @@ const en: Messages = {
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
@@ -983,6 +1044,14 @@ const en: Messages = {
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
'hostDetails.distro.option.juniper': 'Juniper Networks',
'hostDetails.distro.option.huawei': 'Huawei',
'hostDetails.distro.option.hpe': 'HPE / H3C',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': 'Fortinet',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': 'ZyXEL',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
@@ -1127,7 +1196,7 @@ const en: Messages = {
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
'terminal.toolbar.searchTerminal': 'Search terminal (Ctrl+F)',
'terminal.toolbar.searchTerminal': 'Search terminal',
'terminal.toolbar.search': 'Search',
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
@@ -1183,6 +1252,7 @@ const en: Messages = {
'terminal.search.nextMatch': 'Next match (Enter)',
'terminal.menu.copy': 'Copy',
'terminal.menu.paste': 'Paste',
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
@@ -1227,6 +1297,11 @@ const en: Messages = {
'terminal.themeModal.fontWeight': 'Font Weight',
'terminal.themeModal.livePreview': 'Live Preview',
'terminal.themeModal.themeType': '{type} theme',
'terminal.hiddenTheme.title': 'Current hidden theme',
'terminal.hiddenTheme.desc': 'This theme is hidden from manual picks and will be replaced when you choose another theme.',
'topTabs.toggleTheme.systemExitTitle': 'System theme is active',
'topTabs.toggleTheme.systemExitMessage': 'Open Settings to choose a fixed Light or Dark theme.',
'topTabs.toggleTheme.openSettings': 'Open Settings',
// Custom Themes
'terminal.customTheme.section': 'Custom Themes',
@@ -1356,6 +1431,47 @@ const en: Messages = {
'cloudSync.history.download': 'Download',
'cloudSync.history.resolved': 'Resolved',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
'cloudSync.localBackups.maxCount': 'Max backups',
'cloudSync.localBackups.maxSaved': 'Saved backup retention: {count}',
'cloudSync.localBackups.maxInvalid': 'Please enter a number between 1 and 100.',
'cloudSync.localBackups.empty': 'No local backups yet.',
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets',
'cloudSync.localBackups.restore': 'Restore',
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
'cloudSync.localBackups.restoreConfirmButton': 'Restore',
'cloudSync.localBackups.restoreConfirmCancel': 'Cancel',
'cloudSync.localBackups.unavailableTitle': 'Local backups unavailable',
'cloudSync.localBackups.unavailableDesc': 'This platform does not expose a secure keychain to Netcatty, so local backups cannot be written safely. Install Netcatty on a system with a supported keychain to enable the local backup history.',
'cloudSync.localBackups.lockedTitle': 'Master key required',
'cloudSync.localBackups.lockedDesc': 'Set up or unlock your master key before restoring a backup, so restored credentials remain encrypted.',
'cloudSync.revisionHistory.viewButton': 'History',
'cloudSync.revisionHistory.title': 'Vault Version History',
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
'cloudSync.revisionHistory.empty': 'No revisions found.',
'cloudSync.revisionHistory.current': 'Current',
'cloudSync.revisionHistory.revision': 'Revision',
'cloudSync.revisionHistory.revisionPreview': 'Revision Contents',
'cloudSync.revisionHistory.device': 'Device',
'cloudSync.revisionHistory.hosts': 'Hosts',
'cloudSync.revisionHistory.keys': 'Keys',
'cloudSync.revisionHistory.snippets': 'Snippets',
'cloudSync.revisionHistory.identities': 'Identities',
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
'cloudSync.revisionHistory.revisionNotFound': 'Revision not found or does not contain vault data.',
'cloudSync.revisionHistory.decryptFailed': 'Cannot decrypt this revision. It may have been encrypted with a different master password.',
'cloudSync.changeKey.title': 'Change Master Key',
'cloudSync.changeKey.current': 'Current Master Key',
'cloudSync.changeKey.new': 'New Master Key',
@@ -1523,6 +1639,9 @@ const en: Messages = {
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'tabs.closeOthers': 'Close Others',
'tabs.closeToRight': 'Close Tabs to the Right',
'tabs.closeAll': 'Close All',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': 'Key label',
'keychain.edit.privateKeyRequired': 'Private key *',
@@ -1643,10 +1762,7 @@ const en: Messages = {
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.fill': 'Fill',
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
'keyboard.interactive.savePassword': 'Save password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',
@@ -1697,12 +1813,16 @@ const en: Messages = {
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
'ai.codex.detecting': 'Detecting...',
'ai.codex.notFound': 'Not found',
'ai.codex.awaitingLogin': 'Awaiting login',
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
'ai.codex.connectedApiKey': 'Connected via API key',
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
'ai.codex.notConnected': 'Not connected',
'ai.codex.statusUnknown': 'Status unknown',
'ai.codex.path': 'Path:',
@@ -1713,7 +1833,6 @@ const en: Messages = {
'ai.codex.logout': 'Logout',
'ai.codex.connectChatGPT': 'Connect ChatGPT',
'ai.codex.refreshStatus': 'Refresh Status',
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
// AI Claude Code
'ai.claude.title': 'Claude Code',
@@ -1741,6 +1860,22 @@ const en: Messages = {
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
'ai.defaultAgent.catty': 'Catty (Built-in)',
'ai.toolAccess.title': 'Tool Access',
'ai.toolAccess.mode': 'Netcatty Access Mode',
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': 'User Skills',
'ai.userSkills.description': 'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
'ai.userSkills.openFolder': 'Open Skills Folder',
'ai.userSkills.reload': 'Reload Skills',
'ai.userSkills.location': 'Location',
'ai.userSkills.loading': 'Scanning user skills...',
'ai.userSkills.summary': '{ready} ready, {warnings} warnings',
'ai.userSkills.empty': 'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
'ai.userSkills.unavailable': 'User skills are unavailable in this environment.',
'ai.userSkills.status.ready': 'Ready',
'ai.userSkills.status.warning': 'Warning',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
@@ -1795,6 +1930,7 @@ const en: Messages = {
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuUserSkills': 'User Skills',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
@@ -1817,7 +1953,7 @@ const en: Messages = {
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations through Netcatty, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
@@ -1827,7 +1963,7 @@ const en: Messages = {
'ai.safety.maxIterations': 'Max Iterations',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist': 'Command Blocklist',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
'ai.safety.blocklist.placeholder': 'Regex pattern...',
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',

View File

@@ -43,6 +43,11 @@ const zhCN: Messages = {
'confirm.deleteHost': '删除主机 "{name}"',
'confirm.deleteIdentity': '删除身份 "{name}"',
'confirm.removeProvider': '移除提供商 "{name}"',
'confirm.closeBusyTerminal.title': '确认关闭',
'confirm.closeBusyTerminal.message': '进程 "{command}" 仍在运行,关闭后会被终止。',
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
'confirm.closeBusyTerminal.cancel': '取消',
'confirm.closeBusyTerminal.close': '关闭',
'dialog.renameWorkspace.title': '重命名工作区',
'dialog.renameSession.title': '重命名会话',
'field.name': '名称',
@@ -181,9 +186,15 @@ const zhCN: Messages = {
'settings.application.github.subtitle': '源代码',
'settings.application.whatsNew': '更新内容',
'settings.application.whatsNew.subtitle': '查看发布说明',
'settings.application.openExternal.failedTitle': '无法打开链接',
'settings.application.openExternal.failedBody': '系统浏览器和内置浏览器窗口都无法打开该链接。',
'settings.vault.title': '主机库',
'settings.vault.showRecentHosts': '显示最近连接的主机',
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
'settings.vault.showSftpTab': '显示 SFTP 标签页',
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
// Update notifications
'update.available.title': '发现新版本',
@@ -220,9 +231,10 @@ const zhCN: Messages = {
'settings.appearance.themeColor.light': '浅色主题',
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
'settings.appearance.customCss.placeholder':
'/* 示例*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
@@ -255,13 +267,54 @@ const zhCN: Messages = {
'sync.toast.completedMessage': '同步完成',
'sync.toast.errorTitle': '同步错误',
'sync.autoSync.failedTitle': '同步失败',
'sync.autoSync.inspectFailedTitle': '同步已暂停',
'sync.autoSync.inspectFailedMessage': '无法访问云端以检查变更。数据改动或下次启动时会自动重试。',
'sync.autoSync.syncedTitle': '已从云端同步',
'sync.autoSync.syncedMessage': '你的数据已从云端更新。',
'sync.autoSync.noProvider': '未连接云同步 provider。请打开 设置 → Sync & Cloud 进行连接。',
'sync.autoSync.alreadySyncing': '同步正在进行中。',
'sync.autoSync.restoreInProgress': '另一个窗口中的本地备份恢复正在进行中,请等待其完成。',
'sync.autoSync.interruptedApplyTitle': '同步已暂停 — 上次恢复未完成',
'sync.autoSync.interruptedApplyMessage': '上次本地恢复过程未正常结束,本地数据可能处于半应用状态。请打开「设置 → Sync & Cloud → 恢复」,从保护性备份中恢复后再让自动同步继续。',
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
'sync.autoSync.syncFailed': '同步失败',
'sync.autoSync.restoredTitle': '已恢复',
'sync.autoSync.restoredMessage': '已从云端恢复主机库数据。',
'sync.autoSync.keptLocalTitle': '已保留本地数据',
'sync.autoSync.keptLocalMessage': '保留了空的本地主机库,未应用云端数据。',
'sync.autoSync.emptyVaultConflict.title': '检测到空主机库',
'sync.autoSync.emptyVaultConflict.description': '本地主机库为空,但云端有数据。这通常发生在应用更新或存储重置之后。请选择如何处理:',
'sync.autoSync.emptyVaultConflict.cloudLabel': '云端',
'sync.autoSync.emptyVaultConflict.restore': '从云端恢复',
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
'sync.blocked.title': '同步已暂停',
'sync.blocked.reason.bulkShrink': '即将从云端删除 {baseCount} 条 {entityType} 中的 {lost} 条(缩减 {percent}%)。',
'sync.blocked.reason.largeShrink': '即将从云端删除 {lost} 条 {entityType}。',
'sync.blocked.detail': '通常是本地状态异常(钥匙串故障、数据加载不全)导致。请从本地备份恢复,如果确实要删这些条目请使用强制推送。',
'sync.blocked.restoreButton': '从本地备份恢复',
'sync.blocked.forcePushButton': '强制推送',
'sync.forcePush.title': '确认强制推送',
'sync.forcePush.body': '你将从云端移除 {lost} 条 {entityType},此操作不可撤销。继续?',
'sync.forcePush.confirm': '确认推送',
'sync.forcePush.cancel': '取消',
'sync.entityType.hosts': '主机',
'sync.entityType.keys': '密钥',
'sync.entityType.identities': '身份',
'sync.entityType.snippets': '代码片段',
'sync.entityType.customGroups': '分组',
'sync.entityType.snippetPackages': '片段包',
'sync.entityType.knownHosts': '主机密钥记录',
'sync.entityType.portForwardingRules': '端口转发规则',
'sync.entityType.groupConfigs': '分组配置',
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
'time.never': '从未',
'time.justNow': '刚刚',
@@ -349,6 +402,7 @@ const zhCN: Messages = {
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
@@ -626,6 +680,14 @@ const zhCN: Messages = {
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': '思科',
'hostDetails.distro.option.juniper': '瞻博网络',
'hostDetails.distro.option.huawei': '华为',
'hostDetails.distro.option.hpe': '慧与 / H3C',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': '飞塔',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': '合勤',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
@@ -741,7 +803,7 @@ const zhCN: Messages = {
'terminal.toolbar.library': '库',
'terminal.toolbar.noSnippets': '暂无代码片段',
'terminal.toolbar.terminalSettings': '终端设置',
'terminal.toolbar.searchTerminal': '搜索终端 (Ctrl+F)',
'terminal.toolbar.searchTerminal': '搜索终端',
'terminal.toolbar.search': '搜索',
'terminal.toolbar.broadcast': '广播',
'terminal.toolbar.broadcastEnable': '启用广播模式',
@@ -797,6 +859,7 @@ const zhCN: Messages = {
'terminal.search.nextMatch': '下一个匹配 (Enter)',
'terminal.menu.copy': '复制',
'terminal.menu.paste': '粘贴',
'terminal.menu.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
@@ -842,6 +905,11 @@ const zhCN: Messages = {
'terminal.themeModal.fontWeight': '字体粗细',
'terminal.themeModal.livePreview': '实时预览',
'terminal.themeModal.themeType': '{type} 主题',
'terminal.hiddenTheme.title': '当前隐藏主题',
'terminal.hiddenTheme.desc': '这个主题已从手动选择列表中隐藏;当你选择其他可见主题后,它会被替换。',
'topTabs.toggleTheme.systemExitTitle': '当前正在跟随系统主题',
'topTabs.toggleTheme.systemExitMessage': '请到设置里选择固定的浅色或深色主题。',
'topTabs.toggleTheme.openSettings': '打开设置',
// Custom Themes
'terminal.customTheme.section': '自定义主题',
@@ -970,6 +1038,47 @@ const zhCN: Messages = {
'cloudSync.history.download': '下载',
'cloudSync.history.resolved': '已解决',
'cloudSync.history.error': '错误',
'cloudSync.localBackups.title': '本地备份历史',
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
'cloudSync.localBackups.retentionTitle': '备份保留数量',
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
'cloudSync.localBackups.maxCount': '最多保留',
'cloudSync.localBackups.maxSaved': '已保存保留数量:{count}',
'cloudSync.localBackups.maxInvalid': '请输入 1 到 100 之间的数字。',
'cloudSync.localBackups.empty': '还没有本地备份。',
'cloudSync.localBackups.reason.appVersionChange': '版本变化前',
'cloudSync.localBackups.reason.beforeRestore': '恢复前',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
'cloudSync.localBackups.restore': '恢复',
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
'cloudSync.localBackups.restoreConfirmButton': '恢复',
'cloudSync.localBackups.restoreConfirmCancel': '取消',
'cloudSync.localBackups.unavailableTitle': '无法使用本地备份',
'cloudSync.localBackups.unavailableDesc': '当前平台未提供受支持的安全密钥库Netcatty 无法安全地写入本地备份。请在支持系统钥匙串的环境中运行,或改用云同步保留恢复点。',
'cloudSync.localBackups.lockedTitle': '需要主密钥',
'cloudSync.localBackups.lockedDesc': '请先配置或解锁主密钥再恢复备份,以确保恢复后的凭据仍保持加密。',
'cloudSync.revisionHistory.viewButton': '历史版本',
'cloudSync.revisionHistory.title': '主机库版本历史',
'cloudSync.revisionHistory.description': '浏览并恢复 Gist 修订历史中的旧版主机库数据。',
'cloudSync.revisionHistory.empty': '未找到修订记录。',
'cloudSync.revisionHistory.current': '当前版本',
'cloudSync.revisionHistory.revision': '修订',
'cloudSync.revisionHistory.revisionPreview': '修订内容',
'cloudSync.revisionHistory.device': '设备',
'cloudSync.revisionHistory.hosts': '主机',
'cloudSync.revisionHistory.keys': '密钥',
'cloudSync.revisionHistory.snippets': '代码片段',
'cloudSync.revisionHistory.identities': '身份',
'cloudSync.revisionHistory.restoreButton': '恢复此版本',
'cloudSync.revisionHistory.restored': '已从选中的修订恢复主机库数据。',
'cloudSync.revisionHistory.revisionNotFound': '修订未找到或不包含主机库数据。',
'cloudSync.revisionHistory.decryptFailed': '无法解密此修订。可能是使用了不同的主密钥加密的。',
'cloudSync.changeKey.title': '更改主密钥',
'cloudSync.changeKey.current': '当前主密钥',
'cloudSync.changeKey.new': '新的主密钥',
@@ -1239,6 +1348,8 @@ const zhCN: Messages = {
'settings.terminal.themeModal.darkThemes': '深色主题',
'settings.terminal.themeModal.lightThemes': '浅色主题',
'settings.terminal.theme.selectButton': '选择主题',
'settings.terminal.theme.followApp': '跟随应用主题',
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
'settings.terminal.section.font': '字体',
'settings.terminal.section.cursor': '光标',
'settings.terminal.section.keyboard': '键盘',
@@ -1278,6 +1389,12 @@ const zhCN: Messages = {
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
'settings.terminal.behavior.clearWipesScrollback.desc':
'`clear` 命令同时清空回滚历史POSIX 默认行为)。关闭则保留历史。',
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
@@ -1530,6 +1647,9 @@ const zhCN: Messages = {
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'tabs.closeOthers': '关闭其他标签',
'tabs.closeToRight': '关闭右侧标签',
'tabs.closeAll': '关闭所有标签',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
'keychain.edit.privateKeyRequired': '私钥 *',
@@ -1650,10 +1770,7 @@ const zhCN: Messages = {
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.fill': '填入',
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
'keyboard.interactive.savePassword': '保存密码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',
@@ -1704,12 +1821,16 @@ const zhCN: Messages = {
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。',
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
'ai.codex.detecting': '检测中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登录',
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
'ai.codex.connectedApiKey': '已通过 API Key 连接',
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty否则 Codex 无法鉴权。',
'ai.codex.notConnected': '未连接',
'ai.codex.statusUnknown': '状态未知',
'ai.codex.path': '路径:',
@@ -1720,7 +1841,6 @@ const zhCN: Messages = {
'ai.codex.logout': '退出登录',
'ai.codex.connectChatGPT': '连接 ChatGPT',
'ai.codex.refreshStatus': '刷新状态',
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
// AI Claude Code
'ai.claude.title': 'Claude Code',
@@ -1748,6 +1868,22 @@ const zhCN: Messages = {
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
'ai.defaultAgent.catty': 'Catty内置',
'ai.toolAccess.title': '工具接入',
'ai.toolAccess.mode': 'Netcatty 接入模式',
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': '用户 Skills',
'ai.userSkills.description': '打开 Netcatty 的 Skills 文件夹以添加你自己的技能目录。Netcatty 会自动扫描这些 skills默认只注入轻量索引只有在请求明显命中某个 skill 时才展开正文。',
'ai.userSkills.openFolder': '打开 Skills 文件夹',
'ai.userSkills.reload': '重新加载 Skills',
'ai.userSkills.location': '位置',
'ai.userSkills.loading': '正在扫描用户 skills...',
'ai.userSkills.summary': '已就绪 {ready} 个,警告 {warnings} 个',
'ai.userSkills.empty': '暂未发现用户 skills。打开文件夹后可添加包含 SKILL.md 的技能目录。',
'ai.userSkills.unavailable': '当前环境不支持用户 skills。',
'ai.userSkills.status.ready': '正常',
'ai.userSkills.status.warning': '警告',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
@@ -1802,6 +1938,7 @@ const zhCN: Messages = {
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuUserSkills': '用户 Skills',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
@@ -1824,7 +1961,7 @@ const zhCN: Messages = {
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '权限模式',
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性ACP Agent 有自己的工具审批流程)。',
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性ACP Agent 有自己的工具审批流程)。',
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
@@ -1834,7 +1971,7 @@ const zhCN: Messages = {
'ai.safety.maxIterations': '最大迭代次数',
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
'ai.safety.blocklist': '命令黑名单',
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
'ai.safety.blocklist.placeholder': '正则表达式...',
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',

View File

@@ -0,0 +1,495 @@
import type { SyncPayload } from '../domain/sync';
import {
STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION,
STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT,
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { hasMeaningfulSyncData } from './syncPayload';
/**
* Snapshot the current sync data version (the integer that increments
* on each successful cloud sync). Returns undefined when the value is
* 0 (never synced) or unavailable, so the UI can fall back to timestamp.
*/
function captureCurrentSyncDataVersion(): number | undefined {
try {
const state = getCloudSyncManager().getState();
const v = state.localVersion;
return typeof v === 'number' && v > 0 ? v : undefined;
} catch {
return undefined;
}
}
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
export interface LocalVaultBackupPreview {
id: string;
createdAt: number;
reason: LocalVaultBackupReason;
/** Sync-data version at the time the snapshot was taken (the integer
* that the CloudSyncManager increments on each successful cloud sync).
* Undefined when the user had never synced yet, or for legacy backups
* persisted before this field was added. */
syncDataVersion?: number;
/** App version transition fields, only for `app_version_change` records.
* Kept for backward compatibility with already-persisted backups. */
sourceAppVersion?: string;
targetAppVersion?: string;
fingerprint: string;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
}
export interface LocalVaultBackupDetails {
backup: LocalVaultBackupPreview;
payload: SyncPayload;
}
export const DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT = 20;
export const MIN_LOCAL_VAULT_BACKUP_MAX_COUNT = 1;
export const MAX_LOCAL_VAULT_BACKUP_MAX_COUNT = 100;
export const sanitizeLocalVaultBackupMaxCount = (value: number): number => {
if (!Number.isFinite(value)) return DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT;
return Math.max(
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
Math.min(MAX_LOCAL_VAULT_BACKUP_MAX_COUNT, Math.round(value)),
);
};
export const getLocalVaultBackupMaxCount = (): number => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT);
return sanitizeLocalVaultBackupMaxCount(
stored ?? DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT,
);
};
export const setLocalVaultBackupMaxCount = (value: number): number => {
const sanitized = sanitizeLocalVaultBackupMaxCount(value);
localStorageAdapter.writeNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT, sanitized);
return sanitized;
};
export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCount()): Promise<void> {
const bridge = netcattyBridge.get();
await bridge?.trimVaultBackups?.({ maxCount });
}
export async function getLocalVaultBackupCapabilities(): Promise<{
encryptionAvailable: boolean;
}> {
const bridge = netcattyBridge.get();
const caps = await bridge?.getVaultBackupCapabilities?.();
// Conservatively treat a missing bridge (non-Electron environments, early
// boot) as unavailable so callers fall back to the locked-down UI path
// instead of assuming capabilities they can't verify.
return { encryptionAvailable: Boolean(caps?.encryptionAvailable) };
}
export async function listLocalVaultBackups(): Promise<LocalVaultBackupPreview[]> {
const bridge = netcattyBridge.get();
const entries = await bridge?.listVaultBackups?.();
return Array.isArray(entries) ? entries : [];
}
export async function readLocalVaultBackup(id: string): Promise<LocalVaultBackupDetails | null> {
const bridge = netcattyBridge.get();
if (!bridge?.readVaultBackup) return null;
return bridge.readVaultBackup({ id });
}
export async function openLocalVaultBackupDir(): Promise<void> {
const bridge = netcattyBridge.get();
await bridge?.openVaultBackupDir?.();
}
export async function createLocalVaultBackup(
payload: SyncPayload,
options: {
reason: LocalVaultBackupReason;
syncDataVersion?: number;
sourceAppVersion?: string;
targetAppVersion?: string;
maxCount?: number;
},
): Promise<LocalVaultBackupPreview | null> {
// Intentional: an empty-vault backup has nothing to restore from, so we
// early-return instead of writing a zero-entry record. Callers that rely
// on a backup (protective-before-restore, version-change on first run)
// must treat `null` as "no safety net this time" and continue — blocking
// the user's flow on a missing backup would be worse than allowing the
// apply to proceed without one.
if (!hasMeaningfulSyncData(payload)) {
return null;
}
const bridge = netcattyBridge.get();
if (!bridge?.createVaultBackup) {
return null;
}
try {
const result = await bridge.createVaultBackup({
payload,
reason: options.reason,
// Default to the live cloud-sync version so every new backup carries
// it even when the caller didn't pass one explicitly. Bridge sanitizer
// drops invalid values (non-positive / non-finite), so this is safe.
syncDataVersion: options.syncDataVersion ?? captureCurrentSyncDataVersion(),
sourceAppVersion: options.sourceAppVersion,
targetAppVersion: options.targetAppVersion,
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),
});
return result?.backup ?? null;
} catch (error) {
// The main-process bridge refuses to write backups when safeStorage is
// unavailable (VAULT_BACKUP_ENCRYPTION_UNAVAILABLE) because SyncPayload
// carries plaintext credentials that must never touch disk unencrypted.
// Callers (startup version-change, protective-before-restore) intentionally
// continue without a backup rather than blocking the user's flow, so we
// log and return null here.
const message = error instanceof Error ? error.message : String(error);
console.warn('[localVaultBackups] Backup skipped:', message);
return null;
}
}
/**
* Thrown when a caller requires a protective backup and the backup
* couldn't be written — safeStorage unavailable, bridge missing,
* main-process rejection, disk error.
*
* Callers should surface this as a user-visible abort rather than
* proceeding with the destructive apply. Separate from "nothing to
* back up" (empty vault) which is returned as `null`.
*/
export class ProtectiveBackupUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProtectiveBackupUnavailableError';
}
}
/**
* Create a protective local backup before a destructive apply (restore
* from backup list, restore from Gist revision, cloud download applied
* over meaningful local state).
*
* Returns `null` when there is nothing meaningful to back up — in that
* case the caller can safely proceed with the apply, because there is
* no local data to lose.
*
* Throws `ProtectiveBackupUnavailableError` when pre-apply state IS
* meaningful but the backup attempt failed. Callers MUST abort the
* destructive apply in that case and surface the error to the user,
* otherwise we regress the exact safety contract the backup system
* was added to enforce (the `console.error`-and-proceed pattern that
* previously swallowed safeStorage/keychain failures and continued).
*/
export async function createRequiredProtectiveLocalVaultBackup(
payload: SyncPayload,
): Promise<LocalVaultBackupPreview | null> {
if (!hasMeaningfulSyncData(payload)) {
// Nothing to protect — an empty-vault backup would produce a
// useless record, not a safety net.
return null;
}
const bridge = netcattyBridge.get();
if (!bridge?.createVaultBackup) {
throw new ProtectiveBackupUnavailableError(
'Vault backup bridge is not available in this environment.',
);
}
try {
const result = await bridge.createVaultBackup({
payload,
reason: 'before_restore',
maxCount: getLocalVaultBackupMaxCount(),
});
return result?.backup ?? null;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ProtectiveBackupUnavailableError(message);
}
}
/**
* How long each heartbeat extends the cross-window restore barrier.
* Short enough that an abandoned lock (crashed window, hung task)
* clears itself quickly without user intervention. The heartbeat
* interval below refreshes the deadline as long as the caller's task
* is still running, so large vaults or slow keychain unlocks cannot
* expose a mid-apply window to concurrent auto-sync even when the
* total apply time exceeds this value.
*/
const RESTORE_BARRIER_HOLD_MS = 60_000;
/**
* How often the heartbeat refreshes the barrier. Picked to ensure at
* least two refreshes land before the current deadline would expire,
* so a single missed tick (event-loop stall, GC pause) cannot drop
* the barrier prematurely.
*/
const RESTORE_BARRIER_HEARTBEAT_MS = Math.max(1_000, Math.floor(RESTORE_BARRIER_HOLD_MS / 3));
/**
* Run `task` while holding a cross-window "restore in progress" barrier.
*
* The barrier is a localStorage key readable by every window of the same
* origin. useAutoSync reads it on each auto-sync and on each data-change
* debounce tick, refusing to push while the deadline is still in the
* future. We write a time-bounded deadline (rather than a boolean) so a
* crashed window can never leave sync permanently wedged.
*
* While the task runs, a heartbeat timer re-writes the deadline so a
* slow apply (large vault, slow keychain) keeps the barrier held rather
* than exposing a post-deadline window to concurrent auto-sync. The
* heartbeat is cleared and the barrier is released in a finally block
* so success, throw, and unexpected early-return all converge on the
* same cleanup.
*/
export async function withRestoreBarrier<T>(
task: () => Promise<T>,
holdMs: number = RESTORE_BARRIER_HOLD_MS,
): Promise<T> {
const writeDeadline = () => {
try {
localStorageAdapter.writeNumber(
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
Date.now() + holdMs,
);
} catch (error) {
// If we can't write the barrier we still proceed — the UI-side
// `isSyncBusy` guard and same-window debounce cancellation are a
// secondary defense. Better to complete the restore than refuse on
// a broken localStorage.
console.warn('[localVaultBackups] Failed to set restore barrier:', error);
}
};
writeDeadline();
const heartbeat = setInterval(
writeDeadline,
Math.max(1_000, Math.min(holdMs / 3, RESTORE_BARRIER_HEARTBEAT_MS)),
);
try {
return await task();
} finally {
clearInterval(heartbeat);
try {
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
} catch {
/* ignore — the deadline will expire naturally */
}
}
}
/**
* Shape of the apply-in-progress sentinel record. Persisted as JSON in
* `STORAGE_KEY_VAULT_APPLY_IN_PROGRESS` so the next session can
* distinguish "the last apply completed cleanly" from "the last apply
* crashed mid-way and the local vault is a partial mix of states."
*/
export interface VaultApplyInProgressRecord {
startedAt: number;
protectiveBackupId: string | null;
}
/**
* Returns the persisted apply-in-progress record if a previous apply
* was interrupted before clearing it. Callers (notably auto-sync) use
* this to refuse to push a partial-apply local state over an intact
* cloud copy. See `applyProtectedSyncPayload` for the write side.
*
* `null` here means "no interrupted apply detected" — either nothing
* was ever applied, or the last apply finished cleanly.
*/
export function readInterruptedVaultApply(): VaultApplyInProgressRecord | null {
try {
const raw = localStorageAdapter.readString(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;
const protectiveBackupId =
typeof parsed.protectiveBackupId === 'string' ? parsed.protectiveBackupId : null;
if (!startedAt) return null;
return { startedAt, protectiveBackupId };
} catch {
return null;
}
}
/**
* Clears the apply-in-progress sentinel. The normal completion path
* inside `applyProtectedSyncPayload` clears it automatically; this
* export exists so the user's explicit recovery action ("I've restored
* from a backup, resume sync") can acknowledge the interrupted state
* from the UI without re-running an apply.
*/
export function clearInterruptedVaultApply(): void {
try {
localStorageAdapter.remove(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
} catch {
/* ignore — next clean apply will overwrite */
}
}
function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void {
try {
localStorageAdapter.writeString(
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
JSON.stringify(record),
);
} catch (error) {
// Sentinel write is best-effort: a failure here means a later crash
// won't be detected, but does NOT compromise the apply itself.
// Log so a systematic storage outage is diagnosable.
console.warn('[localVaultBackups] Failed to set apply-in-progress sentinel:', error);
}
}
/**
* Shared "apply a remote-sourced payload safely" helper.
*
* Holds the cross-window restore barrier, snapshots the pre-apply vault
* into a protective backup, persists an apply-in-progress sentinel, and
* only then runs the supplied `applyPayload` callback. Every destructive
* apply path (startup merge, conflict resolution, empty-vault restore,
* manual Gist-revision restore) must go through this so the protections
* can't drift out of sync between the main window and the settings
* window.
*
* The sentinel closes the partial-apply-then-crash window: `applyPayload`
* writes to several localStorage keys non-atomically (hosts, keys, port-
* forwarding rules, settings). A crash mid-sequence leaves the vault in
* a state that is neither pre-apply nor post-apply, and the next
* auto-sync would otherwise push that partial state over an intact cloud
* copy. The sentinel flags "local may be inconsistent" for the next
* session; `readInterruptedVaultApply` exposes that to callers that
* enforce "don't auto-push a half-applied vault."
*
* `buildPreApplyPayload` is invoked *before* the apply to snapshot the
* current vault. Callers pass their own React-closure builder (hosts,
* keys, port-forwarding rules) because the caller owns that state.
*
* `translateProtectiveBackupFailure` converts the
* `ProtectiveBackupUnavailableError` into a user-visible message in the
* caller's locale. It runs only on the thrown-and-caught path.
*/
export function applyProtectedSyncPayload(options: {
buildPreApplyPayload: () => SyncPayload;
applyPayload: () => void | Promise<void>;
translateProtectiveBackupFailure: (message: string) => string;
}): Promise<void> {
const { buildPreApplyPayload, applyPayload, translateProtectiveBackupFailure } = options;
return withRestoreBarrier(async () => {
const pre = buildPreApplyPayload();
let protectiveBackupId: string | null = null;
try {
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
protectiveBackupId = backup?.id ?? null;
} catch (error) {
// Destructive apply without a working safety net is exactly the
// overwrite-without-recovery regression this module was added to
// prevent. Surface the failure to the caller; every call site
// currently aborts the apply and shows a user-visible error.
if (error instanceof ProtectiveBackupUnavailableError) {
throw new Error(translateProtectiveBackupFailure(error.message));
}
throw error;
}
// Mark the apply as in-progress. If the renderer crashes between
// the first localStorage write inside `applyPayload` and the
// successful completion below, the next session will observe this
// sentinel and refuse to auto-sync the partial state.
writeApplyInProgressSentinel({
startedAt: Date.now(),
protectiveBackupId,
});
// Only clear the sentinel on successful completion. A throw from
// `applyPayload` deliberately leaves the sentinel set: the partial
// write is still on disk, and the next session must observe the
// flag so auto-sync refuses to push the half-applied state.
await applyPayload();
clearInterruptedVaultApply();
});
}
export async function ensureVersionChangeBackup(
payload: SyncPayload,
currentAppVersion: string | null | undefined,
): Promise<{ created: boolean; backup: LocalVaultBackupPreview | null }> {
const normalizedVersion = currentAppVersion?.trim() || '';
if (!normalizedVersion) {
return { created: false, backup: null };
}
const previousVersion =
localStorageAdapter.readString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION)?.trim() || '';
if (!previousVersion) {
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
return { created: false, backup: null };
}
if (previousVersion === normalizedVersion) {
return { created: false, backup: null };
}
let backup: LocalVaultBackupPreview | null = null;
const payloadIsMeaningful = hasMeaningfulSyncData(payload);
if (payloadIsMeaningful) {
backup = await createLocalVaultBackup(payload, {
reason: 'app_version_change',
sourceAppVersion: previousVersion,
targetAppVersion: normalizedVersion,
});
}
// Only advance the stored version stamp when we actually wrote a
// backup. Two failure modes we must NOT collapse into "advance":
//
// 1. Meaningful payload + backup failed (transient keychain lock,
// disk error) — leaving the stamp unchanged means the next
// launch retries, instead of turning a transient error into a
// permanent "the version-change backup never happened" hole.
//
// 2. Non-meaningful payload at the moment we checked — on startup
// the async vault rehydrate may not have finished yet, so
// `hasMeaningfulSyncData` can return false transiently even
// though the user has real data. Advancing in that window would
// burn the one-shot upgrade opportunity; holding keeps the
// retry available on the next launch when rehydrate has
// completed (or when the user genuinely starts from empty and
// the next migration-boundary arrives).
//
// Trade-off: a user who truly starts empty and never adds data will
// hit this branch on every launch until they do. That's cheap (a
// single meaningful-data check) and strictly safer than silently
// skipping the first real upgrade backup.
const shouldAdvanceVersion = payloadIsMeaningful && backup !== null;
if (shouldAdvanceVersion) {
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
}
return {
created: Boolean(backup),
backup,
};
}

View File

@@ -0,0 +1,349 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
activateDraftView,
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
clearScopeDraftState,
createEmptyDraft,
ensureDraftForScopeState,
getDraftMutationVersionState,
getDraftUploadGenerationState,
pruneTerminalScopeState,
pruneTerminalTransientState,
resolvePanelView,
selectDraftForAgentSwitch,
setDraftView,
setSessionView,
updateDraftForScope,
} from "./aiDraftState.ts";
test("createEmptyDraft seeds selected agent and empty inputs", () => {
const draft = createEmptyDraft("agent-alpha");
assert.equal(draft.agentId, "agent-alpha");
assert.equal(draft.text, "");
assert.deepEqual(draft.attachments, []);
assert.deepEqual(draft.selectedUserSkillSlugs, []);
assert.equal(typeof draft.updatedAt, "number");
});
test("resolvePanelView defaults to draft when no explicit view exists", () => {
assert.deepEqual(resolvePanelView({}, "terminal:123"), { mode: "draft" });
});
test("setDraftView records draft mode", () => {
assert.deepEqual(setDraftView({}, "terminal:123"), {
"terminal:123": { mode: "draft" },
});
});
test("activateDraftView clears the terminal scope's active session owner", () => {
const activeSessionIdMap = {
"terminal:123": "session-123",
"workspace:abc": "session-workspace",
};
const panelViewByScope = {
"terminal:123": { mode: "session", sessionId: "session-123" },
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = activateDraftView(
activeSessionIdMap,
panelViewByScope,
"terminal:123",
);
assert.deepEqual(next.activeSessionIdMap, {
"workspace:abc": "session-workspace",
});
assert.deepEqual(next.panelViewByScope, {
"terminal:123": { mode: "draft" },
"workspace:abc": panelViewByScope["workspace:abc"],
});
});
test("activateDraftView is a no-op when the scope already has explicit draft view", () => {
const activeSessionIdMap = {
"workspace:abc": "session-workspace",
};
const panelViewByScope = {
"terminal:123": { mode: "draft" },
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = activateDraftView(
activeSessionIdMap,
panelViewByScope,
"terminal:123",
);
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("setSessionView records target session id", () => {
assert.deepEqual(setSessionView({}, "workspace:abc", "session-123"), {
"workspace:abc": { mode: "session", sessionId: "session-123" },
});
});
test("clearScopeDraftState removes both the draft and current panel view", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),
"workspace:2": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "session-123" },
"workspace:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:1");
assert.deepEqual(next.draftsByScope, {
"workspace:2": draftsByScope["workspace:2"],
});
assert.deepEqual(next.panelViewByScope, {
"workspace:2": panelViewByScope["workspace:2"],
});
});
test("clearScopeDraftState is a no-op when the scope is already cleared", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"workspace:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:closed");
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("updateDraftForScope creates a draft on first write and keeps other scopes untouched", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const next = updateDraftForScope(
draftsByScope,
"terminal:1",
"agent-alpha",
(draft) => ({
...draft,
text: "hello world",
}),
);
assert.equal(next["terminal:1"].agentId, "agent-alpha");
assert.equal(next["terminal:1"].text, "hello world");
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
});
test("ensureDraftForScopeState adds the missing scope without dropping siblings", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const next = ensureDraftForScopeState(
draftsByScope,
"terminal:1",
"agent-alpha",
);
assert.equal(next["terminal:1"].agentId, "agent-alpha");
assert.equal(next["terminal:1"].text, "");
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
});
test("ensureDraftForScopeState returns the original ref when the scope already exists", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),
};
const next = ensureDraftForScopeState(
draftsByScope,
"terminal:1",
"agent-beta",
);
assert.equal(next, draftsByScope);
});
test("selectDraftForAgentSwitch preserves hidden draft content when leaving a populated chat session", () => {
const currentDraft = {
...createEmptyDraft("agent-alpha"),
text: "keep me only if I was already drafting",
attachments: [{ id: "file-1", filename: "note.txt", dataUrl: "", base64Data: "", mediaType: "text/plain" }],
selectedUserSkillSlugs: ["skill-a"],
};
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "keep me only if I was already drafting");
assert.deepEqual(next.attachments, currentDraft.attachments);
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
});
test("selectDraftForAgentSwitch resets to an empty draft when leaving a populated chat session without pending draft content", () => {
const currentDraft = createEmptyDraft("agent-alpha");
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "");
assert.deepEqual(next.attachments, []);
assert.deepEqual(next.selectedUserSkillSlugs, []);
});
test("selectDraftForAgentSwitch preserves an existing draft while only changing agent", () => {
const currentDraft = {
...createEmptyDraft("agent-alpha"),
text: "unfinished prompt",
selectedUserSkillSlugs: ["skill-a"],
};
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", false);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "unfinished prompt");
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
});
test("draft mutation version increments on every mutation for the same scope", () => {
const scopeKey = "terminal:1";
const initialVersion = getDraftMutationVersionState({}, scopeKey);
const nextVersions = bumpDraftMutationVersionState({}, scopeKey);
const finalVersions = bumpDraftMutationVersionState(nextVersions, scopeKey);
assert.equal(initialVersion, 0);
assert.equal(getDraftMutationVersionState(nextVersions, scopeKey), 1);
assert.equal(getDraftMutationVersionState(finalVersions, scopeKey), 2);
});
test("draft upload generation only increments when the draft lifecycle rolls over", () => {
const scopeKey = "terminal:1";
const initialGeneration = getDraftUploadGenerationState({}, scopeKey);
const nextGenerations = bumpDraftUploadGenerationState({}, scopeKey);
const finalGenerations = bumpDraftUploadGenerationState(nextGenerations, scopeKey);
assert.equal(initialGeneration, 0);
assert.equal(getDraftUploadGenerationState(nextGenerations, scopeKey), 1);
assert.equal(getDraftUploadGenerationState(finalGenerations, scopeKey), 2);
});
test("pruneTerminalScopeState removes closed terminal drafts and views only", () => {
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),
"terminal:open": createEmptyDraft("agent-beta"),
"workspace:keep": createEmptyDraft("agent-gamma"),
};
const panelViewByScope = {
"terminal:closed": { mode: "draft" },
"terminal:open": { mode: "session", sessionId: "session-open" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.deepEqual(next.draftsByScope, {
"terminal:open": draftsByScope["terminal:open"],
"workspace:keep": draftsByScope["workspace:keep"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open": panelViewByScope["terminal:open"],
"workspace:keep": panelViewByScope["workspace:keep"],
});
});
test("pruneTerminalScopeState returns original refs when nothing is pruned", () => {
const draftsByScope = {
"terminal:open": createEmptyDraft("agent-alpha"),
"workspace:keep": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:open": { mode: "draft" },
"workspace:keep": { mode: "session", sessionId: "session-1" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("pruneTerminalTransientState clears closed terminal active session, draft, and view state only", () => {
const activeSessionIdMap = {
"terminal:closed": "session-closed",
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
};
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),
"terminal:open": createEmptyDraft("agent-beta"),
"workspace:keep": createEmptyDraft("agent-gamma"),
};
const panelViewByScope = {
"terminal:closed": { mode: "draft" },
"terminal:open": { mode: "session", sessionId: "session-open" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open": draftsByScope["terminal:open"],
"workspace:keep": draftsByScope["workspace:keep"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open": panelViewByScope["terminal:open"],
"workspace:keep": panelViewByScope["workspace:keep"],
});
});
test("pruneTerminalTransientState returns original refs when no terminal scopes close", () => {
const activeSessionIdMap = {
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
};
const draftsByScope = {
"terminal:open": createEmptyDraft("agent-alpha"),
"workspace:keep": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:open": { mode: "draft" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});

View File

@@ -0,0 +1,282 @@
import type {
AIDraft,
AIPanelView,
} from '../../infrastructure/ai/types';
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
type DraftMutationVersionByScope = Record<string, number>;
type DraftUploadGenerationByScope = Record<string, number>;
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
export function createEmptyDraft(agentId: string): AIDraft {
return {
text: '',
agentId,
attachments: [],
selectedUserSkillSlugs: [],
updatedAt: Date.now(),
};
}
export function getDraftMutationVersionState(
versionsByScope: DraftMutationVersionByScope,
scopeKey: string,
): number {
return versionsByScope[scopeKey] ?? 0;
}
export function bumpDraftMutationVersionState(
versionsByScope: DraftMutationVersionByScope,
scopeKey: string,
): DraftMutationVersionByScope {
return {
...versionsByScope,
[scopeKey]: getDraftMutationVersionState(versionsByScope, scopeKey) + 1,
};
}
export function getDraftUploadGenerationState(
generationsByScope: DraftUploadGenerationByScope,
scopeKey: string,
): number {
return generationsByScope[scopeKey] ?? 0;
}
export function bumpDraftUploadGenerationState(
generationsByScope: DraftUploadGenerationByScope,
scopeKey: string,
): DraftUploadGenerationByScope {
return {
...generationsByScope,
[scopeKey]: getDraftUploadGenerationState(generationsByScope, scopeKey) + 1,
};
}
export function resolvePanelView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
): AIPanelView {
return panelViewByScope[scopeKey] ?? DEFAULT_PANEL_VIEW;
}
export function setDraftView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
): PanelViewByScope {
const currentPanelView = panelViewByScope[scopeKey];
if (currentPanelView?.mode === 'draft') {
return panelViewByScope;
}
return {
...panelViewByScope,
[scopeKey]: DEFAULT_PANEL_VIEW,
};
}
export function activateDraftView(
activeSessionIdMap: ActiveSessionIdMap,
panelViewByScope: PanelViewByScope,
scopeKey: string,
): {
activeSessionIdMap: ActiveSessionIdMap;
panelViewByScope: PanelViewByScope;
} {
const nextPanelViewByScope = setDraftView(panelViewByScope, scopeKey);
const hasActiveSession = activeSessionIdMap[scopeKey] != null;
if (!hasActiveSession) {
return {
activeSessionIdMap,
panelViewByScope: nextPanelViewByScope,
};
}
const nextActiveSessionIdMap = { ...activeSessionIdMap };
delete nextActiveSessionIdMap[scopeKey];
return {
activeSessionIdMap: nextActiveSessionIdMap,
panelViewByScope: nextPanelViewByScope,
};
}
export function setSessionView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
sessionId: string,
): PanelViewByScope {
return {
...panelViewByScope,
[scopeKey]: { mode: 'session', sessionId },
};
}
export function updateDraftForScope(
draftsByScope: DraftsByScope,
scopeKey: string,
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
): DraftsByScope {
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
const nextDraft = updater(currentDraft);
return {
...draftsByScope,
[scopeKey]: nextDraft,
};
}
export function ensureDraftForScopeState(
draftsByScope: DraftsByScope,
scopeKey: string,
agentId: string,
): DraftsByScope {
if (draftsByScope[scopeKey]) {
return draftsByScope;
}
return {
...draftsByScope,
[scopeKey]: createEmptyDraft(agentId),
};
}
export function selectDraftForAgentSwitch(
currentDraft: AIDraft | null | undefined,
agentId: string,
startFresh: boolean,
): AIDraft {
const hasPendingDraftContent = Boolean(
currentDraft
&& (
currentDraft.text.length > 0
|| currentDraft.attachments.length > 0
|| currentDraft.selectedUserSkillSlugs.length > 0
),
);
if (startFresh && !hasPendingDraftContent) {
return createEmptyDraft(agentId);
}
const baseDraft = currentDraft ?? createEmptyDraft(agentId);
return {
...baseDraft,
agentId,
};
}
export function clearScopeDraftState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
scopeKey: string,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const hasDraft = Object.prototype.hasOwnProperty.call(draftsByScope, scopeKey);
const hasPanelView = Object.prototype.hasOwnProperty.call(panelViewByScope, scopeKey);
if (!hasDraft && !hasPanelView) {
return {
draftsByScope,
panelViewByScope,
};
}
return {
draftsByScope: hasDraft
? (() => {
const nextDrafts = { ...draftsByScope };
delete nextDrafts[scopeKey];
return nextDrafts;
})()
: draftsByScope,
panelViewByScope: hasPanelView
? (() => {
const nextPanelViews = { ...panelViewByScope };
delete nextPanelViews[scopeKey];
return nextPanelViews;
})()
: panelViewByScope,
};
}
function isClosedTerminalScope(scopeKey: string, activeTerminalTargetIds: Set<string>) {
if (!scopeKey.startsWith('terminal:')) return false;
const targetId = scopeKey.slice('terminal:'.length);
if (!targetId) return false;
return !activeTerminalTargetIds.has(targetId);
}
export function pruneTerminalScopeState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTerminalTargetIds: Set<string>,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const nextDraftsByScope = { ...draftsByScope };
const nextPanelViewByScope = { ...panelViewByScope };
let draftsChanged = false;
let panelViewsChanged = false;
for (const scopeKey of Object.keys(nextDraftsByScope)) {
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
delete nextDraftsByScope[scopeKey];
draftsChanged = true;
}
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
delete nextPanelViewByScope[scopeKey];
panelViewsChanged = true;
}
return {
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
};
}
export function pruneTerminalTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTerminalTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextTerminalScopeState = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
activeTerminalTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextTerminalScopeState.draftsByScope,
panelViewByScope: nextTerminalScopeState.panelViewByScope,
};
}

View File

@@ -0,0 +1,160 @@
import test from "node:test";
import assert from "node:assert/strict";
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
import { createEmptyDraft } from "./aiDraftState.ts";
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from "./aiScopeCleanup.ts";
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
return {
id,
title: id,
agentId: "catty",
scope,
messages: [],
externalSessionId,
createdAt: 1,
updatedAt: 1,
};
}
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
const activeSessionIdMap = {
"terminal:open-terminal": "session-open",
"terminal:closed-terminal": "session-closed-terminal",
"workspace:open-workspace": "session-open-workspace",
"workspace:closed-workspace": "session-closed-workspace",
};
const draftsByScope = {
"terminal:open-terminal": createEmptyDraft("catty"),
"terminal:closed-terminal": createEmptyDraft("catty"),
"workspace:open-workspace": createEmptyDraft("catty"),
"workspace:closed-workspace": createEmptyDraft("catty"),
};
const panelViewByScope = {
"terminal:open-terminal": { mode: "draft" },
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
"workspace:open-workspace": { mode: "draft" },
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
} satisfies Record<string, AIPanelView>;
const next = pruneInactiveScopedTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open-terminal", "open-workspace"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open-terminal": "session-open",
"workspace:open-workspace": "session-open-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
});
});
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
targetId: "closed-restorable",
hostIds: ["host-1"],
}, "ext-1"),
createSession("terminal-local", {
type: "terminal",
targetId: "closed-local",
hostIds: ["local-shell"],
}, "ext-2"),
createSession("workspace-closed", {
type: "workspace",
targetId: "closed-workspace",
}, "ext-3"),
createSession("terminal-open", {
type: "terminal",
targetId: "open-terminal",
hostIds: ["host-2"],
}, "ext-4"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["open-terminal"]),
);
assert.deepEqual(next.orphanedSessionIds, [
"terminal-restorable",
"terminal-local",
"workspace-closed",
]);
assert.deepEqual(next.sessions, [
sessions[0],
sessions[3],
]);
});
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
targetId: "closed-restorable",
hostIds: ["host-1"],
}),
createSession("terminal-open", {
type: "terminal",
targetId: "open-terminal",
hostIds: ["host-2"],
}, "ext-4"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["open-terminal"]),
);
assert.deepEqual(next.orphanedSessionIds, ["terminal-restorable"]);
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use, not orphaned", () => {
// terminal-restorable's original scope (terminal-closed-A) is gone, but
// the user resumed it into terminal-open-B from history. The session's
// externalSessionId must be preserved and it must not appear in the
// orphaned list, otherwise the active chat loses ACP continuity.
const resumedElsewhere = createSession("terminal-restorable", {
type: "terminal",
targetId: "terminal-closed-A",
hostIds: ["host-1"],
}, "ext-resumed");
const trulyOrphaned = createSession("terminal-stale", {
type: "terminal",
targetId: "terminal-closed-C",
hostIds: ["host-2"],
}, "ext-stale");
const sessions = [resumedElsewhere, trulyOrphaned];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["terminal-open-B"]),
new Set(["terminal-restorable"]),
);
// Only the one not being displayed anywhere should show up as orphaned.
assert.deepEqual(next.orphanedSessionIds, ["terminal-stale"]);
// The resumed session must retain its externalSessionId.
const resumedNext = next.sessions.find((s) => s.id === "terminal-restorable");
assert.equal(resumedNext?.externalSessionId, "ext-resumed");
});

View File

@@ -0,0 +1,145 @@
import type {
AIDraft,
AIPanelView,
AISession,
} from "../../infrastructure/ai/types";
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
function isInactiveScopedTarget(
scopeKey: string,
activeTargetIds: Set<string>,
): boolean {
const separatorIndex = scopeKey.indexOf(":");
if (separatorIndex === -1) return false;
const scopeType = scopeKey.slice(0, separatorIndex);
if (scopeType !== "terminal" && scopeType !== "workspace") return false;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return false;
return !activeTargetIds.has(targetId);
}
export function pruneInactiveScopedState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const nextDraftsByScope = { ...draftsByScope };
const nextPanelViewByScope = { ...panelViewByScope };
let draftsChanged = false;
let panelViewsChanged = false;
for (const scopeKey of Object.keys(nextDraftsByScope)) {
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
delete nextDraftsByScope[scopeKey];
draftsChanged = true;
}
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
delete nextPanelViewByScope[scopeKey];
panelViewsChanged = true;
}
return {
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
};
}
export function pruneInactiveScopedTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextScopedState = pruneInactiveScopedState(
draftsByScope,
panelViewByScope,
activeTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextScopedState.draftsByScope,
panelViewByScope: nextScopedState.panelViewByScope,
};
}
function isRestorableTerminalSession(session: AISession): boolean {
return session.scope.type === "terminal"
&& !!session.scope.hostIds?.length
&& session.scope.hostIds.some((id) => !id.startsWith("local-") && !id.startsWith("serial-"));
}
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
/**
* Session ids currently displayed by any live scope. A session whose
* `scope.targetId` is inactive but whose id is still in use somewhere
* (e.g. resumed from history into a different terminal) must not be
* treated as orphaned — deleting it outright would break the chat the
* user is actively continuing.
*/
activeSessionIds: Set<string> = new Set(),
): {
sessions: AISession[];
orphanedSessionIds: string[];
} {
const orphanedSessionIds = sessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.filter((session) => !activeSessionIds.has(session.id))
.map((session) => session.id);
if (orphanedSessionIds.length === 0) {
return {
sessions,
orphanedSessionIds,
};
}
const orphanedSessionIdSet = new Set(orphanedSessionIds);
let sessionsChanged = false;
const nextSessions = sessions.flatMap((session) => {
if (!orphanedSessionIdSet.has(session.id)) {
return [session];
}
if (!isRestorableTerminalSession(session)) {
sessionsChanged = true;
return [];
}
return [session];
});
return {
sessions: sessionsChanged ? nextSessions : sessions,
orphanedSessionIds,
};
}

View File

@@ -75,7 +75,6 @@ class CustomThemeStore {
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
// Another window changed custom themes — reload from localStorage
this.loadFromStorage();
this.notify();
}
});
} catch {
@@ -129,6 +128,13 @@ class CustomThemeStore {
this.notify();
this.broadcastChange();
};
replaceThemes = (themes: TerminalTheme[]) => {
this.themes = themes.map((theme) => ({ ...theme, colors: { ...theme.colors }, isCustom: true }));
this.saveToStorage();
this.notify();
this.broadcastChange();
};
}
// Singleton
@@ -172,5 +178,9 @@ export const useCustomThemeActions = () => {
customThemeStore.deleteTheme(id);
}, []);
return { addTheme, updateTheme, deleteTheme };
const replaceThemes = useCallback((themes: TerminalTheme[]) => {
customThemeStore.replaceThemes(themes);
}, []);
return { addTheme, updateTheme, deleteTheme, replaceThemes };
};

View File

@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
const baseWorkspace = {
id: "w1",
focusedSessionId: "s1",
};
const baseSession = { id: "s1" };
test("non-workspace tab → closeSingleTab with session id", () => {
const result = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: baseSession,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
});
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: { id: "s1" },
activeSidePanelTab: "ai",
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("vault/sftp tab → noop", () => {
const r = resolveCloseIntent({
activeTabId: "vault",
workspace: null,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "noop" });
});
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "sftp",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
});
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});

View File

@@ -0,0 +1,43 @@
export type CloseIntent =
| { kind: 'closeTerminal'; sessionId: string }
| { kind: 'closeSidePanel' }
| { kind: 'closeWorkspace'; workspaceId: string }
| { kind: 'closeSingleTab'; sessionId: string }
| { kind: 'noop' };
export interface ResolveCloseInput {
activeTabId: string | null;
workspace: { id: string; focusedSessionId?: string } | null;
sessionForTab: { id: string } | null;
activeSidePanelTab: string | null;
focusIsInsideTerminal: boolean;
}
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
if (!activeTabId) return { kind: 'noop' };
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
// Modals take priority over this but are intercepted upstream in App.tsx before the
// hotkey reaches resolveCloseIntent.
if (activeSidePanelTab !== null) {
return { kind: 'closeSidePanel' };
}
if (sessionForTab && !workspace) {
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
}
if (!workspace) {
// e.g. 'vault', 'sftp', or any non-closable pinned tab
return { kind: 'noop' };
}
const focusedSessionId = workspace.focusedSessionId;
if (focusedSessionId && focusIsInsideTerminal) {
return { kind: 'closeTerminal', sessionId: focusedSessionId };
}
return { kind: 'closeWorkspace', workspaceId: workspace.id };
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from "react";
import React, { useCallback, useRef } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";

View File

@@ -5,6 +5,7 @@ import {
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_EXTERNAL_AGENTS,
STORAGE_KEY_AI_DEFAULT_AGENT,
@@ -17,8 +18,11 @@ import {
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
import type {
AIDraft,
AIPanelView,
AISession,
AIPermissionMode,
AIToolIntegrationMode,
ProviderConfig,
HostAIPermission,
ExternalAgentConfig,
@@ -27,13 +31,42 @@ import type {
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import {
activateDraftView,
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
clearScopeDraftState,
ensureDraftForScopeState,
getDraftUploadGenerationState,
setSessionView,
updateDraftForScope,
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from './aiScopeCleanup';
import { convertFilesToUploads } from './useFileUpload';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
interface AIBridge {
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
}
function getAIBridge() {
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
return (window as unknown as { netcatty?: AIBridge }).netcatty;
}
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
function emitAIStateChanged(key: string) {
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
@@ -61,53 +94,41 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const orphanedSessionIds = currentSessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.map((session) => session.id);
if (orphanedSessionIds.length > 0) {
const orphanedSessionIdSet = new Set(orphanedSessionIds);
// Determine which sessions can be restored via host-based matching
const preservedIds = new Set<string>();
for (const session of currentSessions) {
if (!orphanedSessionIdSet.has(session.id)) continue;
// Only preserve remote terminal sessions with real hostIds
const isRestorable = session.scope.type === 'terminal'
&& session.scope.hostIds?.length
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
if (isRestorable) {
preservedIds.add(session.id);
}
}
// Cleanup ACP sessions for all orphans (both deleted and preserved).
// Preserved sessions will get a new externalSessionId on next use,
// so cleaning the old one is safe and prevents subprocess leaks.
cleanupAcpSessions(orphanedSessionIds);
const nextSessions = currentSessions
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
.map((session) => {
if (!preservedIds.has(session.id) || !session.externalSessionId) {
return session;
}
// Drop transient ACP session handles so the next turn starts cleanly.
return { ...session, externalSessionId: undefined };
});
const sessionsChanged = nextSessions.length !== currentSessions.length
|| nextSessions.some((session, index) => session !== currentSessions[index]);
if (sessionsChanged) {
setLatestAISessionsSnapshot(nextSessions);
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
}
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
// history can be resumed into a different terminal and we must not
// delete it outright while it's actively being used.
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const activeSessionIds = new Set<string>();
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
if (!sessionId) continue;
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
activeSessionIds.add(sessionId);
}
const nextSessionCleanup = pruneInactiveScopedSessions(
currentSessions,
activeTargetIds,
activeSessionIds,
);
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
}
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
localStorageAdapter.write(
STORAGE_KEY_AI_SESSIONS,
pruneSessionsForStorage(nextSessionCleanup.sessions),
);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
@@ -122,6 +143,46 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
currentDraftsByScope,
currentPanelViewByScope,
activeTargetIds,
);
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
localStorageAdapter.write(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
prunedScopedTransientState.activeSessionIdMap,
);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
@@ -152,6 +213,10 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
let latestAISessionsSnapshot: AISession[] | null = null;
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
@@ -161,17 +226,33 @@ function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string,
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
function buildScopeKey(scope: AISessionScope) {
return `${scope.type}:${scope.targetId ?? ''}`;
function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}
function areHostIdsEqual(left?: string[], right?: string[]) {
const leftIds = left ?? [];
const rightIds = right ?? [];
if (leftIds.length !== rightIds.length) return false;
function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
latestAIPanelViewByScopeSnapshot = panelViewByScope;
}
const rightSet = new Set(rightIds);
return leftIds.every((hostId) => rightSet.has(hostId));
function bumpDraftMutationVersion(scopeKey: string) {
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
latestAIDraftMutationVersionByScopeSnapshot,
scopeKey,
);
}
function getDraftUploadGeneration(scopeKey: string) {
return getDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
function bumpDraftUploadGeneration(scopeKey: string) {
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
export function useAIState() {
@@ -192,6 +273,10 @@ export function useAIState() {
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
return 'confirm';
});
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
return stored === 'skills' ? 'skills' : 'mcp';
});
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
);
@@ -228,6 +313,14 @@ export function useAIState() {
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
);
// Per-scope draft/view state is intentionally memory-only so a relaunch
// does not restore stale composer input or panel intent against new history.
const [draftsByScope, setDraftsByScopeRaw] = useState<DraftsByScope>(() =>
latestAIDraftsByScopeSnapshot ?? {}
);
const [panelViewByScope, setPanelViewByScopeRaw] = useState<PanelViewByScope>(() =>
latestAIPanelViewByScopeSnapshot ?? {}
);
// Per-agent model selection: remembers last selected model per agent
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
@@ -247,12 +340,20 @@ export function useAIState() {
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
}, [activeSessionIdMap]);
useEffect(() => {
setLatestAIDraftsByScopeSnapshot(draftsByScope);
}, [draftsByScope]);
useEffect(() => {
setLatestAIPanelViewByScopeSnapshot(panelViewByScope);
}, [panelViewByScope]);
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap) as Array<[string, string | null]>) {
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
nextActiveSessionIdMap[scopeKey] = nextSessionId;
if (nextSessionId !== sessionId) {
@@ -269,13 +370,39 @@ export function useAIState() {
}, [sessions, activeSessionIdMap]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] === id) {
return prev;
}
const next = { ...prev, [scopeKey]: id };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
nextActiveSessionIdMap = next;
return next;
});
if (!nextActiveSessionIdMap) return;
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}, []);
const setPanelViewByScope = useCallback((value: PanelViewByScope | ((prev: PanelViewByScope) => PanelViewByScope)) => {
let nextPanelViewByScope: PanelViewByScope | null = null;
setPanelViewByScopeRaw((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
if (next === prev) return prev;
nextPanelViewByScope = next;
return next;
});
if (!nextPanelViewByScope) return;
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}, []);
const setAgentModel = useCallback((agentId: string, modelId: string) => {
@@ -330,6 +457,13 @@ export function useAIState() {
});
}, []);
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
setToolIntegrationModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
const bridge = getAIBridge();
bridge?.aiMcpSetToolIntegrationMode?.(mode);
}, []);
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
setExternalAgentsRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
@@ -396,6 +530,15 @@ export function useAIState() {
}
break;
}
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
{
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
? 'skills'
: 'mcp';
setToolIntegrationModeRaw(mode);
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
}
break;
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
if (agents != null && !Array.isArray(agents)) {
@@ -491,6 +634,12 @@ export function useAIState() {
?? {},
);
return;
case AI_STATE_CHANGED_DRAFTS_BY_SCOPE:
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
return;
case AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE:
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
return;
default:
handleStorage({ key } as StorageEvent);
}
@@ -511,8 +660,17 @@ export function useAIState() {
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
const storedPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
const initialPermMode: AIPermissionMode =
storedPermMode === 'observer' || storedPermMode === 'confirm' || storedPermMode === 'autonomous'
? storedPermMode
: 'confirm';
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
const initialToolMode: AIToolIntegrationMode =
localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
? 'skills'
: 'mcp';
bridge?.aiMcpSetToolIntegrationMode?.(initialToolMode);
}, []);
// ── Session CRUD ──
@@ -646,61 +804,6 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
if (!currentSession) return;
const currentScope = currentSession.scope;
const scopeChanged =
currentScope.type !== scope.type
|| currentScope.targetId !== scope.targetId
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
const nextScopeKey = buildScopeKey(scope);
const currentScopeKey = buildScopeKey(currentScope);
if (scopeChanged) {
setSessionsRaw((prev) => {
let changed = false;
const next = prev.map((session) => {
if (session.id !== sessionId) return session;
changed = true;
// Clear stale ACP handle — retarget may run before orphan cleanup
return { ...session, scope, externalSessionId: undefined };
});
if (!changed) return prev;
sessionsRef.current = next;
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
}
setActiveSessionIdMapRaw((prev) => {
let changed = false;
const next = { ...prev };
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
delete next[currentScopeKey];
changed = true;
}
if (next[nextScopeKey] !== sessionId) {
next[nextScopeKey] = sessionId;
changed = true;
}
if (!changed) return prev;
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
}, [persistSessions]);
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
@@ -768,14 +871,193 @@ export function useAIState() {
});
}, [persistSessions]);
const ensureDraftForScope = useCallback((scopeKey: string, agentId: string): void => {
let nextDraftsByScope: DraftsByScope | null = null;
setDraftsByScopeRaw((prev) => {
const next = ensureDraftForScopeState(prev, scopeKey, agentId);
if (next === prev) return prev;
nextDraftsByScope = next;
return next;
});
if (!nextDraftsByScope) return;
bumpDraftMutationVersion(scopeKey);
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}, []);
const updateDraft = useCallback((
scopeKey: string,
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
): void => {
setDraftsByScopeRaw((prev) => {
const next = updateDraftForScope(
prev,
scopeKey,
fallbackAgentId,
(draft) => {
return {
...updater(draft),
updatedAt: Date.now(),
};
},
);
setLatestAIDraftsByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
return next;
});
bumpDraftMutationVersion(scopeKey);
}, []);
const updateDraftIfPresent = useCallback((
scopeKey: string,
updater: (draft: AIDraft) => AIDraft,
): void => {
let updated = false;
setDraftsByScopeRaw((prev) => {
const currentDraft = prev[scopeKey];
if (!currentDraft) return prev;
const nextDraft = {
...updater(currentDraft),
updatedAt: Date.now(),
};
const next = {
...prev,
[scopeKey]: nextDraft,
};
updated = true;
setLatestAIDraftsByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
return next;
});
if (updated) {
bumpDraftMutationVersion(scopeKey);
}
}, []);
const showDraftView = useCallback((scopeKey: string) => {
const currentPanelViewByScope = panelViewByScope;
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
let nextPanelViewByScope: PanelViewByScope | null = null;
let activeSessionMapChanged = false;
let panelViewChanged = false;
setActiveSessionIdMapRaw((prevActiveSessionIdMap) => {
const next = activateDraftView(
prevActiveSessionIdMap,
currentPanelViewByScope,
scopeKey,
);
activeSessionMapChanged = next.activeSessionIdMap !== prevActiveSessionIdMap;
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
nextActiveSessionIdMap = next.activeSessionIdMap;
nextPanelViewByScope = next.panelViewByScope;
return activeSessionMapChanged ? next.activeSessionIdMap : prevActiveSessionIdMap;
});
if (activeSessionMapChanged && nextActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (panelViewChanged && nextPanelViewByScope) {
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
setPanelViewByScopeRaw(nextPanelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}, [panelViewByScope]);
const showSessionView = useCallback((scopeKey: string, sessionId: string) => {
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
}, [setPanelViewByScope]);
const clearDraftForScope = useCallback((scopeKey: string) => {
const currentPanelViewByScope = panelViewByScope;
let nextDraftsByScope: DraftsByScope | null = null;
let nextPanelViewByScope: PanelViewByScope | null = null;
let draftsChanged = false;
let panelViewChanged = false;
setDraftsByScopeRaw((prevDraftsByScope) => {
const next = clearScopeDraftState(
prevDraftsByScope,
currentPanelViewByScope,
scopeKey,
);
draftsChanged = next.draftsByScope !== prevDraftsByScope;
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
nextDraftsByScope = next.draftsByScope;
nextPanelViewByScope = next.panelViewByScope;
return draftsChanged ? next.draftsByScope : prevDraftsByScope;
});
if (!draftsChanged && !panelViewChanged) return;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
if (draftsChanged && nextDraftsByScope) {
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (panelViewChanged && nextPanelViewByScope) {
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
setPanelViewByScopeRaw(nextPanelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}, [panelViewByScope]);
const addDraftFiles = useCallback(async (
scopeKey: string,
fallbackAgentId: string,
inputFiles: File[],
) => {
ensureDraftForScope(scopeKey, fallbackAgentId);
const initialUploadGeneration = getDraftUploadGeneration(scopeKey);
const uploads = await convertFilesToUploads(inputFiles);
if (uploads.length === 0) return;
if (getDraftUploadGeneration(scopeKey) !== initialUploadGeneration) {
return;
}
updateDraftIfPresent(scopeKey, (draft) => ({
...draft,
attachments: [...draft.attachments, ...uploads],
}));
}, [ensureDraftForScope, updateDraftIfPresent]);
const removeDraftFile = useCallback((scopeKey: string, fallbackAgentId: string, fileId: string) => {
updateDraft(scopeKey, fallbackAgentId, (draft) => ({
...draft,
attachments: draft.attachments.filter((file) => file.id !== fileId),
}));
}, [updateDraft]);
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
cleanupOrphanedAISessions(activeTargetIds);
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
const nextSessions =
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
}, []);
// ── Provider CRUD helpers ──
@@ -819,6 +1101,8 @@ export function useAIState() {
// Permission model
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
hostPermissions,
setHostPermissions,
@@ -847,13 +1131,21 @@ export function useAIState() {
// Sessions (per-scope active session)
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
setActiveSessionId,
ensureDraftForScope,
updateDraft,
showDraftView,
showSessionView,
clearDraftForScope,
addDraftFiles,
removeDraftFile,
createSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
retargetSessionScope,
addMessageToSession,
updateLastMessage,
updateMessageById,

View File

@@ -15,15 +15,15 @@ export type SshAgentStatus = {
export const useApplicationBackend = () => {
const openExternal = useCallback(async (url: string) => {
try {
const bridge = netcattyBridge.get();
if (bridge?.openExternal) {
await bridge.openExternal(url);
return;
}
} catch {
// Ignore and fall back below
const bridge = netcattyBridge.get();
if (bridge?.openExternal) {
// Bridge resolves on success (either via system browser or in-app
// fallback window) and rejects only when both paths fail. Let the
// rejection propagate so callers can present a user-facing message.
await bridge.openExternal(url);
return;
}
// Fallback for non-Electron environments (tests, dev server, etc.).
window.open(url, "_blank", "noopener,noreferrer");
}, []);

View File

@@ -16,8 +16,12 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings } from '../syncPayload';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { notify } from '../notification';
@@ -35,15 +39,24 @@ interface AutoSyncConfig {
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
startupReady?: boolean;
// Callbacks
onApplyPayload: (payload: SyncPayload) => void;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
}
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
// in the future means a restore is applying in some window and auto-sync
// must not push concurrently.
const isRestoreInProgress = (): boolean => {
const raw = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL);
return typeof raw === 'number' && raw > Date.now();
};
type SyncTrigger = 'auto' | 'manual';
interface SyncNowOptions {
@@ -57,10 +70,27 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
/** True once checkRemoteVersion has completed (success or failure). Until
* this is set, the debounced auto-sync effect will not fire, preventing
* an empty local vault from racing ahead and overwriting a non-empty
* cloud vault before the startup pull has run. See #679. */
const remoteCheckDoneRef = useRef(false);
const isInitializedRef = useRef(false);
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
// State for the empty-vault-vs-cloud confirmation dialog (Fix D).
// When checkRemoteVersion detects that the local vault is empty but
// the cloud has data, it pauses and exposes this state so the root
// component can render a confirmation dialog.
const [emptyVaultConflict, setEmptyVaultConflict] = useState<{
remotePayload: SyncPayload;
hostCount: number;
keyCount: number;
snippetCount: number;
} | null>(null);
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
// Listen for SFTP bookmark changes to trigger auto-sync
const [bookmarksVersion, setBookmarksVersion] = useState(0);
useEffect(() => {
@@ -147,6 +177,50 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.alreadySyncing'));
}
// Cross-window guard: another window may be in the middle of
// applying a local vault restore. If we push right now we'd upload
// the pre-restore snapshot (the main window's React state hasn't
// observed the localStorage writes yet), clobbering the just-
// restored cloud copy. Skip silently on auto triggers and fail
// loudly on manual ones so the user understands why their click
// did nothing.
//
// Pairs with `withRestoreBarrier` in application/localVaultBackups.ts
// (the writer) and with the matching early-return in the
// debounced-sync effect below (the other reader, which prevents
// scheduling a push while the barrier is held).
if (isRestoreInProgress()) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping: a vault restore is in progress in another window.');
return;
}
throw new Error(t('sync.autoSync.restoreInProgress'));
}
// Refuse to auto-push when a previous apply crashed mid-way and
// left the vault in a partial state. `applyProtectedSyncPayload`
// sets a sentinel before its non-atomic localStorage writes and
// clears it on successful completion; the sentinel's presence
// here means the renderer crashed between a first write and the
// clean-up, so the in-memory payload is a mix of pre-apply and
// post-apply entries. Pushing that would silently overwrite an
// intact cloud copy with corrupted data.
//
// Manual triggers surface a user-visible error that points the
// user at the Restore UI; auto triggers return quietly (the
// next startup toast below flags the state).
const interruptedApply = readInterruptedVaultApply();
if (interruptedApply) {
if (trigger === 'auto') {
console.warn(
'[AutoSync] Skipping: previous apply was interrupted — refusing to push partial state.',
interruptedApply,
);
return;
}
throw new Error(t('sync.autoSync.interruptedApplyMessage'));
}
// If another window unlocked, reuse the in-memory session password from main process.
if (state.securityState !== 'UNLOCKED') {
const bridge = netcattyBridge.get();
@@ -173,13 +247,32 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.credentialsUnavailable'));
}
// Refuse to push an empty vault to cloud. This is almost always
// a sign that the local state was lost (update, import failure,
// storage corruption) rather than a deliberate "delete everything".
// Both auto and manual triggers are blocked; the user can still
// use Force Push from the SyncBlocked banner if they genuinely
// want to wipe the cloud.
//
// This pairs with the inspect-failure "fail open" behavior in
// checkRemoteVersion below: if inspect transiently errors we still
// let auto-sync run, trusting this guard to refuse if local is
// truly empty rather than letting an empty state clobber remote.
if (!hasMeaningfulSyncData(payload)) {
if (trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
}
throw new Error(t('sync.autoSync.emptyVaultManual'));
}
const results = await sync.syncNow(payload);
// Apply merged payloads first (before checking for failures) so local
// state gets updated even when some providers failed
for (const result of results.values()) {
if (result.mergedPayload) {
onApplyPayload(result.mergedPayload);
await Promise.resolve(onApplyPayload(result.mergedPayload));
skipNextSyncRef.current = true;
break; // All providers share the same merged payload
}
@@ -195,6 +288,18 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
lastSyncedDataRef.current = dataHash;
// Successful sync implies a successful per-provider
// `checkProviderConflict` (which inspects remote) — equivalent
// to a successful startup reconciliation from the auto-sync
// gate's point of view. Opening the gate here is the escape
// hatch when a network outage exhausted the startup retry
// timer: a user-triggered manual sync (or any first successful
// auto sync that somehow ran anyway) resumes auto-sync for the
// rest of the session. Without this, a degraded-startup session
// would require the user to manually sync after every edit.
hasCheckedRemoteRef.current = true;
remoteCheckDoneRef.current = true;
} catch (error) {
if (trigger === 'manual') {
throw error;
@@ -208,46 +313,232 @@ export const useAutoSync = (config: AutoSyncConfig) => {
isSyncRunningRef.current = false;
}
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
// One-shot toast per mount when a previous apply was interrupted, so the
// user understands why auto-sync is silently paused and where to go to
// recover. `applyProtectedSyncPayload` clears the sentinel on a clean
// apply, so this only fires once per genuine crash and naturally stops
// after the user completes a recovery.
const interruptedApplyNotifiedRef = useRef(false);
useEffect(() => {
if (interruptedApplyNotifiedRef.current) return;
if (!sync.isUnlocked) return;
const interrupted = readInterruptedVaultApply();
if (!interrupted) return;
interruptedApplyNotifiedRef.current = true;
notify.error(
t('sync.autoSync.interruptedApplyMessage'),
t('sync.autoSync.interruptedApplyTitle'),
);
}, [sync.isUnlocked, t]);
// Stabilize the fields `checkRemoteVersion` reads from `config`.
// AutoSyncConfig is a fresh object literal on every App render, so a
// naive `config` dep would rebuild `checkRemoteVersion`'s identity on
// every unrelated state change — re-firing the retry effect with
// `attempt=0` and spawning overlapping in-flight inspections. The
// refs below let `checkRemoteVersion` read the latest callback and
// readiness flag without pulling the object identity into deps.
const onApplyPayloadRef = useRef(config.onApplyPayload);
useEffect(() => {
onApplyPayloadRef.current = config.onApplyPayload;
}, [config.onApplyPayload]);
const startupReadyRef = useRef(config.startupReady);
useEffect(() => {
startupReadyRef.current = config.startupReady;
}, [config.startupReady]);
// `buildPayload` closes over live React state so its identity flips
// on every vault edit; route it through a ref so `checkRemoteVersion`
// can read the latest builder without churning its memo identity.
const buildPayloadRef = useRef(buildPayload);
useEffect(() => {
buildPayloadRef.current = buildPayload;
}, [buildPayload]);
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
// could both write-then-clear the apply-in-progress sentinel around
// interleaved applies, and both could push post-merge snapshots to
// remote. The cross-window `withRestoreBarrier` protects other
// windows but does NOT serialize same-window re-entry, so this
// in-flight guard closes that gap at the top of the call.
const checkRemoteInFlightRef = useRef(false);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
if (checkRemoteInFlightRef.current) {
return;
}
const state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
return;
}
hasCheckedRemoteRef.current = true;
// Find connected provider
// Find connected provider BEFORE acquiring the in-flight lock so the
// "nothing to check" early return doesn't leak the lock and wedge
// the retry timer. Any path that takes the lock MUST reach the
// finally-release below.
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
isProviderReadyForSync(state.providers[provider]),
) ?? null;
if (!connectedProvider) return;
if (!connectedProvider) {
// Nothing to check — mark as done so the auto-sync gate opens.
remoteCheckDoneRef.current = true;
return;
}
checkRemoteInFlightRef.current = true;
// Track whether the startup path completed in a state where the anchor/base
// are consistent with the local vault. Only then should we latch
// hasCheckedRemoteRef so that transient failures are retryable.
let startupConsistent = false;
try {
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
// Load base BEFORE observing the remote payload (commitRemoteInspection overwrites the base).
const base = await manager.loadSyncBase(connectedProvider);
const remotePayload = await sync.downloadFromProvider(connectedProvider);
const inspection = await manager.inspectProviderRemote(connectedProvider);
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const localPayload = buildPayload();
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
if (!inspection.payload || !inspection.remoteChanged || !inspection.remoteFile) {
// Remote unchanged (or empty) — no local mutation needed; anchor/base
// are already in sync with remote from a previous run.
startupConsistent = true;
return;
}
config.onApplyPayload(mergeResult.payload);
// Don't save base or skip auto-sync — let the data-change effect
// naturally trigger an upload of the merged payload (which will
// go through syncAllProviders and save base on success).
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
const remoteFile = inspection.remoteFile;
const remotePayload = inspection.payload;
const localPayload = buildPayloadRef.current();
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
const remoteHasData = hasMeaningfulSyncData(remotePayload);
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).
// Pause and ask the user what to do instead of silently merging.
if (localIsEmpty && remoteHasData) {
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
emptyVaultResolveRef.current = resolve;
setEmptyVaultConflict({
remotePayload,
hostCount: remotePayload.hosts?.length ?? 0,
keyCount: remotePayload.keys?.length ?? 0,
snippetCount: remotePayload.snippets?.length ?? 0,
});
});
setEmptyVaultConflict(null);
emptyVaultResolveRef.current = null;
if (userAction === 'restore') {
// Apply remote FIRST; only commit anchor/base after the UI-side
// state has accepted the remote payload, otherwise a failure
// between commit and apply would leave the anchor pointing at
// remote while local is still empty — the exact overwrite window
// we're trying to close.
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
skipNextSyncRef.current = true;
startupConsistent = true;
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
} else {
// User chose to keep the empty vault. Deliberately do NOT advance
// the anchor or base — the next sync must still treat remote as
// "unseen" so the empty-vault-push guard (`hasMeaningfulSyncData`)
// keeps protecting the cloud copy. startupConsistent stays false
// so hasCheckedRemoteRef is not latched and the next startup will
// re-prompt if the user still has not added anything.
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
}
return;
}
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
// Apply merged payload to local state BEFORE committing. If the apply
// throws, the next startup will re-run the merge with fresh data.
await Promise.resolve(onApplyPayloadRef.current(mergeResult.payload));
// Base is the last-agreed remote snapshot; `commitRemoteInspection`
// stores remotePayload as the base so the next diff is computed
// against what the cloud actually has, not against the merged
// local-only state.
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
startupConsistent = true;
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
// If the three-way merge introduced any local-only additions that the
// remote does not yet have, we MUST round-trip those to the cloud.
// Previously this branch stopped after applying merge locally, so the
// merged-in additions lived only on the device that ran the merge
// until the user's next edit.
//
// We push the merged payload *directly* through the manager rather
// than going through the React-state-driven `syncNow`. syncNow
// rebuilds the payload from hooks state, which may not yet reflect
// the onApplyPayload we awaited above (React commit phase is async
// relative to the awaited promise resolution). Passing mergeResult
// in explicitly removes the race entirely and avoids a setTimeout(0)
// that only approximated the correct ordering.
if (mergeResult.payload) {
try {
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
const wasShrinkBlocked = Array.from(roundTripResults.values()).some(
(r) => r.shrinkBlocked === true,
);
if (wasShrinkBlocked) {
// The merged payload is already applied locally and is the source of truth
// for THIS device. The blocking only prevents pushing it to cloud, which
// is acceptable here — the next user-edit-triggered sync will re-check
// (and the user can also force-push from the Settings banner if they
// navigate there). Reset syncState so we don't leave the manager wedged
// in BLOCKED with no banner visible.
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
manager.clearShrinkBlockedState();
}
// Suppress the debounced follow-up tick that otherwise fires
// once React commits the applied state, since we've just
// already pushed that exact payload upstream.
skipNextSyncRef.current = true;
} catch (error) {
// Non-fatal: the next user edit will drive another sync cycle.
console.warn('[AutoSync] Post-merge round-trip push failed:', error);
}
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
// Leave hasCheckedRemoteRef=false so the next startup (or the next
// provider/unlock transition) can retry.
} finally {
if (startupConsistent) {
hasCheckedRemoteRef.current = true;
// Only open the auto-sync gate when the inspect actually
// validated the remote state. Leaving the gate closed on
// inspect failure is intentional: an edit made during a
// degraded startup must not race ahead and push a partially-
// hydrated vault over an intact remote. The retry effect
// below re-fires checkRemoteVersion on the next provider/
// unlock/startupReady transition, and a manual sync from
// Settings remains available as an escape hatch.
remoteCheckDoneRef.current = true;
}
checkRemoteInFlightRef.current = false;
}
}, [sync, config, buildPayload, t]);
// Intentionally minimal deps: `buildPayload`, `config.onApplyPayload`,
// and `config.startupReady` are read through refs above so their
// identity flips (every vault edit produces a fresh `buildPayload`
// and a fresh AutoSyncConfig literal) cannot re-memoize this
// callback and restart the retry-timer's exponential backoff.
}, [t]);
// Debounced auto-sync when data changes
useEffect(() => {
@@ -255,7 +546,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
// Don't auto-sync until the startup remote check has completed.
// Without this gate, an empty local vault can push to the cloud
// before checkRemoteVersion even runs, overwriting a non-empty
// remote vault — the exact bug described in #679.
if (!remoteCheckDoneRef.current) {
return;
}
// Skip initial render
if (!isInitializedRef.current) {
isInitializedRef.current = true;
@@ -283,6 +582,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (sync.isSyncing || isSyncRunningRef.current) {
return;
}
// Hold off on scheduling a new push while another window is applying
// a restore — the restore is about to land via localStorage and the
// debounce-fired syncNow would otherwise race it. The next data-
// change tick after the restore barrier clears will re-enter here.
if (isRestoreInProgress()) {
return;
}
// Don't even schedule a push while the apply-in-progress sentinel
// is held. The syncNow path re-checks and refuses too, but dropping
// the debounced schedule here avoids spinning a 3-second timer for
// every keystroke while the user is in the Restore UI working
// through recovery.
if (readInterruptedVaultApply()) {
return;
}
// Clear existing timeout
if (syncTimeoutRef.current) {
@@ -301,31 +617,111 @@ export const useAutoSync = (config: AutoSyncConfig) => {
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
// Check remote version on startup/unlock
// Check remote version on startup/unlock, then retry with backoff
// while the inspect keeps failing. Without the timer-based retry,
// a failure that doesn't coincide with a dep change would wedge the
// auto-sync gate closed until the user restarts or manually triggers
// sync from Settings — the 30s/60s/90s cadence below lets a short
// outage (network blip, provider rate-limit) self-heal.
useEffect(() => {
if (sync.hasAnyConnectedProvider && sync.isUnlocked && !hasCheckedRemoteRef.current) {
// Delay check to ensure everything is loaded
const timer = setTimeout(() => {
checkRemoteVersion();
}, 1000);
return () => clearTimeout(timer);
if (
!sync.hasAnyConnectedProvider ||
!sync.isUnlocked ||
hasCheckedRemoteRef.current ||
config.startupReady === false
) {
return;
}
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, checkRemoteVersion]);
let cancelled = false;
let attempt = 0;
let timerId: NodeJS.Timeout | null = null;
const tick = () => {
if (cancelled) return;
void (async () => {
await checkRemoteVersion();
if (cancelled || hasCheckedRemoteRef.current) return;
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
// persistent failure beyond that is almost certainly a
// misconfiguration that needs user action rather than more
// auto-retries.
//
// When retries exhaust we deliberately leave the auto-sync gate
// CLOSED. Opening it here would allow a partially-lost local
// vault to silently clobber an unchanged remote: anchor still
// matches, `checkProviderConflict` sees no remote change,
// `hasMeaningfulSyncData` doesn't flag non-empty-but-partial
// local, and the empty-vault prompt never fires.
//
// Escape hatch: a successful manual sync from Settings opens
// the gate via `syncNow`'s success path. That path runs the
// same per-provider inspect we use here, so a successful
// manual sync is equivalent to a successful startup inspect
// from the gate's point of view — the user's explicit click
// authorizes both the push and the subsequent auto-sync
// resumption. Until then, auto-sync stays paused and the
// "sync paused" toast is the user's signal to act.
if (attempt >= 4) return;
const delayMs = Math.min(240_000, 30_000 * 2 ** attempt);
attempt += 1;
timerId = setTimeout(tick, delayMs);
})();
};
tick();
return () => {
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
// Reset check flag when provider disconnects
// Reset check flags when provider disconnects
useEffect(() => {
if (!sync.hasAnyConnectedProvider) {
hasCheckedRemoteRef.current = false;
remoteCheckDoneRef.current = false;
}
}, [sync.hasAnyConnectedProvider]);
// On unmount, release any pending empty-vault confirmation. Without
// this, an unmount mid-dialog (window close, workspace switch) leaves
// the resolver promise dangling forever and the `checkRemoteVersion`
// finally block never sets remoteCheckDoneRef — in practice React
// tears down the hook first, but leaking the resolve callback and
// referenced remotePayload keeps them pinned by the awaiter until
// the next reload. Resolving with 'keep-empty' is the safe default:
// it mirrors the "don't touch remote" choice and leaves the version
// stamp untouched so the next mount re-prompts.
useEffect(() => {
return () => {
const resolve = emptyVaultResolveRef.current;
if (resolve) {
emptyVaultResolveRef.current = null;
resolve('keep-empty');
}
};
}, []);
const resolveEmptyVaultConflict = useCallback((action: 'restore' | 'keep-empty') => {
// Guard: resolve only once (prevents double-click from entering an
// inconsistent state). The ref is nulled immediately so subsequent
// calls are no-ops.
const resolve = emptyVaultResolveRef.current;
if (!resolve) return;
emptyVaultResolveRef.current = null;
resolve(action);
}, []);
return {
syncNow,
buildPayload,
isSyncing: sync.isSyncing,
isConnected: sync.hasAnyConnectedProvider,
autoSyncEnabled: sync.autoSyncEnabled,
emptyVaultConflict,
resolveEmptyVaultConflict,
};
};

View File

@@ -26,7 +26,9 @@ import {
import {
getCloudSyncManager,
type SyncManagerState,
type SyncEventCallback,
} from '../../infrastructure/services/CloudSyncManager';
import type { ShrinkFinding } from '../../domain/syncGuards';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { DeviceFlowState } from '../../infrastructure/services/adapters/GitHubAdapter';
@@ -55,7 +57,7 @@ export interface CloudSyncHook {
// Computed
hasAnyConnectedProvider: boolean;
connectedProviderCount: number;
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict';
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked';
// Master Key Actions
setupMasterKey: (password: string, confirmPassword: string) => Promise<void>;
@@ -86,11 +88,25 @@ export interface CloudSyncHook {
resetProviderStatus: (provider: CloudProvider) => void;
// Sync Actions
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
// Gist Revision History
getGistRevisionHistory: () => Promise<Array<{ version: string; date: Date }>>;
downloadGistRevision: (sha: string) => Promise<{
payload: SyncPayload;
meta: import('../../domain/sync').SyncFileMeta;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
} | null>;
// Settings
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
setDeviceName: (name: string) => void;
@@ -102,6 +118,12 @@ export interface CloudSyncHook {
formatLastSync: (timestamp?: number) => string;
getProviderDotColor: (provider: CloudProvider) => string;
refresh: () => void;
// Event subscription (for non-state events like SYNC_BLOCKED_SHRINK)
subscribeToEvents: (callback: SyncEventCallback) => () => void;
// Shrink-block state query (for banner hydration on mount)
getShrinkBlockedFinding: () => Extract<ShrinkFinding, { suspicious: true }> | null;
}
// ============================================================================
@@ -176,7 +198,8 @@ export const useCloudSync = (): CloudSyncHook => {
).length;
}, [state.providers]);
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' => {
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked' => {
if (state.syncState === 'BLOCKED') return 'blocked';
if (state.syncState === 'CONFLICT') return 'conflict';
if (state.syncState === 'ERROR') return 'error';
if (state.syncState === 'SYNCING') return 'syncing';
@@ -408,14 +431,14 @@ export const useCloudSync = (): CloudSyncHook => {
throw new Error('Vault is locked');
}, []);
const syncNowWithUnlock = useCallback(async (payload: SyncPayload) => {
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
await ensureUnlocked();
return await manager.syncAllProviders(payload);
return await manager.syncAllProviders(payload, opts);
}, [ensureUnlocked]);
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload) => {
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
await ensureUnlocked();
return await manager.syncToProvider(provider, payload);
return await manager.syncToProvider(provider, payload, opts);
}, [ensureUnlocked]);
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
@@ -423,6 +446,16 @@ export const useCloudSync = (): CloudSyncHook => {
return await manager.downloadFromProvider(provider);
}, [ensureUnlocked]);
const subscribeToEvents = useCallback(
(callback: SyncEventCallback) => manager.subscribe(callback),
[],
);
const getShrinkBlockedFinding = useCallback(
() => manager.getShrinkBlockedFinding(),
[],
);
const resolveConflictWithUnlock = useCallback(async (resolution: ConflictResolution) => {
await ensureUnlocked();
return await manager.resolveConflict(resolution);
@@ -475,6 +508,10 @@ export const useCloudSync = (): CloudSyncHook => {
syncToProvider: syncToProviderWithUnlock,
downloadFromProvider: downloadFromProviderWithUnlock,
resolveConflict: resolveConflictWithUnlock,
// Gist Revision History (#679)
getGistRevisionHistory: manager.getGistRevisionHistory.bind(manager),
downloadGistRevision: manager.downloadGistRevision.bind(manager),
// Settings
setAutoSync,
@@ -487,6 +524,12 @@ export const useCloudSync = (): CloudSyncHook => {
formatLastSync,
getProviderDotColor,
refresh,
// Event subscription
subscribeToEvents,
// Shrink-block state query
getShrinkBlockedFinding,
};
};

View File

@@ -1,20 +1,13 @@
/**
* useFileUpload - Handle file paste/drop with base64 conversion
* File upload conversion helpers for AI draft attachments.
*
* Supports images, PDFs, and other document types.
* Ported from 1code's use-agents-file-upload.ts
*/
import { useCallback, useState } from 'react';
import type { UploadedFile } from '../../infrastructure/ai/types';
import { getPathForFile } from '../../lib/sftpFileUtils';
export interface UploadedFile {
id: string;
filename: string;
dataUrl: string; // data:...;base64,... for preview
base64Data: string; // raw base64 for API
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
filePath?: string; // original filesystem path (Electron only)
}
export type { UploadedFile } from '../../infrastructure/ai/types';
/** Reject only known binary blobs that AI models can't process */
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
@@ -38,42 +31,32 @@ async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: str
});
}
export function useFileUpload() {
const [files, setFiles] = useState<UploadedFile[]>([]);
export async function convertFilesToUploads(inputFiles: File[]): Promise<UploadedFile[]> {
const supported = inputFiles.filter(isSupportedFile);
if (supported.length === 0) return [];
const addFiles = useCallback(async (inputFiles: File[]) => {
const supported = inputFiles.filter(isSupportedFile);
if (supported.length === 0) return;
const newFiles: UploadedFile[] = await Promise.all(
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
let dataUrl = '';
let base64Data = '';
try {
const result = await fileToDataUrl(file);
dataUrl = result.dataUrl;
base64Data = result.base64;
} catch (err) {
console.error('[useFileUpload] Failed to convert:', err);
}
const uploads: Array<UploadedFile | null> = await Promise.all(
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
try {
const result = await fileToDataUrl(file);
const filePath = getPathForFile(file);
return { id, filename, dataUrl, base64Data, mediaType, filePath };
}),
);
return {
id,
filename,
dataUrl: result.dataUrl,
base64Data: result.base64,
mediaType,
filePath,
};
} catch (err) {
console.error('[useFileUpload] Failed to convert:', err);
return null;
}
}),
);
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const removeFile = useCallback((id: string) => {
setFiles((prev) => prev.filter((f) => f.id !== id));
}, []);
const clearFiles = useCallback(() => {
setFiles([]);
}, []);
return { files, addFiles, removeFile, clearFiles };
return uploads.filter((upload): upload is UploadedFile => upload !== null);
}

View File

@@ -77,6 +77,7 @@ export const getTerminalPassthroughActions = (): Set<string> => {
return new Set([
'copy',
'paste',
'pasteSelection',
'selectAll',
'clearBuffer',
'searchTerminal',

View File

@@ -144,6 +144,7 @@ function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
function removeImmersiveStyle() {
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.immersiveTheme;
}
// ---------------------------------------------------------------------------
@@ -174,6 +175,7 @@ export function useImmersiveMode({
overrideActiveRef.current = true;
appliedFpRef.current = fp;
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
document.documentElement.dataset.immersiveTheme = fp;
}
}, [isTerminalTab, activeTerminalTheme]);

View File

@@ -0,0 +1,95 @@
import { useCallback, useEffect, useState } from 'react';
import {
type LocalVaultBackupPreview,
getLocalVaultBackupCapabilities,
getLocalVaultBackupMaxCount,
listLocalVaultBackups,
openLocalVaultBackupDir,
readLocalVaultBackup,
setLocalVaultBackupMaxCount,
trimLocalVaultBackups,
} from '../localVaultBackups';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function useLocalVaultBackups() {
const [backups, setBackups] = useState<LocalVaultBackupPreview[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [maxBackups, setMaxBackupsState] = useState(() => getLocalVaultBackupMaxCount());
// `null` while we're still asking the main process. The UI should treat
// `null` as "unknown, don't render restore controls yet" so we never expose
// a destructive action that might later be disabled.
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
const refreshBackups = useCallback(async () => {
setIsLoading(true);
try {
const next = await listLocalVaultBackups();
setBackups(next);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const caps = await getLocalVaultBackupCapabilities();
if (!cancelled) {
setEncryptionAvailable(caps.encryptionAvailable);
}
} catch {
if (!cancelled) {
setEncryptionAvailable(false);
}
}
})();
void refreshBackups();
return () => {
cancelled = true;
};
}, [refreshBackups]);
// Cross-window live refresh: the main process broadcasts when any
// renderer's createBackup or trimBackups actually mutated the on-disk
// set. Without this subscription, a protective backup written by the
// main window wouldn't show up in the Settings window's list until
// the user manually navigated away and back, silently under-reporting
// the most recent recovery points.
useEffect(() => {
const bridge = netcattyBridge.get();
const subscribe = bridge?.onVaultBackupsChanged;
if (typeof subscribe !== 'function') return undefined;
const unsubscribe = subscribe(() => {
void refreshBackups();
});
return () => {
try { unsubscribe?.(); } catch { /* ignore */ }
};
}, [refreshBackups]);
const updateMaxBackups = useCallback(async (value: number) => {
const sanitized = setLocalVaultBackupMaxCount(value);
setMaxBackupsState(sanitized);
await trimLocalVaultBackups(sanitized);
await refreshBackups();
return sanitized;
}, [refreshBackups]);
const openBackupDirectory = useCallback(async () => {
await openLocalVaultBackupDir();
}, []);
return {
backups,
isLoading,
maxBackups,
encryptionAvailable,
refreshBackups,
readBackup: readLocalVaultBackup,
setMaxBackups: updateMaxBackups,
openBackupDirectory,
};
}
export default useLocalVaultBackups;

View File

@@ -141,19 +141,48 @@ export const useSessionState = () => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
}, []);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === workspaceId) {
if (remainingWorkspaces.length > 0) {
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
} else {
setActiveTabId('vault');
}
}
return remainingWorkspaces;
});
}, [setActiveTabId]);
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
e?.stopPropagation();
// Pre-compute outside the setSessions updater so we don't depend on React
// having run the updater by the time we queue the microtask. React 18+ does
// not guarantee updater execution timing under concurrent scheduling.
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
const workspaceIdToMaybeClose =
sessionBeingClosed?.workspaceId &&
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
? sessionBeingClosed.workspaceId
: undefined;
setSessions(prevSessions => {
const targetSession = prevSessions.find(s => s.id === sessionId);
const wsId = targetSession?.workspaceId;
setWorkspaces(prevWorkspaces => {
let removedWorkspaceId: string | null = null;
let nextWorkspaces = prevWorkspaces;
let dissolvedWorkspaceId: string | null = null;
let lastRemainingSessionId: string | null = null;
if (wsId) {
nextWorkspaces = prevWorkspaces
.map(ws => {
@@ -163,7 +192,7 @@ export const useSessionState = () => {
removedWorkspaceId = ws.id;
return null;
}
// Check if only 1 session remains - dissolve workspace
const remainingSessionIds = collectSessionIds(pruned);
if (remainingSessionIds.length === 1) {
@@ -171,12 +200,12 @@ export const useSessionState = () => {
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
return { ...ws, root: pruned };
})
.filter((ws): ws is Workspace => Boolean(ws));
}
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
@@ -198,10 +227,10 @@ export const useSessionState = () => {
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
setActiveTabId(getFallback());
}
return nextWorkspaces;
});
// Check if we need to dissolve a workspace (convert remaining session to orphan)
if (targetSession?.workspaceId) {
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
@@ -218,29 +247,14 @@ export const useSessionState = () => {
}
}
}
return prevSessions.filter(s => s.id !== sessionId);
});
}, [workspaces, setActiveTabId]);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === workspaceId) {
if (remainingWorkspaces.length > 0) {
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
} else {
setActiveTabId('vault');
}
}
return remainingWorkspaces;
});
}, [setActiveTabId]);
return prevSessions.filter(s => s.id !== sessionId);
});
if (workspaceIdToMaybeClose) {
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
}
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
const startSessionRename = useCallback((sessionId: string) => {
setSessions(prevSessions => {
@@ -654,16 +668,22 @@ export const useSessionState = () => {
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
const newSessionId = crypto.randomUUID();
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
// Source may have been closed between the user's action and this
// update running; in that case skip entirely — do NOT switch the
// active tab or insert into tabOrder, which would leave dangling ids.
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// Create a new session with the same connection info
const newSession: TerminalSession = {
id: crypto.randomUUID(),
id: newSessionId,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
@@ -681,10 +701,40 @@ export const useSessionState = () => {
localShellIcon: session.localShellIcon,
};
setActiveTabId(newSession.id);
// Schedule the activeTab + tabOrder updates only when creation
// actually happens. These nested setStates are idempotent, so
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
// Fast path: source is already tracked in tabOrder — splice directly.
const directIdx = prevTabOrder.indexOf(sessionId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, newSessionId);
return next;
}
// Fallback: source is only in the derived tab collections. Rebuild the
// effective order (same pattern as reorderTabs) to locate its position.
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sessionId);
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, newSessionId);
return next;
});
return [...prevSessions, newSession];
});
}, [setActiveTabId]);
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {

View File

@@ -4,6 +4,7 @@ import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -33,10 +34,13 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
@@ -69,6 +73,9 @@ const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
const DEFAULT_SHOW_RECENT_HOSTS = true;
const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
const DEFAULT_SHOW_SFTP_TAB = true;
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -195,6 +202,17 @@ export const useSettingsState = () => {
});
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
const [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState<boolean>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
if (stored !== null) return stored === 'true';
// First time seeing this key. For genuinely fresh installs (no existing
// terminal theme in storage) default ON so the terminal matches the app
// theme out of the box. For upgrades from an older version (existing
// terminal theme present) default OFF to avoid silently overriding the
// user's manual choice.
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
return !isUpgrade;
});
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
@@ -247,6 +265,18 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
});
const [showRecentHosts, setShowRecentHostsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
return stored ?? DEFAULT_SHOW_RECENT_HOSTS;
});
const [showOnlyUngroupedHostsInRoot, setShowOnlyUngroupedHostsInRootState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
});
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
return stored ?? DEFAULT_SHOW_SFTP_TAB;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -450,6 +480,12 @@ export const useSettingsState = () => {
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
setShowRecentHostsState(storedShowRecentHosts ?? DEFAULT_SHOW_RECENT_HOSTS);
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -539,6 +575,10 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
const next = value === true || value === 'true';
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
}
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
setTerminalFontFamilyId(value);
}
@@ -642,18 +682,20 @@ export const useSettingsState = () => {
const settingsSnapshotRef = useRef({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
};
@@ -732,6 +774,13 @@ export const useSettingsState = () => {
setTerminalThemeId(e.newValue);
}
}
// Sync follow-app-theme toggle from other windows
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
const next = e.newValue === 'true';
if (next !== s.followAppTerminalTheme) {
setFollowAppTerminalThemeState(next);
}
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
if (e.newValue !== s.terminalFontFamilyId) {
@@ -810,6 +859,24 @@ export const useSettingsState = () => {
setSftpDefaultViewMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_RECENT_HOSTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showRecentHosts) {
setShowRecentHostsState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
setShowOnlyUngroupedHostsInRootState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showSftpTab) {
setShowSftpTabState(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -849,6 +916,12 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
}, [terminalThemeId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
}, [followAppTerminalTheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
@@ -893,6 +966,27 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_HOTKEY_RECORDING, isRecording);
}, [notifySettingsChanged]);
const setShowRecentHosts = useCallback((enabled: boolean) => {
setShowRecentHostsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
}, [notifySettingsChanged]);
const setShowOnlyUngroupedHostsInRoot = useCallback((enabled: boolean) => {
setShowOnlyUngroupedHostsInRootState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
}, [notifySettingsChanged]);
const setShowSftpTab = useCallback((enabled: boolean) => {
setShowSftpTabState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
// Always apply CSS to document (needed on mount)
@@ -1116,12 +1210,21 @@ export const useSettingsState = () => {
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId)
const currentTerminalTheme = useMemo(() => {
// When "Follow Application Theme" is enabled, pick the terminal theme
// whose background matches the active UI theme preset.
if (followAppTerminalTheme) {
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
if (mapped) {
const found = TERMINAL_THEMES.find(t => t.id === mapped);
if (found) return found;
}
}
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0],
[terminalThemeId, customThemes]
);
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
@@ -1156,6 +1259,8 @@ export const useSettingsState = () => {
setUiLanguage,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
@@ -1187,6 +1292,12 @@ export const useSettingsState = () => {
setSftpAutoOpenSidebar,
sftpDefaultViewMode,
setSftpDefaultViewMode,
showRecentHosts,
setShowRecentHosts,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1225,6 +1336,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
customThemes, workspaceFocusStyle,
]),
};

View File

@@ -128,6 +128,22 @@ export const useTerminalBackend = () => {
return bridge.getSessionPwd(sessionId);
}, []);
const getSessionRemoteInfo = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionRemoteInfo) {
return { success: false, error: 'getSessionRemoteInfo unavailable' };
}
return bridge.getSessionRemoteInfo(sessionId);
}, []);
const getSessionDistroInfo = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionDistroInfo) {
return { success: false, error: 'getSessionDistroInfo unavailable' };
}
return bridge.getSessionDistroInfo(sessionId);
}, []);
const getServerStats = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) return { success: false, error: 'getServerStats unavailable' };
@@ -150,6 +166,8 @@ export const useTerminalBackend = () => {
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,

View File

@@ -102,6 +102,7 @@ const safeParse = <T,>(value: string | null): T | null => {
};
export const useVaultState = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
const [identities, setIdentities] = useState<Identity[]>([]);
@@ -339,129 +340,133 @@ export const useVaultState = () => {
useEffect(() => {
const init = async () => {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
try {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
if (savedHosts) {
// Capture version before the async gap so that any write occurring
// during decryption (storage event, user edit) advances the counter
// and causes this stale result to be discarded.
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (ver === hostsWriteVersion.current) {
const sanitized = decrypted.map(sanitizeHost);
setHosts(sanitized);
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
if (savedHosts) {
// Capture version before the async gap so that any write occurring
// during decryption (storage event, user edit) advances the counter
// and causes this stale result to be discarded.
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (ver === hostsWriteVersion.current) {
const sanitized = decrypted.map(sanitizeHost);
setHosts(sanitized);
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
}
} else {
updateHosts(INITIAL_HOSTS);
}
} else {
updateHosts(INITIAL_HOSTS);
}
// Read keys fresh here (not before the hosts await) so we don't apply
// a stale snapshot if keys were updated during host decryption.
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
// Read keys fresh here (not before the hosts await) so we don't apply
// a stale snapshot if keys were updated during host decryption.
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
// Decrypt sensitive fields (passphrase, privateKey)
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (keyVer === keysWriteVersion.current) {
setKeys(decryptedKeys);
encryptKeys(decryptedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
// Decrypt sensitive fields (passphrase, privateKey)
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (keyVer === keysWriteVersion.current) {
setKeys(decryptedKeys);
encryptKeys(decryptedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
// Read identities fresh here (not before the hosts/keys awaits) so we
// don't apply a stale snapshot if identities were updated during prior decryption.
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
if (savedIdentities) {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (idVer === identitiesWriteVersion.current) {
setIdentities(decryptedIds);
encryptIdentities(decryptedIds).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}
}
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
// Read identities fresh here (not before the hosts/keys awaits) so we
// don't apply a stale snapshot if identities were updated during prior decryption.
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
if (savedIdentities) {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (idVer === identitiesWriteVersion.current) {
setIdentities(decryptedIds);
encryptIdentities(decryptedIds).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}
}
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
if (savedGroupConfigs) {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
setGroupConfigs(decryptedGC);
encryptGroupConfigs(decryptedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
if (savedGroupConfigs) {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
setGroupConfigs(decryptedGC);
encryptGroupConfigs(decryptedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}
}
} finally {
setIsInitialized(true);
}
};
@@ -657,6 +662,7 @@ export const useVaultState = () => {
);
return {
isInitialized,
hosts,
keys,
identities,

View File

@@ -43,6 +43,8 @@ import {
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -61,6 +63,29 @@ export interface SyncableVaultData {
groupConfigs?: GroupConfig[];
}
/**
* Returns true when the payload contains any meaningful user data worth
* protecting or syncing.
*/
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.knownHosts?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
@@ -83,7 +108,8 @@ const SYNCABLE_TERMINAL_KEYS = [
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'osc52Clipboard',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
] as const;
@@ -173,6 +199,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
if (showRecent != null) settings.showRecentHosts = showRecent;
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
return Object.keys(settings).length > 0 ? settings : undefined;
}
@@ -238,6 +268,15 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
// Immersive mode (legacy — always enabled, ignore incoming value)
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
if (settings.showOnlyUngroupedHostsInRoot != null) {
localStorageAdapter.writeBoolean(
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
settings.showOnlyUngroupedHostsInRoot,
);
}
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
}
// ---------------------------------------------------------------------------

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
* - Sync status and conflict resolution
*/
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import {
AlertTriangle,
Check,
@@ -17,11 +17,13 @@ import {
Download,
Database,
ExternalLink,
FolderOpen,
Eye,
EyeOff,
Github,
Key,
Loader2,
History,
RefreshCw,
Settings,
Server,
@@ -31,11 +33,19 @@ import {
X,
} from 'lucide-react';
import { useCloudSync } from '../application/state/useCloudSync';
import { useLocalVaultBackups } from '../application/state/useLocalVaultBackups';
import {
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
withRestoreBarrier,
} from '../application/localVaultBackups';
import { useI18n } from '../application/i18n/I18nProvider';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type SyncResult, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
import type { ShrinkFinding } from '../domain/syncGuards';
import { SyncBlockedBanner } from './sync/SyncBlockedBanner';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
@@ -285,6 +295,7 @@ interface ProviderCardProps {
onCancelConnect?: () => void;
onDisconnect: () => void;
onSync: () => void;
extraActions?: React.ReactNode;
}
const ProviderCard: React.FC<ProviderCardProps> = ({
@@ -303,6 +314,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
onCancelConnect,
onDisconnect,
onSync,
extraActions,
}) => {
const { t } = useI18n();
const formatLastSync = (timestamp?: number): string => {
@@ -395,6 +407,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
)}
{t('cloudSync.provider.sync')}
</Button>
{extraActions}
{onEdit && (
<Button
size="sm"
@@ -624,10 +637,421 @@ const ConflictModal: React.FC<ConflictModalProps> = ({
interface SyncDashboardProps {
onBuildPayload: () => SyncPayload;
onApplyPayload: (payload: SyncPayload) => void;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
}
interface LocalBackupsPanelProps {
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
/**
* When true, the panel hides the Restore button entirely — e.g. while the
* master key has not been configured yet, a restore would land credentials
* on disk in plaintext (I3). Listing is still allowed so users can see that
* their history exists.
*/
restoreDisabledReason?: 'no-master-key' | null;
}
const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
onApplyPayload,
restoreDisabledReason = null,
}) => {
const { t, resolvedLocale } = useI18n();
const {
backups,
isLoading,
maxBackups,
encryptionAvailable,
refreshBackups,
readBackup,
setMaxBackups,
openBackupDirectory,
} = useLocalVaultBackups();
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
// Backup chosen in the list but not yet confirmed. A two-step flow keeps
// users from wiping their vault with a single accidental click (I2).
const [pendingRestoreBackup, setPendingRestoreBackup] = useState<
(typeof backups)[number] | null
>(null);
useEffect(() => {
setMaxBackupsInput(String(maxBackups));
}, [maxBackups]);
const formatTimestamp = (timestamp: number) =>
new Date(timestamp).toLocaleString(resolvedLocale || undefined);
const getReasonLabel = (reason: 'app_version_change' | 'before_restore') =>
reason === 'app_version_change'
? t('cloudSync.localBackups.reason.appVersionChange')
: t('cloudSync.localBackups.reason.beforeRestore');
const handleSaveMaxBackups = async () => {
// Validate BEFORE calling setMaxBackups, which hands off to the
// renderer's `sanitizeLocalVaultBackupMaxCount` clamp. Two failure
// modes must be surfaced rather than silently clamped, because
// both produce a misleading "saved" toast:
//
// 1. Empty / non-numeric input — `Number("")` coerces to 0 and
// sanitize clamps to the default (20). A user who meant to
// clear the field then re-type would see their retention
// silently reset to 20 with a success message.
//
// 2. Out-of-range input (e.g. 500) — sanitize clamps to 100 and
// still reports success, but the visible error string says
// "between 1 and 100", so the user has no idea their value
// was changed. Reject explicitly instead.
//
// The 1..MAX range check mirrors the main-process `sanitizeMaxCount`
// in vaultBackupBridge.cjs so renderer and bridge agree.
const parsed = Number(maxBackupsInput);
const inRange =
Number.isFinite(parsed) &&
parsed >= MIN_LOCAL_VAULT_BACKUP_MAX_COUNT &&
parsed <= MAX_LOCAL_VAULT_BACKUP_MAX_COUNT;
if (!inRange || maxBackupsInput.trim() === '') {
toast.error(
t('cloudSync.localBackups.maxInvalid'),
t('sync.toast.errorTitle'),
);
return;
}
setIsSavingMaxBackups(true);
try {
const next = await setMaxBackups(parsed);
setMaxBackupsInput(String(next));
toast.success(t('cloudSync.localBackups.maxSaved', { count: String(next) }));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.toast.errorTitle'),
);
} finally {
setIsSavingMaxBackups(false);
}
};
const handleOpenBackupDirectory = async () => {
try {
await openBackupDirectory();
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.toast.errorTitle'),
);
}
};
const performRestore = async (backupId: string) => {
setRestoringBackupId(backupId);
try {
// Hold the cross-window restore barrier around both the load
// and the apply so another window's auto-sync cannot push a
// pre-restore snapshot concurrently. See `withRestoreBarrier`
// in application/localVaultBackups.ts for the read-side in
// useAutoSync.
//
// In-memory React state refresh is implicit: `onApplyPayload`
// (supplied by the hosting screen) routes through
// `applySyncPayload` → `importDataFromString` → store writes
// → the hook-store listeners in `useVaultState` /
// `useCustomThemes` / etc. We do NOT explicitly re-pull host
// lists here because a future refactor that decouples those
// stores from the apply path would silently break the UI
// refresh in a way that's only visible after a manual
// restart. Any change to that chain must either preserve
// store-listener notification OR add an explicit
// `rehydrateAllFromStorage` call here — do not assume
// restore is "just" a payload swap.
await withRestoreBarrier(async () => {
const detail = await readBackup(backupId);
if (!detail) {
throw new Error(t('cloudSync.localBackups.restoreMissing'));
}
await Promise.resolve(onApplyPayload(detail.payload));
});
await refreshBackups();
toast.success(t('cloudSync.localBackups.restoreSuccess'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.localBackups.restoreFailedTitle'),
);
} finally {
setRestoringBackupId(null);
}
};
const restoreAllowed = restoreDisabledReason === null;
// While encryptionAvailable is still `null` we're mid-probe — render the
// restore button as disabled so the user never sees a path they can't
// actually take (I1 surface). Once resolved, `false` hides the panel body
// via the unavailable banner below.
const encryptionResolved = encryptionAvailable !== null;
const encryptionUsable = encryptionAvailable === true;
// safeStorage probe finished and returned "not available" → disable the
// panel entirely; the main process refuses to write in this state (I1).
if (encryptionResolved && !encryptionUsable) {
return (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle size={16} />
<span className="text-sm font-medium">
{t('cloudSync.localBackups.unavailableTitle')}
</span>
</div>
<div className="text-xs text-muted-foreground">
{t('cloudSync.localBackups.unavailableDesc')}
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="max-w-lg">
<div className="text-sm font-medium">{t('cloudSync.localBackups.retentionTitle')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.retentionDesc')}
</div>
</div>
<div className="space-y-2 md:min-w-[260px] md:shrink-0">
<div className="flex items-end gap-2 md:justify-end">
<Input
type="number"
min={1}
max={100}
value={maxBackupsInput}
onChange={(e) => setMaxBackupsInput(e.target.value)}
className="w-28"
/>
<Button
variant="outline"
onClick={() => void handleSaveMaxBackups()}
disabled={isSavingMaxBackups}
className="gap-2"
>
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
{t('common.save')}
</Button>
</div>
</div>
</div>
</div>
{!restoreAllowed && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
<AlertTriangle size={14} />
<span className="font-medium">
{t('cloudSync.localBackups.lockedTitle')}
</span>
</div>
{t('cloudSync.localBackups.lockedDesc')}
</div>
)}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.desc')}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => void refreshBackups()}
disabled={isLoading}
className="gap-1"
>
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
{t('settings.system.refresh')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void handleOpenBackupDirectory()}
className="gap-1"
>
<FolderOpen size={14} />
{t('settings.system.openFolder')}
</Button>
</div>
</div>
{backups.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
{t('cloudSync.localBackups.empty')}
</div>
) : (
<div className="space-y-2">
{backups.map((backup) => (
<div
key={backup.id}
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
{backup.syncDataVersion
? `v${backup.syncDataVersion}`
: formatTimestamp(backup.createdAt)}
</div>
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
<span>{getReasonLabel(backup.reason)}</span>
{backup.syncDataVersion && (
<>
<span aria-hidden="true">·</span>
<span>{formatTimestamp(backup.createdAt)}</span>
</>
)}
{backup.sourceAppVersion && backup.targetAppVersion && (
<>
<span aria-hidden="true">·</span>
<span>
{t('cloudSync.localBackups.versionChange', {
from: backup.sourceAppVersion,
to: backup.targetAppVersion,
})}
</span>
</>
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.counts', {
hosts: String(backup.preview.hostCount),
keys: String(backup.preview.keyCount),
snippets: String(backup.preview.snippetCount),
})}
</div>
</div>
{restoreAllowed && (
<Button
size="sm"
variant="outline"
onClick={() => setPendingRestoreBackup(backup)}
// Disable every row while ANY restore is in
// flight. Each restore runs a full
// `applyProtectedSyncPayload` — multiple
// localStorage writes + the apply-in-progress
// sentinel. `withRestoreBarrier` serializes
// across windows but does NOT serialize
// same-window re-entry, so two overlapping
// clicks here would interleave destructive
// writes and the second run's sentinel-clear
// could mask a still-partial first apply.
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId === backup.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t('cloudSync.localBackups.restore')}
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Restore confirmation dialog (I2). Keeps the destructive action
gated behind an explicit second click, mirroring the clear-local
dialog elsewhere in this screen. */}
<Dialog
open={pendingRestoreBackup !== null}
onOpenChange={(open) => {
if (!open) setPendingRestoreBackup(null);
}}
>
<DialogContent className="sm:max-w-[440px] z-[70]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle size={20} />
{t('cloudSync.localBackups.restoreConfirmTitle')}
</DialogTitle>
<DialogDescription>
{t('cloudSync.localBackups.restoreConfirmDesc')}
</DialogDescription>
</DialogHeader>
{pendingRestoreBackup && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
<div className="font-medium">
{pendingRestoreBackup.syncDataVersion
? `v${pendingRestoreBackup.syncDataVersion}`
: formatTimestamp(pendingRestoreBackup.createdAt)}
</div>
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
{pendingRestoreBackup.syncDataVersion && (
<>
<span aria-hidden="true">·</span>
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
</>
)}
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
<>
<span aria-hidden="true">·</span>
<span>
{t('cloudSync.localBackups.versionChange', {
from: pendingRestoreBackup.sourceAppVersion,
to: pendingRestoreBackup.targetAppVersion,
})}
</span>
</>
)}
</div>
<div className="text-muted-foreground">
{t('cloudSync.localBackups.counts', {
hosts: String(pendingRestoreBackup.preview.hostCount),
keys: String(pendingRestoreBackup.preview.keyCount),
snippets: String(pendingRestoreBackup.preview.snippetCount),
})}
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setPendingRestoreBackup(null)}
disabled={restoringBackupId !== null}
>
{t('cloudSync.localBackups.restoreConfirmCancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
const target = pendingRestoreBackup;
if (!target) return;
setPendingRestoreBackup(null);
await performRestore(target.id);
}}
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId !== null ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t('cloudSync.localBackups.restoreConfirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onApplyPayload,
@@ -715,6 +1139,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Conflict modal
const [showConflictModal, setShowConflictModal] = useState(false);
// Gist revision history (#679)
const [showHistoryModal, setShowHistoryModal] = useState(false);
const [historyRevisions, setHistoryRevisions] = useState<Array<{ version: string; date: Date }>>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyPreview, setHistoryPreview] = useState<{
sha: string;
payload: SyncPayload;
preview: { hostCount: number; keyCount: number; snippetCount: number; identityCount: number; portForwardingRuleCount: number };
deviceName?: string;
version?: number;
} | null>(null);
const [historyPreviewLoading, setHistoryPreviewLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
// Change master key dialog
const [showChangeKeyDialog, setShowChangeKeyDialog] = useState(false);
const [currentMasterKey, setCurrentMasterKey] = useState('');
@@ -762,6 +1200,17 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Clear local data dialog
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
// Sync-blocked banner (Task 7) + force-push confirmation modal (Task 8)
const [blockedFinding, setBlockedFinding] = useState<Extract<ShrinkFinding, { suspicious: true }> | null>(null);
const [showForcePushConfirm, setShowForcePushConfirm] = useState(false);
// Ref for scrolling to LocalBackupsPanel when the banner's Restore button is clicked
const localBackupsRef = useRef<HTMLDivElement>(null);
// Active tab state — lets the banner's "Restore" button switch to the
// local-backups tab without a separate DOM query.
const [activeTab, setActiveTab] = useState<'providers' | 'status'>('providers');
const ensureSyncablePayload = useCallback(
(payload: SyncPayload): boolean => {
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
@@ -780,6 +1229,35 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
}
}, [sync.currentConflict]);
// Subscribe to sync events to show/clear the blocked-shrink banner.
// Destructure the stable useCallback reference so the effect runs once on
// mount rather than re-subscribing on every render when `sync` object ref changes.
const { subscribeToEvents, getShrinkBlockedFinding } = sync;
// Hydrate from current manager state in case a shrink-block happened
// before this component mounted (e.g., auto-sync ran while the user
// was on a different tab). Without this, the banner only shows
// blocks that occur after Settings is open.
useEffect(() => {
const existing = getShrinkBlockedFinding();
if (existing) {
setBlockedFinding(existing);
}
}, [getShrinkBlockedFinding]);
useEffect(() => {
const unsub = subscribeToEvents((event) => {
if (event.type === 'SYNC_BLOCKED_SHRINK') {
if (event.finding.suspicious) {
setBlockedFinding(event.finding);
}
} else if (event.type === 'SYNC_BLOCKED_CLEARED') {
setBlockedFinding(null);
}
});
return unsub;
}, [subscribeToEvents]);
// If we have a master key but we're still locked (e.g. older installs),
// prompt once and persist the password via safeStorage.
useEffect(() => {
@@ -994,7 +1472,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
if (result.success) {
// Apply merged data if a three-way merge happened
if (result.mergedPayload && onApplyPayload) {
onApplyPayload(result.mergedPayload);
await Promise.resolve(onApplyPayload(result.mergedPayload));
}
toast.success(t('cloudSync.sync.success', { provider }));
} else if (result.conflictDetected) {
@@ -1012,13 +1490,49 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
try {
const payload = await sync.resolveConflict(resolution);
if (payload && resolution === 'USE_REMOTE') {
onApplyPayload(payload);
// USE_REMOTE applies cloud data over local — same data-loss
// shape as a local backup restore, so gate auto-sync in
// every other window the same way.
await withRestoreBarrier(async () => {
await Promise.resolve(onApplyPayload(payload));
});
toast.success(t('cloudSync.resolve.downloaded'));
} else if (resolution === 'USE_LOCAL') {
// Re-sync with local data
// Re-sync with local data. Hold the same cross-window
// restore barrier that USE_REMOTE uses: without it, a
// concurrent auto-sync tick in another window can slip
// between our conflict resolution and the upload,
// producing a second upload path with stale state that
// races against this push. USE_LOCAL doesn't mutate the
// renderer's in-memory state (no onApplyPayload call), so
// the barrier is belt-and-suspenders against the other
// window's push, not ours.
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) return;
await sync.syncNow(localPayload);
let results: Map<CloudProvider, SyncResult> | null = null;
await withRestoreBarrier(async () => {
results = await sync.syncNow(localPayload, { overrideShrink: true });
});
if (results) {
// Apply any merged payload BEFORE closing the modal so local state
// reflects what's now on cloud (in case remote changed during the merge).
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
break;
}
}
const allOk = Array.from((results as Map<CloudProvider, SyncResult>).values()).every((r) => r.success);
if (!allOk) {
const firstError = Array.from((results as Map<CloudProvider, SyncResult>).values())
.find((r) => !r.success)?.error
?? t('common.unknownError');
toast.error(firstError, t('cloudSync.resolve.failedTitle'));
return; // KEEP the modal open so user can retry / pick USE_REMOTE
}
}
toast.success(t('cloudSync.resolve.uploaded'));
}
setShowConflictModal(false);
@@ -1030,6 +1544,65 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
}
};
// -- Gist revision history handlers --
const handleOpenHistory = async () => {
setShowHistoryModal(true);
setHistoryLoading(true);
setHistoryError(null);
setHistoryPreview(null);
setHistoryRevisions([]);
try {
const revisions = await sync.getGistRevisionHistory();
setHistoryRevisions(revisions);
} catch (err) {
setHistoryError(err instanceof Error ? err.message : t('common.unknownError'));
} finally {
setHistoryLoading(false);
}
};
const handlePreviewRevision = async (sha: string) => {
setHistoryPreviewLoading(true);
setHistoryError(null);
try {
const result = await sync.downloadGistRevision(sha);
if (result) {
setHistoryPreview({
sha,
payload: result.payload,
preview: result.preview,
deviceName: result.meta.deviceName,
version: result.meta.version,
});
} else {
setHistoryError(t('cloudSync.revisionHistory.revisionNotFound'));
}
} catch {
// Decrypt failures can manifest as various error types:
// "Decryption failed", OperationError, "unable to authenticate
// data", AES-GCM tag mismatch, etc. Show the friendly message
// for any error originating from the decrypt step; network
// errors would have been caught by the fetch layer already.
setHistoryError(t('cloudSync.revisionHistory.decryptFailed'));
} finally {
setHistoryPreviewLoading(false);
}
};
const handleRestoreRevision = async () => {
if (!historyPreview) return;
// Gist revision restore is a destructive "replace local with cloud
// snapshot" op — same shape as a local backup restore, same
// cross-window race to block.
await withRestoreBarrier(async () => {
await Promise.resolve(onApplyPayload(historyPreview.payload));
});
toast.success(t('cloudSync.revisionHistory.restored'));
setShowHistoryModal(false);
setHistoryPreview(null);
};
return (
<div className="space-y-6">
{/* Header with status */}
@@ -1070,7 +1643,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</div>
</div>
<Tabs defaultValue="providers" className="space-y-4">
{blockedFinding && (
<SyncBlockedBanner
finding={blockedFinding}
onRestore={() => {
setActiveTab('status');
requestAnimationFrame(() => {
localBackupsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}}
onForcePush={() => setShowForcePushConfirm(true)}
/>
)}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
<TabsTrigger value="status">{t('cloudSync.status.title')}</TabsTrigger>
@@ -1091,6 +1677,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
onConnect={handleConnectGitHub}
onDisconnect={() => sync.disconnectProvider('github')}
onSync={() => handleSync('github')}
extraActions={
isProviderReadyForSync(sync.providers.github) ? (
<Button size="sm" variant="ghost" onClick={handleOpenHistory} className="gap-1">
<History size={14} />
{t('cloudSync.revisionHistory.viewButton')}
</Button>
) : undefined
}
/>
<ProviderCard
@@ -1247,6 +1841,12 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</div>
)}
<div ref={localBackupsRef}>
<LocalBackupsPanel
onApplyPayload={onApplyPayload}
/>
</div>
{/* Clear Local Data */}
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
<div className="flex items-center justify-between">
@@ -1291,6 +1891,113 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
onClose={() => setShowConflictModal(false)}
/>
{/* Gist Revision History Modal (#679) */}
<Dialog open={showHistoryModal} onOpenChange={setShowHistoryModal}>
<DialogContent className="sm:max-w-[520px] max-h-[80vh] overflow-hidden flex flex-col z-[70]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History size={18} />
{t('cloudSync.revisionHistory.title')}
</DialogTitle>
<DialogDescription>{t('cloudSync.revisionHistory.description')}</DialogDescription>
</DialogHeader>
{historyError && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-sm text-red-500">
{historyError}
</div>
)}
{historyLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
) : historyPreview ? (
// Preview of a selected revision
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
<div className="rounded-lg border p-4 space-y-2">
<div className="text-sm font-medium">{t('cloudSync.revisionHistory.revisionPreview')}</div>
{historyPreview.deviceName && (
<div className="text-xs text-muted-foreground">
{t('cloudSync.revisionHistory.device')}: {historyPreview.deviceName}
{historyPreview.version != null && ` · v${historyPreview.version}`}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.hosts')}</span>
<span className="font-medium">{historyPreview.preview.hostCount}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.keys')}</span>
<span className="font-medium">{historyPreview.preview.keyCount}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.snippets')}</span>
<span className="font-medium">{historyPreview.preview.snippetCount}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.identities')}</span>
<span className="font-medium">{historyPreview.preview.identityCount}</span>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setHistoryPreview(null)}>
{t('common.back')}
</Button>
<Button onClick={handleRestoreRevision} className="gap-1">
<Download size={14} />
{t('cloudSync.revisionHistory.restoreButton')}
</Button>
</DialogFooter>
</div>
) : (
// Revision list
<div className="overflow-y-auto flex-1 min-h-0 -mx-1">
{historyRevisions.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-8">
{t('cloudSync.revisionHistory.empty')}
</div>
) : (
<div className="space-y-1 px-1">
{historyRevisions.map((rev, index) => (
<button
key={rev.version}
onClick={() => handlePreviewRevision(rev.version)}
disabled={historyPreviewLoading}
className={cn(
"w-full flex items-center justify-between p-2.5 rounded-lg text-left text-sm transition-colors",
"hover:bg-accent border border-transparent hover:border-border",
index === 0 && "bg-primary/5 border-primary/20",
)}
>
<div>
<div className="font-medium">
{index === 0 ? t('cloudSync.revisionHistory.current') : `${t('cloudSync.revisionHistory.revision')} #${historyRevisions.length - index}`}
</div>
<div className="text-xs text-muted-foreground">
{rev.date.toLocaleString()}
</div>
</div>
<div className="text-xs text-muted-foreground font-mono">
{rev.version.slice(0, 7)}
</div>
</button>
))}
</div>
)}
</div>
)}
{historyPreviewLoading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center rounded-lg">
<Loader2 size={24} className="animate-spin" />
</div>
)}
</DialogContent>
</Dialog>
<Dialog open={showWebdavDialog} onOpenChange={setShowWebdavDialog}>
<DialogContent className="sm:max-w-[460px] max-h-[80vh] overflow-y-auto z-[70]">
<DialogHeader>
@@ -1758,6 +2465,69 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Force-push confirmation modal (Task 8) */}
{showForcePushConfirm && blockedFinding && (
<Dialog open onOpenChange={(open) => !open && setShowForcePushConfirm(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('sync.forcePush.title')}</DialogTitle>
</DialogHeader>
<p className="text-sm">
{t('sync.forcePush.body', {
lost: blockedFinding.lost,
entityType: t(`sync.entityType.${blockedFinding.entityType}`),
})}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setShowForcePushConfirm(false)}>
{t('sync.forcePush.cancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) {
setShowForcePushConfirm(false);
return;
}
setShowForcePushConfirm(false);
try {
const results = await sync.syncNow(localPayload, { overrideShrink: true });
// Apply any merged payload BEFORE clearing the banner. If a merge happened
// during force-push (remote changed), the merged result is what the cloud
// now has — applying it to local state prevents the next sync from
// re-deleting the remote additions we just merged in.
for (const result of results.values()) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
break; // All providers share the same merged payload
}
}
const allOk = Array.from(results.values()).every((r) => r.success);
if (allOk) {
setBlockedFinding(null);
} else {
// Surface the failure but KEEP the banner so the user can retry or
// restore. Find the first error string to display.
const firstError = Array.from(results.values())
.find((r) => !r.success)
?.error ?? t('sync.toast.errorTitle');
toast.error(firstError, t('sync.toast.errorTitle'));
}
} catch (err) {
toast.error(String(err), t('sync.toast.errorTitle'));
}
}}
>
{t('sync.forcePush.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
};
@@ -1768,7 +2538,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
interface CloudSyncSettingsProps {
onBuildPayload: () => SyncPayload;
onApplyPayload: (payload: SyncPayload) => void;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
}
@@ -1778,7 +2548,19 @@ export const CloudSyncSettings: React.FC<CloudSyncSettingsProps> = (props) => {
// Simplified UX: once a master key is configured, we auto-unlock via safeStorage
// so users don't have to manage a separate LOCKED screen.
if (securityState === 'NO_KEY') {
return <GatekeeperScreen onSetupComplete={() => { }} />;
return (
<div className="space-y-6">
<GatekeeperScreen onSetupComplete={() => { }} />
{/* The master key is not configured yet. Expose the backup
history for diagnostic purposes but refuse restores: the
vault encryption layer can't re-protect the restored
credentials until the user finishes master-key setup (I3). */}
<LocalBackupsPanel
onApplyPayload={props.onApplyPayload}
restoreDisabledReason="no-master-key"
/>
</div>
);
}
return <SyncDashboard {...props} />;

View File

@@ -67,27 +67,27 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
<DialogHeader>
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
<DialogTitle>{t('dialog.createWorkspace.title', { defaultValue: 'Create Workspace' })}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
<div className="space-y-2">
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
<Label htmlFor="workspace-name">{t('field.name', { defaultValue: 'Name' })}</Label>
<Input
id="workspace-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
placeholder={t('placeholder.workspaceName', { defaultValue: 'Workspace Name' })}
autoFocus
/>
</div>
<div className="space-y-2 flex-1 flex flex-col min-h-0">
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
<Label>{t('field.selectHosts', { defaultValue: 'Select Hosts' })}</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
@@ -99,7 +99,7 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
<div className="p-2 space-y-1">
{filteredHosts.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
{t('common.noResults', 'No hosts found')}
{t('common.noResults', { defaultValue: 'No hosts found' })}
</div>
) : (
filteredHosts.map(host => {
@@ -126,15 +126,15 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
</ScrollArea>
</div>
<div className="text-xs text-muted-foreground text-right">
{selectedHostIds.size} {t('common.selected', 'selected')}
{selectedHostIds.size} {t('common.selected', { defaultValue: 'selected' })}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', { defaultValue: 'Cancel' })}</Button>
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
{t('common.create', 'Create')}
{t('common.create', { defaultValue: 'Create' })}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -22,6 +22,16 @@ export const DISTRO_LOGOS: Record<string, string> = {
macos: "/distro/macos.svg",
windows: "/distro/windows.svg",
linux: "/distro/linux.svg",
// Network device vendors — auto-detected from the SSH server
// identification string (see domain/host.ts `detectVendorFromSshVersion`).
cisco: "/distro/cisco.svg",
juniper: "/distro/juniper.svg",
huawei: "/distro/huawei.svg",
hpe: "/distro/hpe.svg",
mikrotik: "/distro/mikrotik.svg",
fortinet: "/distro/fortinet.svg",
paloalto: "/distro/paloalto.svg",
zyxel: "/distro/zyxel.svg",
};
export const DISTRO_COLORS: Record<string, string> = {
@@ -42,6 +52,15 @@ export const DISTRO_COLORS: Record<string, string> = {
macos: "bg-[#333333]",
windows: "bg-[#0078D4]",
linux: "bg-[#333333]",
// Network device vendor brand colors
cisco: "bg-[#1BA0D7]",
juniper: "bg-[#0A6EB4]",
huawei: "bg-[#CF0A2C]",
hpe: "bg-[#01A982]",
mikrotik: "bg-[#293239]",
fortinet: "bg-[#EE3124]",
paloalto: "bg-[#FA582D]",
zyxel: "bg-[#00497A]",
default: "bg-slate-600",
};

View File

@@ -21,6 +21,7 @@ import {
import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { customThemeStore } from "../application/state/customThemeStore";
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
import { cn } from "../lib/utils";
import {
EnvVar,
@@ -39,6 +40,7 @@ import {
import {
AsidePanel,
AsidePanelContent,
type AsidePanelLayout,
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
@@ -60,9 +62,11 @@ interface GroupDetailsPanelProps {
allHosts: Host[];
groups: string[];
terminalThemeId: string;
groupConfigs?: GroupConfig[];
terminalFontSize: number;
onSave: (config: GroupConfig, newName?: string, newParent?: string | null) => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
@@ -73,9 +77,11 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
allHosts,
groups,
terminalThemeId,
groupConfigs = [],
terminalFontSize,
onSave,
onCancel,
layout = "overlay",
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
@@ -274,7 +280,14 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
}, [groups, groupPath, t]);
// Effective theme
const effectiveThemeId = form.theme || terminalThemeId;
const inheritedThemeId = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return terminalThemeId;
return resolveGroupTerminalThemeId(resolveGroupDefaults(parentGroup, groupConfigs), terminalThemeId);
}, [groupConfigs, parentGroup, terminalThemeId]);
const effectiveThemeId = form.themeOverride === false
? inheritedThemeId
: (form.theme || inheritedThemeId);
const hasActiveThemeOverride = form.themeOverride === true || (form.theme != null && form.themeOverride !== false);
// Save handler
const handleSubmit = () => {
@@ -322,7 +335,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
}),
// Shared fields (always saved)
...(form.charset !== undefined && { charset: form.charset }),
...(form.theme !== undefined && { theme: form.theme }),
...((form.themeOverride !== false && form.theme !== undefined) && { theme: form.theme }),
...(form.themeOverride !== undefined && { themeOverride: form.themeOverride }),
...(form.fontFamily !== undefined && { fontFamily: form.fontFamily }),
...(form.fontFamilyOverride !== undefined && { fontFamilyOverride: form.fontFamilyOverride }),
@@ -351,6 +364,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -368,6 +382,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onClearChain={clearHostChain}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -395,6 +410,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
}}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -405,12 +421,17 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
open={true}
selectedThemeId={effectiveThemeId}
onSelect={(themeId) => {
if (themeId === effectiveThemeId && !hasActiveThemeOverride) {
setActiveSubPanel("none");
return;
}
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
setActiveSubPanel("none");
}}
onClose={onCancel}
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
/>
);
}
@@ -426,7 +447,9 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
open={true}
onClose={onCancel}
width="w-[380px]"
dataSection="group-details-panel"
title={t("vault.groups.details")}
layout={layout}
actions={
<Button
variant="ghost"
@@ -1018,7 +1041,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
</span>
</button>
{form.themeOverride && (
{hasActiveThemeOverride && (
<Button
variant="ghost"
size="sm"

View File

@@ -31,7 +31,12 @@ import {
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
import {
getEffectiveHostDistro,
LINUX_DISTRO_OPTIONS,
NETWORK_DEVICE_OPTIONS,
} from "../domain/host";
import { customThemeStore } from "../application/state/customThemeStore";
import {
clearHostFontSizeOverride,
@@ -43,7 +48,7 @@ import {
} from "../domain/terminalAppearance";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
import { DistroAvatar } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
@@ -51,6 +56,7 @@ import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
@@ -82,7 +88,10 @@ type SubPanel =
| "theme-select"
| "telnet-theme-select";
const LINUX_DISTRO_OPTION_IDS = [...LINUX_DISTRO_OPTIONS];
const LINUX_DISTRO_OPTION_IDS = [
...LINUX_DISTRO_OPTIONS,
...NETWORK_DEVICE_OPTIONS,
];
interface HostDetailsPanelProps {
initialData?: Host | null;
@@ -100,6 +109,8 @@ interface HostDetailsPanelProps {
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
onCreateTag?: (tag: string) => void; // Callback to create a new tag
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
groupConfigs?: GroupConfig[];
layout?: AsidePanelLayout;
}
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
@@ -118,6 +129,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onCreateGroup,
onCreateTag,
groupDefaults,
groupConfigs = [],
layout = "overlay",
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
@@ -201,9 +214,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
setForm((prev) => ({ ...prev, [key]: value }));
};
const effectiveGroupDefaults = useMemo(() => {
const currentGroupPath = form.group || defaultGroup;
if (currentGroupPath && groupConfigs.length > 0) {
return resolveGroupDefaults(currentGroupPath, groupConfigs);
}
return groupDefaults;
}, [defaultGroup, form.group, groupConfigs, groupDefaults]);
const effectiveThemeId = useMemo(
() => resolveHostTerminalThemeId(form, terminalThemeId),
[form, terminalThemeId],
() => resolveHostTerminalThemeId(form, resolveGroupTerminalThemeId(effectiveGroupDefaults, terminalThemeId)),
[effectiveGroupDefaults, form, terminalThemeId],
);
const effectiveFontSize = useMemo(
() => resolveHostTerminalFontSize(form, terminalFontSize),
@@ -502,6 +523,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onSave={handleCreateGroup}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -514,6 +536,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -531,6 +554,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClearChain={clearHostChain}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -559,6 +583,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -570,12 +595,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
open={true}
selectedThemeId={effectiveThemeId}
onSelect={(themeId) => {
if (themeId === effectiveThemeId && !hasEffectiveThemeOverride) {
setActiveSubPanel("none");
return;
}
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
setActiveSubPanel("none");
}}
onClose={onCancel}
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
/>
);
}
@@ -614,6 +644,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClose={onCancel}
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
/>
);
}
@@ -624,6 +655,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
open={true}
onClose={onCancel}
width="w-[420px]"
layout={layout}
dataSection="host-details-panel"
title={
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
}

View File

@@ -36,6 +36,8 @@ interface HostTreeViewProps {
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
}
interface TreeNodeProps {
@@ -61,6 +63,8 @@ interface TreeNodeProps {
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
}
@@ -87,6 +91,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
@@ -140,6 +146,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
<div
className={cn(
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
getDropTargetClasses?.(node.path),
)}
style={{ paddingLeft }}
draggable
@@ -147,10 +154,19 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(node.path);
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget?.(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(null);
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
@@ -242,6 +258,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
@@ -425,9 +443,11 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
}) => {
const { t } = useI18n();
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
@@ -548,6 +568,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
@@ -578,4 +600,4 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
)}
</div>
);
};
};

View File

@@ -4,7 +4,7 @@
* This modal displays prompts from the SSH server and collects user responses.
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
@@ -24,6 +24,7 @@ export interface KeyboardInteractivePrompt {
export interface KeyboardInteractiveRequest {
requestId: string;
sessionId?: string;
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
@@ -31,9 +32,18 @@ export interface KeyboardInteractiveRequest {
savedPassword?: string | null;
}
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
if (prompt.echo) return false;
const lower = prompt.prompt.toLowerCase();
if (!lower.includes("password")) return false;
// Exclude OTP / one-time password / verification code prompts
if (lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code")) return false;
return true;
};
interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[]) => void;
onSubmit: (requestId: string, responses: string[], savePassword?: string) => void;
onCancel: (requestId: string) => void;
}
@@ -46,15 +56,28 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const [responses, setResponses] = useState<string[]>([]);
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePassword, setSavePassword] = useState(false);
// Index of the first password prompt (if any)
const passwordPromptIndex = useMemo(() => {
if (!request) return -1;
return request.prompts.findIndex(p => isAPasswordPrompt(p));
}, [request]);
// Reset state when request changes
useEffect(() => {
if (request) {
setResponses(request.prompts.map(() => ""));
const initial = request.prompts.map(() => "");
// Auto-fill saved password into the password prompt
if (request.savedPassword && passwordPromptIndex >= 0) {
initial[passwordPromptIndex] = request.savedPassword;
}
setResponses(initial);
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
setSavePassword(false);
}
}, [request]);
}, [request, passwordPromptIndex]);
const handleResponseChange = useCallback((index: number, value: string) => {
setResponses((prev) => {
@@ -75,8 +98,11 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const handleSubmit = useCallback(() => {
if (!request || isSubmitting) return;
setIsSubmitting(true);
onSubmit(request.requestId, responses);
}, [request, responses, onSubmit, isSubmitting]);
const passwordToSave = savePassword && passwordPromptIndex >= 0
? responses[passwordPromptIndex]
: undefined;
onSubmit(request.requestId, responses, passwordToSave);
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex]);
const handleCancel = useCallback(() => {
if (!request) return;
@@ -154,19 +180,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</button>
)}
</div>
{/* Use saved password button - shown below input, right-aligned */}
{isPassword && request.savedPassword && !responses[index] && (
<div className="flex justify-end">
<button
type="button"
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
onClick={() => handleResponseChange(index, request.savedPassword!)}
{/* Save password checkbox - shown only for the first password prompt */}
{index === passwordPromptIndex && (
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={savePassword}
onChange={(e) => setSavePassword(e.target.checked)}
disabled={isSubmitting}
>
<KeyRound size={12} />
<span>{t("keyboard.interactive.useSavedPassword")}</span>
</button>
</div>
className="accent-primary"
/>
<span className="text-xs text-muted-foreground">
{t("keyboard.interactive.savePassword")}
</span>
</label>
)}
</div>
);

View File

@@ -515,12 +515,12 @@ echo $3 >> "$FILE"`);
{/* Main Content */}
<div
className={cn(
"flex-1 overflow-y-auto transition-all duration-200",
"flex-1 flex flex-col min-h-0 transition-all duration-200",
panel.type !== "closed" && "mr-[380px]",
)}
>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5">
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5 shrink-0">
{/* Filter Tabs */}
<div className="flex items-center gap-1">
{/* KEY button with split interaction: left=switch view, right=dropdown */}
@@ -684,8 +684,10 @@ echo $3 >> "$FILE"`);
</div>
</div>
{/* Keys Section */}
<div className="space-y-3 p-3">
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto">
{/* Keys Section */}
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
{t("keychain.section.keys")}
@@ -817,6 +819,7 @@ echo $3 >> "$FILE"`);
</div>
</div>
)}
</div>
</div>
{/* Slide-out Panel */}

View File

@@ -36,19 +36,35 @@ const LogViewComponent: React.FC<LogViewProps> = ({
const [isReady, setIsReady] = useState(false);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [previewTheme, setPreviewTheme] = useState<TerminalTheme | null>(null);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const explicitThemeId = useMemo(() => {
if (!log.themeId) return undefined;
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|| customThemes.some((theme) => theme.id === log.themeId);
return exists ? log.themeId : undefined;
}, [customThemes, log.themeId]);
useEffect(() => {
if (log.themeId && !explicitThemeId) {
onUpdateLog(log.id, { themeId: undefined });
}
}, [explicitThemeId, log.id, log.themeId, onUpdateLog]);
// Use log's saved theme/fontSize or fall back to defaults
const currentTheme = useMemo(() => {
if (log.themeId) {
return TERMINAL_THEMES.find(t => t.id === log.themeId)
|| customThemes.find(t => t.id === log.themeId)
if (previewTheme) {
return previewTheme;
}
if (explicitThemeId) {
return TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|| customThemes.find(t => t.id === explicitThemeId)
|| defaultTerminalTheme;
}
return defaultTerminalTheme;
}, [log.themeId, defaultTerminalTheme, customThemes]);
}, [customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
const currentFontSize = log.fontSize ?? defaultFontSize;
@@ -69,6 +85,12 @@ const LogViewComponent: React.FC<LogViewProps> = ({
onUpdateLog(log.id, { themeId });
}, [log.id, onUpdateLog]);
useEffect(() => {
if (!themeModalOpen) {
setPreviewTheme(null);
}
}, [themeModalOpen]);
// Handle font size change
const handleFontSizeChange = useCallback((fontSize: number) => {
onUpdateLog(log.id, { fontSize });
@@ -295,10 +317,13 @@ const LogViewComponent: React.FC<LogViewProps> = ({
<ThemeCustomizeModal
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
currentThemeId={currentTheme.id}
currentThemeId={explicitThemeId}
displayThemeId={currentTheme.id}
currentFontSize={currentFontSize}
onThemeChange={handleThemeChange}
onThemeReset={() => onUpdateLog(log.id, { themeId: undefined })}
onFontSizeChange={handleFontSizeChange}
onPreviewThemeChange={setPreviewTheme}
/>
</div>
);

View File

@@ -0,0 +1,185 @@
/**
* QuickAddSnippetDialog — lightweight "new snippet" modal mounted at the
* App root and triggered by the `netcatty:snippets:add` window event.
*
* Intentionally minimal: label + command + package only. Advanced fields
* (target hosts, shortkey, tags) can be set later via the full Snippets
* manager. This keeps the user in their terminal context instead of
* navigating to the Vault view just to add a command.
*/
import { Package } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import type { Snippet } from '../domain/models';
import { Button } from './ui/button';
import { Combobox } from './ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
export interface QuickAddSnippetDialogProps {
snippets: Snippet[];
packages: string[];
onCreateSnippet: (snippet: Snippet) => void;
onCreatePackage?: (packagePath: string) => void;
}
export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
snippets,
packages,
onCreateSnippet,
onCreatePackage,
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [label, setLabel] = useState('');
const [command, setCommand] = useState('');
const [packagePath, setPackagePath] = useState('');
const labelInputRef = useRef<HTMLInputElement>(null);
// Listen for the global "add snippet" request dispatched by the
// terminal-side ScriptsSidePanel + button. We reset form state on
// every open so stale input from a previous cancel does not leak.
useEffect(() => {
const handler = () => {
setLabel('');
setCommand('');
setPackagePath('');
setOpen(true);
};
window.addEventListener('netcatty:snippets:add', handler);
return () => window.removeEventListener('netcatty:snippets:add', handler);
}, []);
// Auto-focus the label input once the dialog renders, so the user can
// start typing immediately after clicking the + button.
useEffect(() => {
if (!open) return;
const id = window.setTimeout(() => labelInputRef.current?.focus(), 50);
return () => window.clearTimeout(id);
}, [open]);
// Derive combobox options from the union of existing packages (from
// props) and any package path referenced by an existing snippet, so
// the user can reuse anything they see in the main snippets view.
const packageOptions = useMemo(() => {
const set = new Set<string>();
for (const p of packages) {
if (p) set.add(p);
}
for (const s of snippets) {
if (s.package) set.add(s.package);
}
return Array.from(set).sort().map((value) => ({ value, label: value }));
}, [packages, snippets]);
const canSave = label.trim().length > 0 && command.trim().length > 0;
const handleSave = useCallback(() => {
if (!canSave) return;
const trimmedPackage = packagePath.trim();
// If the user typed a brand new package name, surface it to the parent
// so it can be added to the user's package list alongside the snippet.
if (trimmedPackage && !packages.includes(trimmedPackage)) {
onCreatePackage?.(trimmedPackage);
}
onCreateSnippet({
id: crypto.randomUUID(),
label: label.trim(),
command, // preserve whitespace in multi-line commands
tags: [],
package: trimmedPackage || '',
targets: [],
});
setOpen(false);
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, label, command]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Cmd/Ctrl+Enter from anywhere in the dialog saves the snippet.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canSave) {
e.preventDefault();
handleSave();
}
},
[canSave, handleSave],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle>{t('snippets.panel.newTitle')}</DialogTitle>
<DialogDescription>
{t('snippets.empty.desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-label" className="text-xs">
{t('snippets.field.description')}
</Label>
<Input
id="quick-add-snippet-label"
ref={labelInputRef}
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('snippets.field.descriptionPlaceholder')}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-command" className="text-xs">
{t('snippets.field.scriptRequired')}
</Label>
<Textarea
id="quick-add-snippet-command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="echo hello"
className="min-h-[120px] font-mono text-xs"
spellCheck={false}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs flex items-center gap-1.5">
<Package size={12} /> {t('snippets.field.package')}
</Label>
<Combobox
value={packagePath}
onValueChange={setPackagePath}
options={packageOptions}
placeholder={t('snippets.field.packagePlaceholder')}
allowCreate
onCreateNew={setPackagePath}
createText={t('snippets.field.createPackage')}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} disabled={!canSave}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default QuickAddSnippetDialog;

View File

@@ -70,6 +70,7 @@ interface QuickSwitcherProps {
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
// onCreateWorkspace removed - feature not currently used
keyBindings?: KeyBinding[];
showSftpTab: boolean;
}
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
@@ -84,16 +85,19 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onClose,
onCreateLocalTerminal,
keyBindings,
showSftpTab,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
const filteredShells = useMemo(() => {
if (!query.trim()) return discoveredShells;
const q = query.toLowerCase();
return discoveredShells.filter(
(s) => s.name.toLowerCase().includes(q) || s.id.toLowerCase().includes(q)
);
const list = !query.trim()
? discoveredShells
: discoveredShells.filter(
(s) => s.name.toLowerCase().includes(query.toLowerCase()) || s.id.toLowerCase().includes(query.toLowerCase())
);
// Default shell first
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
}, [discoveredShells, query]);
// Get hotkey display strings
@@ -159,7 +163,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
);
// Tabs (built-in + sessions + workspaces)
items.push({ type: "tab", id: "vault" });
items.push({ type: "tab", id: "sftp" });
if (showSftpTab) items.push({ type: "tab", id: "sftp" });
orphanSessions.forEach((s) =>
items.push({ type: "tab", id: s.id, data: s }),
);
@@ -192,7 +196,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
});
return { flatItems: items, itemIndexMap: indexMap };
}, [showCategorized, results, orphanSessions, workspaces, filteredShells]);
}, [showCategorized, results, orphanSessions, workspaces, filteredShells, showSftpTab]);
// O(1) index lookup
const getItemIndex = useCallback((type: string, id: string) => {
@@ -315,7 +319,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
</div>
{/* Built-in tabs */}
{["vault", "sftp"].map((tabId) => {
{(showSftpTab ? ["vault", "sftp"] : ["vault"]).map((tabId) => {
const idx = getItemIndex("tab", tabId);
const isSelected = idx === selectedIndex;
const icon =

View File

@@ -5,7 +5,7 @@
* Clicking a snippet executes it in the focused terminal session.
*/
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
import { ChevronRight, Package, Plus, Search, Zap } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
@@ -119,15 +119,25 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
onSnippetClick(command, noAutoRun);
}, [onSnippetClick]);
const handleAddSnippet = useCallback(() => {
// Let the App shell listen and navigate to the Snippets section with
// the "add" panel pre-opened, so the user does not have to leave the
// terminal to jump back and click "New Snippet".
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
}, []);
if (!isVisible) return null;
const hasAnyContent = snippets.length > 0 || packages.length > 0;
return (
<div className="h-full flex flex-col bg-background overflow-hidden">
{/* Search */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
<div className="relative">
<div
className="h-full flex flex-col bg-background overflow-hidden"
data-section="snippets-panel"
>
{/* Search + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
<div className="relative flex-1 min-w-0">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
@@ -136,6 +146,15 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
<button
type="button"
onClick={handleAddSnippet}
title={t('snippets.action.newSnippet')}
aria-label={t('snippets.action.newSnippet')}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Plus size={14} />
</button>
</div>
{/* Breadcrumb */}

View File

@@ -1,11 +1,9 @@
import {
ArrowLeft,
Check,
ChevronRight,
LayoutGrid,
Plus,
Search,
X,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { cn } from "../lib/utils";
@@ -14,6 +12,7 @@ import { Host, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { DistroAvatar } from "./DistroAvatar";
import HostDetailsPanel from "./HostDetailsPanel";
import { AsidePanel, type AsidePanelLayout } from "./ui/aside-panel";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
@@ -44,6 +43,7 @@ interface SelectHostPanelProps {
title?: string;
subtitle?: string;
className?: string;
layout?: AsidePanelLayout;
}
const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
@@ -63,6 +63,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
title,
subtitle,
className,
layout = "overlay",
}) => {
const { t } = useI18n();
const panelTitle = title ?? t("selectHost.title");
@@ -205,35 +206,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
return (
<TooltipProvider delayDuration={300}>
<div
<AsidePanel
open={true}
onClose={onBack}
title={panelTitle}
subtitle={subtitle}
showBackButton={true}
onBack={onBack}
className={cn(
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
layout === "overlay" && "z-40",
showNewHostPanel && "overflow-visible",
className,
)}
layout={layout}
>
{/* Header */}
<div className="px-4 py-3 border-b border-border/60 flex items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<button
onClick={onBack}
className="p-1 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
>
<ArrowLeft size={18} />
</button>
<div className="min-w-0">
<h3 className="text-sm font-semibold">{panelTitle}</h3>
{subtitle && (
<p className="text-xs text-muted-foreground">{subtitle}</p>
)}
</div>
</div>
<button
onClick={onBack}
className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
>
<X size={18} />
</button>
</div>
{/* Toolbar */}
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/60 shrink-0">
@@ -277,7 +263,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
</div>
{/* Content */}
<ScrollArea className="flex-1">
<ScrollArea className="flex-1 min-w-0">
<div className="p-3 space-y-3">
{/* Breadcrumbs */}
{currentPath && (
@@ -398,7 +384,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
</ScrollArea>
{/* Footer */}
<div className="px-4 py-3 border-t border-border/60">
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<Button
className="w-full"
disabled={selectedHostIds.length === 0}
@@ -436,7 +422,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onCreateGroup={onCreateGroup}
/>
)}
</div>
</AsidePanel>
</TooltipProvider>
);
};

View File

@@ -17,6 +17,7 @@ import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
} from './ui/aside-panel';
interface SerialPort {
@@ -35,6 +36,7 @@ interface SerialHostDetailsPanelProps {
groups?: string[];
onSave: (host: Host) => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
@@ -49,6 +51,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
groups = [],
onSave,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
const terminalBackend = useTerminalBackend();
@@ -164,6 +167,8 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
title={t('serial.edit.title')}
subtitle={initialData.label}
className="z-40"
layout={layout}
dataSection="serial-host-details-panel"
>
<AsidePanelContent>
{/* Label */}

View File

@@ -96,6 +96,18 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
};
}, [getApplicationInfo]);
const handleOpenExternal = async (url: string) => {
try {
await openExternal(url);
} catch (err) {
console.warn("[SettingsApplicationTab] openExternal failed:", err);
toast.error(
t("settings.application.openExternal.failedBody"),
t("settings.application.openExternal.failedTitle"),
);
}
};
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
@@ -198,25 +210,25 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
icon={<Bug size={18} />}
title={t("settings.application.reportProblem")}
subtitle={t("settings.application.reportProblem.subtitle")}
onClick={() => void openExternal(issueUrl)}
onClick={() => void handleOpenExternal(issueUrl)}
/>
<ActionRow
icon={<MessageCircle size={18} />}
title={t("settings.application.community")}
subtitle={t("settings.application.community.subtitle")}
onClick={() => void openExternal(discussionsUrl)}
onClick={() => void handleOpenExternal(discussionsUrl)}
/>
<ActionRow
icon={<Github size={18} />}
title="GitHub"
subtitle={t("settings.application.github.subtitle")}
onClick={() => void openExternal(REPO_URL)}
onClick={() => void handleOpenExternal(REPO_URL)}
/>
<ActionRow
icon={<Newspaper size={18} />}
title={t("settings.application.whatsNew")}
subtitle={t("settings.application.whatsNew.subtitle")}
onClick={() => void openExternal(releasesUrl)}
onClick={() => void handleOpenExternal(releasesUrl)}
/>
</div>
</div>

View File

@@ -41,7 +41,7 @@ class AITabErrorBoundary extends React.Component<
</div>
);
}
return this.props.children;
return (this.props as { children: React.ReactNode }).children;
}
}
@@ -56,6 +56,8 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
@@ -86,6 +88,8 @@ const SettingsAITabContainer: React.FC = () => {
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
toolIntegrationMode={aiState.toolIntegrationMode}
setToolIntegrationMode={aiState.setToolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
@@ -282,6 +286,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
setCustomCSS={settings.setCustomCSS}
showRecentHosts={settings.showRecentHosts}
setShowRecentHosts={settings.setShowRecentHosts}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
/>
)}

View File

@@ -8,7 +8,7 @@ import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
import { DistroAvatar } from './DistroAvatar';
import SelectHostPanel from './SelectHostPanel';
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Combobox, ComboboxOption } from './ui/combobox';
@@ -18,6 +18,7 @@ import { Input } from './ui/input';
import { Label } from './ui/label';
import { SortDropdown, SortMode } from './ui/sort-dropdown';
import { Textarea } from './ui/textarea';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
interface SnippetsManagerProps {
snippets: Snippet[];
@@ -720,6 +721,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
title={t('snippets.targets.add')}
layout="inline"
/>
);
}
@@ -730,6 +732,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
open={true}
onClose={handleClosePanel}
title={editingSnippet.id ? t('snippets.panel.editTitle') : t('snippets.panel.newTitle')}
layout="inline"
actions={
<Button
variant="ghost"
@@ -883,7 +886,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
</AsidePanelContent>
{/* Footer */}
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<AsidePanelFooter>
<Button
className="w-full"
onClick={handleSubmit}
@@ -891,7 +894,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
>
{editingSnippet.targets?.length ? t('action.run') : t('common.save')}
</Button>
</div>
</AsidePanelFooter>
</AsidePanel>
);
}
@@ -905,6 +908,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
subtitle={t('snippets.history.subtitle', { count: shellHistory.length })}
showBackButton={true}
onBack={handleClosePanel}
layout="inline"
>
{/* History List */}
<div
@@ -951,8 +955,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
};
return (
<div className="h-full flex gap-3 relative">
<div className="flex-1 flex flex-col min-h-0">
<TooltipProvider delayDuration={300}>
<div className="h-full min-h-0 flex relative">
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
<div className="h-14 px-4 py-2 flex items-center gap-2">
{/* Search box */}
@@ -1059,7 +1064,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
@@ -1079,11 +1084,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
onClick={() => setSelectedPackage(pkg.path)}
>
<div className="flex items-center gap-3 h-full">
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<Package size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{pkg.name}</div>
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
</div>
@@ -1114,7 +1119,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
@@ -1126,15 +1131,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
onClick={() => handleEdit(snippet)}
>
<div className="flex items-center gap-3 h-full">
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<FileCode size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{snippet.label}</div>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-sm break-all font-mono text-xs">
{snippet.command}
</TooltipContent>
</Tooltip>
</div>
{snippet.shortkey && (
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
@@ -1254,6 +1266,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{/* Right Panel */}
{renderRightPanel()}
</div>
</TooltipProvider>
);
};

View File

@@ -136,7 +136,13 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
// Determine overall status for the button indicator
const getOverallStatus = (): StatusIndicatorProps['status'] => {
if (sync.overallSyncStatus === 'syncing') return 'syncing';
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
if (
sync.overallSyncStatus === 'error' ||
sync.overallSyncStatus === 'conflict' ||
sync.overallSyncStatus === 'blocked'
) {
return 'error';
}
if (sync.overallSyncStatus === 'synced') return 'synced';
return 'none';
};

View File

@@ -28,6 +28,7 @@ import {
import {
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { classifyDistroId } from "../domain/host";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
@@ -47,7 +48,9 @@ import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
@@ -122,6 +125,7 @@ interface TerminalProps {
fontFamilyId: string;
fontSize: number;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
@@ -196,6 +200,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fontFamilyId,
fontSize,
terminalTheme,
followAppTerminalTheme = false,
terminalSettings,
sessionId,
startupCommand,
@@ -242,6 +247,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sessionRef = useRef<string | null>(null);
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
// Token for an in-flight retry chain. handleRetry sets this to a fresh
// symbol; any cancel/close/teardown/subsequent-retry invalidates it. The
// chained xterm.write callbacks verify the token before proceeding so a
// cancelled retry can't fire a startNewSession after the fact.
const retryTokenRef = useRef<symbol | null>(null);
const terminalDataCapturedRef = useRef(false);
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
@@ -256,6 +266,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
const fontWeightFixupDoneRef = useRef(false);
useEffect(() => {
if (xtermRuntimeRef.current) {
@@ -329,6 +340,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
// Work around xterm.js WebGL renderer bug: glyphs rendered via the constructor
// look different from dynamically-set ones. After text appears on screen (status
// becomes "connected"), do a fontWeight round-trip to normalize the rendering.
useEffect(() => {
if (status !== 'connected' || fontWeightFixupDoneRef.current || !termRef.current) return;
fontWeightFixupDoneRef.current = true;
const timer = setTimeout(() => {
if (!termRef.current) return;
// Re-read the current weight at fire time to avoid stale closures
const w = termRef.current.options.fontWeight;
if (w === 'normal' || w === 400) return;
termRef.current.options.fontWeight = 'normal';
termRef.current.options.fontWeight = w;
}, 200);
return () => clearTimeout(timer);
}, [status]);
const [chainProgress, setChainProgress] = useState<{
currentHop: number;
totalHops: number;
@@ -492,12 +520,33 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
// for hosts classified as network devices (either via explicit
// deviceType='network' or via SSH banner detection that populated
// host.distro with a network-vendor ID). See #674: polling the stats
// command on Cisco / Huawei / Juniper etc. generates one AAA session
// log entry per poll because each exec channel is counted as a new
// session on those devices.
//
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
// because that honors the manual distro override (`distroMode: 'manual'`
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
// pinned an "ubuntu" icon on what is actually a Cisco host would
// otherwise silently re-enable the polling loop and re-introduce the
// AAA log flood this patch is meant to eliminate. The display icon can
// still be overridden (see DistroAvatar) — gating uses the raw detected
// `host.distro` and the explicit `host.deviceType` only.
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
const isSupportedOs =
!isNetworkDevice &&
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
const { stats: serverStats } = useServerStats({
sessionId,
enabled: terminalSettings?.showServerStats ?? true,
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
isSupportedOs: host.os === 'linux' || host.os === 'macos',
isSupportedOs,
isConnected: status === 'connected',
isVisible,
});
@@ -572,6 +621,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.focus();
}, []);
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
e.preventDefault();
}, []);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
@@ -594,6 +649,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
const effectiveTheme = useMemo(() => {
// When "Follow Application Theme" is on and there's no active
// preview, skip per-host overrides — all terminals should use the
// UI-matched theme passed via terminalTheme prop.
if (followAppTerminalTheme && !themePreviewId) return terminalTheme;
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
terminalTheme.id,
@@ -604,7 +663,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (hostTheme) return hostTheme;
}
return terminalTheme;
}, [customThemes, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
}, [customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
const resolvedChainHosts =
chainHosts;
@@ -638,6 +697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const teardown = () => {
retryTokenRef.current = null;
cleanupSession();
xtermRuntimeRef.current?.dispose();
xtermRuntimeRef.current = null;
@@ -740,6 +800,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Autocomplete integration
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
isRestoringSelectionRef,
});
xtermRuntimeRef.current = runtime;
@@ -943,7 +1004,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings) {
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
termRef.current.options.scrollback = terminalSettings.scrollback;
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
termRef.current.options.fontWeight = effectiveFontWeight as
| 100
| 200
@@ -959,7 +1020,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (typeof document === "undefined" || !document.fonts?.check) {
return terminalSettings.fontWeightBold;
}
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: effectiveFontWeight;
@@ -1046,7 +1107,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
const resolvedBold = document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: effectiveFontWeight;
@@ -1117,10 +1178,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
const timer = setTimeout(() => {
// Fit twice: once after initial layout (100ms) and again after layout settles
// (350ms) to handle race conditions during split operations where the container
// dimensions may not be final on the first pass.
const timer1 = setTimeout(() => {
safeFit({ requireVisible: true });
}, 100);
return () => clearTimeout(timer);
const timer2 = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
}, 350);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}, [inWorkspace, isVisible]);
// When search bar opens/closes, re-fit terminal and maintain scroll position
@@ -1171,7 +1238,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hasText = !!selection && selection.length > 0;
setHasSelection(hasText);
if (hasText && terminalSettings?.copyOnSelect) {
if (hasText && terminalSettings?.copyOnSelect && !isRestoringSelectionRef.current) {
navigator.clipboard.writeText(selection).catch((err) => {
logger.warn("Copy on select failed:", err);
});
@@ -1262,6 +1329,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
// True only while createXTermRuntime is programmatically restoring the
// selection right after a keystroke (preserveSelectionOnInput). Lets
// copy-on-select skip a redundant clipboard write that would otherwise
// clobber whatever the user copied elsewhere in the meantime.
const isRestoringSelectionRef = useRef(false);
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
@@ -1346,6 +1419,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleCancelConnect = () => {
retryTokenRef.current = null;
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
@@ -1365,6 +1439,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleCloseDisconnectedSession = () => {
retryTokenRef.current = null;
onCloseSession?.(sessionId);
};
@@ -1406,6 +1481,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleRetry = () => {
if (!termRef.current) return;
cleanupSession();
const term = termRef.current;
// Claim a fresh retry token. If the user cancels / closes / unmounts /
// kicks off another retry while the chained writes below are still
// queued, the token will be invalidated and our callbacks will abort
// before opening a ghost backend session with no owning UI.
const retryToken = Symbol("retry");
retryTokenRef.current = retryToken;
const retryStillActive = () => retryTokenRef.current === retryToken && termRef.current === term;
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
@@ -1414,17 +1498,51 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setError(null);
setProgressLogs(["Retrying secure channel..."]);
setShowLogs(true);
if (host.protocol === "serial") {
sessionStarters.startSerial(termRef.current);
} else if (host.protocol === "local" || host.hostname === "localhost") {
sessionStarters.startLocal(termRef.current);
} else if (host.protocol === "telnet") {
sessionStarters.startTelnet(termRef.current);
} else if (host.moshEnabled) {
sessionStarters.startMosh(termRef.current);
} else {
sessionStarters.startSSH(termRef.current);
}
const startNewSession = () => {
if (!retryStillActive()) return;
if (host.protocol === "serial") {
sessionStarters.startSerial(term);
} else if (host.protocol === "local" || host.hostname === "localhost") {
sessionStarters.startLocal(term);
} else if (host.protocol === "telnet") {
sessionStarters.startTelnet(term);
} else if (host.moshEnabled) {
sessionStarters.startMosh(term);
} else {
sessionStarters.startSSH(term);
}
};
// Chain the whole preparation through xterm.write callbacks so everything
// lands in strict order — see #695. xterm.write is async, so without
// chaining, a fast reconnect path (local/serial especially) can interleave
// the new session's first bytes with our reset sequence, corrupting the
// first screen.
//
// 1. Exit the alternate screen first. preserveTerminalViewportInScrollback
// is a no-op on the alt buffer (disconnect while in vim/less/top), so
// we must be on the normal buffer before preserving.
term.write('\x1b[?1049l', () => {
if (!retryStillActive()) return;
// 2. Push the previous session's viewport into scrollback so the user
// can still read it after reconnect.
preserveTerminalViewportInScrollback(term);
// 3. Soft terminal reset (DECSTR, \x1b[!p) resets VT220-era modes that
// full-screen apps may have left on — DECCKM (otherwise arrow keys
// emit SS3 and break readline history), keypad mode, SGR,
// insert/replace, origin, cursor visibility — without clearing the
// buffer. DECSTR does not cover xterm-specific extensions, so also
// explicitly disable mouse tracking (1000/1002/1003/1006) and
// bracketed paste (2004). Finally home the cursor.
term.write(
'\x1b[!p\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[H',
// 4. Only now — after every prep byte has been applied to the
// terminal — start the new session, so its first output can't
// interleave with the reset sequence.
startNewSession,
);
});
};
const shouldShowConnectionDialog = status !== "connected"
@@ -1559,6 +1677,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isAlternateScreen={hasMouseTracking}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
onPasteSelection={terminalContextActions.onPasteSelection}
onSelectAll={terminalContextActions.onSelectAll}
onClear={terminalContextActions.onClear}
onSelectWord={terminalContextActions.onSelectWord}
@@ -1601,6 +1720,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
onMouseDownCapture={handleTopOverlayMouseDownCapture}
style={{
backgroundColor: 'var(--terminal-ui-bg)',
color: 'var(--terminal-ui-fg)',
@@ -1988,7 +2108,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
>
<div
ref={containerRef}
className="absolute inset-x-0 bottom-0"
className="xterm-container absolute inset-x-0 bottom-0"
style={{
top: isSearchOpen ? "64px" : "30px",
paddingLeft: 6,

View File

@@ -33,6 +33,7 @@ import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKe
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { DistroAvatar } from './DistroAvatar';
import Terminal from './Terminal';
@@ -40,7 +41,7 @@ import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { AIChatSidePanel } from './AIChatSidePanel';
import { cleanupOrphanedAISessions, useAIState } from '../application/state/useAIState';
import { useAIState } from '../application/state/useAIState';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
@@ -259,6 +260,10 @@ interface AIChatPanelsHostProps {
}) => ExecutorContext;
}
interface AIStateMaintenanceHostProps {
validAIScopeTargetIds: Set<string>;
}
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const aiState = useAIState();
return (
@@ -271,6 +276,27 @@ const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ childre
const AIStateProvider = memo(AIStateProviderInner);
AIStateProvider.displayName = 'AIStateProvider';
const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
validAIScopeTargetIds,
}) => {
const aiState = useContext(AIStateContext);
if (!aiState) {
throw new Error('AIStateMaintenanceHost must be rendered inside AIStateProvider');
}
const { cleanupOrphanedSessions } = aiState;
useEffect(() => {
cleanupOrphanedSessions(validAIScopeTargetIds);
}, [cleanupOrphanedSessions, validAIScopeTargetIds]);
return null;
};
const AIStateMaintenanceHost = memo(AIStateMaintenanceHostInner);
AIStateMaintenanceHost.displayName = 'AIStateMaintenanceHost';
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
mountedTabIds,
activeTabId,
@@ -300,12 +326,20 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
<AIChatSidePanel
sessions={aiState.sessions}
activeSessionIdMap={aiState.activeSessionIdMap}
draftsByScope={aiState.draftsByScope}
panelViewByScope={aiState.panelViewByScope}
setActiveSessionId={aiState.setActiveSessionId}
ensureDraftForScope={aiState.ensureDraftForScope}
updateDraft={aiState.updateDraft}
showDraftView={aiState.showDraftView}
showSessionView={aiState.showSessionView}
clearDraftForScope={aiState.clearDraftForScope}
addDraftFiles={aiState.addDraftFiles}
removeDraftFile={aiState.removeDraftFile}
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
retargetSessionScope={aiState.retargetSessionScope}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
@@ -313,6 +347,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
toolIntegrationMode={aiState.toolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
@@ -352,6 +387,7 @@ interface TerminalLayerProps {
knownHosts?: KnownHost[];
draggingSessionId: string | null;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
terminalSettings?: TerminalSettings;
terminalFontFamilyId: string;
fontSize?: number;
@@ -393,6 +429,8 @@ interface TerminalLayerProps {
sessionLogsEnabled?: boolean;
sessionLogsDir?: string;
sessionLogsFormat?: string;
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
}
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
@@ -407,6 +445,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
knownHosts = [],
draggingSessionId,
terminalTheme,
followAppTerminalTheme = false,
terminalSettings,
terminalFontFamilyId,
fontSize = 14,
@@ -445,6 +484,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
closeSidePanelRef,
activeSidePanelTabRef,
}) => {
// Subscribe to activeTabId from external store
const activeTabId = useActiveTabId();
@@ -625,6 +666,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Whether side panel is open for the currently active tab and which sub-panel
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
if (activeSidePanelTabRef) {
activeSidePanelTabRef.current = activeSidePanelTab;
}
// Legacy compatibility helpers for SFTP-specific logic
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
@@ -849,7 +893,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return map;
}, [sessions, sessionHostsMap, hostMap, groupConfigs]);
const validTerminalTabIds = useMemo(() => {
const validAIScopeTargetIds = useMemo(() => {
const ids = new Set<string>();
for (const session of sessions) ids.add(session.id);
for (const workspace of workspaces) ids.add(workspace.id);
@@ -937,16 +981,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [workspaces]);
useEffect(() => {
setSidePanelOpenTabs(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpHostForTab(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
setSidePanelOpenTabs(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpHostForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
sessionActivityStore.prune(validSessionActivityIds);
}, [validSessionActivityIds, validTerminalTabIds]);
useEffect(() => {
cleanupOrphanedAISessions(validTerminalTabIds);
}, [validTerminalTabIds]);
}, [validSessionActivityIds, validAIScopeTargetIds]);
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
if (!workspace) return {} as Record<string, WorkspaceRect>;
@@ -1225,9 +1265,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
if (!sessionId) return;
const focusTarget = () => {
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
};
requestAnimationFrame(() => {
focusTarget();
setTimeout(focusTarget, 50);
});
}, []);
// Close the entire side panel for the current tab
const handleCloseSidePanel = useCallback(() => {
if (!activeTabId) return;
const sessionIdToRefocus = activeWorkspace?.focusedSessionId ?? activeSession?.id;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.delete(activeTabId);
@@ -1250,7 +1306,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.delete(activeTabId);
return next;
});
}, [activeTabId]);
refocusTerminalSession(sessionIdToRefocus);
}, [activeTabId, activeWorkspace?.focusedSessionId, activeSession?.id, refocusTerminalSession]);
useEffect(() => {
if (!closeSidePanelRef) return;
closeSidePanelRef.current = handleCloseSidePanel;
return () => {
closeSidePanelRef.current = null;
};
}, [closeSidePanelRef, handleCloseSidePanel]);
// Switch side panel to a specific tab (or toggle if already on that tab)
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
@@ -1379,6 +1444,17 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const isFocusedHostLocal = useMemo(() => {
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
}, [focusedHost]);
// Hosts not in the persisted hostMap (e.g. quick-connect) are ephemeral —
// sidebar appearance changes should update global settings, not per-host overrides.
const isFocusedHostEphemeral = useMemo(() => {
if (isFocusedHostLocal) return true;
if (!focusedHost) return true;
return !hostMap.has(focusedHost.id);
}, [focusedHost, isFocusedHostLocal, hostMap]);
const rawFocusedHost = useMemo(() => {
if (!focusedHost) return null;
return hostMap.get(focusedHost.id) ?? null;
}, [focusedHost, hostMap]);
const previewTargetSessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id ?? null;
const activeThemePreviewId = themePreview.targetSessionId === previewTargetSessionId
? themePreview.themeId
@@ -1393,9 +1469,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
const focusedFontWeight = resolveHostTerminalFontWeight(focusedHost, terminalSettings?.fontWeight ?? 400);
const focusedFontWeightOverridden = hasHostFontWeightOverride(focusedHost);
const visibleFocusedThemeId = followAppTerminalTheme ? terminalTheme.id : focusedThemeId;
const previewedOrVisibleThemeId = activeThemePreviewId ?? visibleFocusedThemeId;
const activeTopTabsThemeId = activeSidePanelTab === 'theme' && previewTargetSessionId
? (activeThemePreviewId ?? focusedThemeId)
: (isVisible ? focusedThemeId : null);
? previewedOrVisibleThemeId
: (isVisible ? visibleFocusedThemeId : null);
const appliedPreviewSessionRef = useRef<string | null>(null);
const customThemes = useCustomThemes();
const applyTerminalPreviewVars = useCallback((sessionId: string | null, themeId: string | null) => {
@@ -1486,10 +1564,28 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
clearTopTabsPreviewVars();
}, [activeTopTabsThemeId, applyTopTabsPreviewVars]);
useEffect(() => {
if (!followAppTerminalTheme) return;
if (themeCommitTimerRef.current) {
clearTimeout(themeCommitTimerRef.current);
themeCommitTimerRef.current = null;
}
const appliedSessionId = appliedPreviewSessionRef.current;
if (appliedSessionId) {
clearTerminalPreviewVars(appliedSessionId);
appliedPreviewSessionRef.current = null;
}
clearTopTabsPreviewVars();
if (themePreview.targetSessionId || themePreview.themeId) {
setThemePreview({ targetSessionId: null, themeId: null });
}
}, [followAppTerminalTheme, themePreview.targetSessionId, themePreview.themeId]);
useEffect(() => {
const panelOpen = activeSidePanelTab === 'theme' && !!previewTargetSessionId;
const shouldKeepPreview =
panelOpen &&
themePreview.targetSessionId === previewTargetSessionId &&
!!themePreview.targetSessionId &&
!!themePreview.themeId;
@@ -1509,14 +1605,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (
themePreview.targetSessionId === previewTargetSessionId &&
themePreview.themeId &&
themePreview.themeId === focusedThemeId
themePreview.themeId === visibleFocusedThemeId
) {
setThemePreview({ targetSessionId: null, themeId: null });
}
}, [focusedThemeId, previewTargetSessionId, themePreview]);
}, [previewTargetSessionId, themePreview, visibleFocusedThemeId]);
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
if (!focusedHost || themeId === focusedThemeId) return;
if (!focusedHost || themeId === previewedOrVisibleThemeId) return;
applyTerminalPreviewVars(previewTargetSessionId, themeId);
applyTopTabsPreviewVars(themeId);
setThemePreview({ targetSessionId: previewTargetSessionId, themeId });
@@ -1525,14 +1621,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
themeCommitTimerRef.current = setTimeout(() => {
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalThemeId?.(themeId);
return;
}
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
if (rawFocusedHost) {
onUpdateHost({ ...rawFocusedHost, theme: themeId, themeOverride: true });
}
});
}, 160);
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, isFocusedHostEphemeral, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId, previewedOrVisibleThemeId, rawFocusedHost]);
const handleThemeResetForFocusedSession = useCallback(() => {
if (themeCommitTimerRef.current) {
@@ -1540,64 +1638,64 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
clearTerminalPreviewVars(previewTargetSessionId);
setThemePreview({ targetSessionId: null, themeId: null });
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostThemeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost, previewTargetSessionId]);
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
onUpdateHost(clearHostThemeOverride(rawFocusedHost));
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, previewTargetSessionId, rawFocusedHost]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (!focusedHost || fontFamilyId === focusedFontFamilyId) return;
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
});
}, [focusedHost, focusedFontFamilyId, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (!focusedHost || newFontSize === focusedFontSize) return;
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontSize?.(newFontSize);
return;
}
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
});
}, [focusedHost, focusedFontSize, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostFontSizeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
if (!focusedHost || newFontWeight === focusedFontWeight) return;
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontWeight?.(newFontWeight);
return;
}
// Patch only fontWeight fields on the raw (un-merged) host to avoid flattening group defaults
// Prefer raw (un-merged) host to avoid flattening group defaults
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost({ ...rawHost, fontWeight: newFontWeight, fontWeightOverride: true });
}
});
}, [focusedHost, focusedFontWeight, isFocusedHostLocal, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
}, [focusedHost, focusedFontWeight, isFocusedHostEphemeral, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
const handleFontWeightResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost(clearHostFontWeightOverride(rawHost));
}
}, [focusedHost, isFocusedHostLocal, onUpdateHost, hostMap]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, hostMap]);
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
@@ -1610,8 +1708,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// recomputing scope resolution from scratch on every tab switch.
const aiContextsByTabId = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionById = new Map(sessions.map((session) => [session.id, session]));
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
const sessionById = new Map<string, TerminalSession>(sessions.map((session) => [session.id, session]));
const workspaceById = new Map<string, Workspace>(workspaces.map((workspace) => [workspace.id, workspace]));
const tabIds = new Set<string>(mountedAiTabIds);
if (activeTabId) tabIds.add(activeTabId);
@@ -1692,11 +1790,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, []);
const resolvedPreviewTheme = useMemo(() => {
const themeId = activeThemePreviewId ?? focusedThemeId;
const themeId = previewedOrVisibleThemeId;
return TERMINAL_THEMES.find((theme) => theme.id === themeId)
|| customThemes.find((theme) => theme.id === themeId)
|| terminalTheme;
}, [activeThemePreviewId, customThemes, focusedThemeId, terminalTheme]);
}, [customThemes, previewedOrVisibleThemeId, terminalTheme]);
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
@@ -1812,7 +1910,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (!activeWorkspace || !isFocusMode) return null;
return (
<div className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col">
<div
className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col"
data-section="terminal-workspace-sidebar"
>
{/* Header with view toggle */}
<div className="h-10 flex items-center justify-between px-3 border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground">
@@ -1880,9 +1981,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return (
<AIStateProvider>
<AIStateMaintenanceHost validAIScopeTargetIds={validAIScopeTargetIds} />
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex flex-col"
data-section="terminal-workspace"
style={{
visibility: isTerminalLayerVisible ? 'visible' : 'hidden',
pointerEvents: isTerminalLayerVisible ? 'auto' : 'none',
@@ -1933,7 +2036,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
data-tab-id="sftp"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'sftp'
? 'var(--terminal-sidepanel-fg)'
@@ -1947,7 +2053,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
data-tab-id="scripts"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'scripts'
? 'var(--terminal-sidepanel-fg)'
@@ -1961,7 +2070,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
data-tab-id="theme"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'theme'
? 'var(--terminal-sidepanel-fg)'
@@ -1975,7 +2087,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
data-tab-id="ai"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'ai'
? 'var(--terminal-sidepanel-fg)'
@@ -2064,7 +2179,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
{activeSidePanelTab === 'theme' && (
<div className="absolute inset-0 z-10">
<ThemeSidePanel
currentThemeId={activeThemePreviewId ?? focusedThemeId}
followAppTerminalTheme={followAppTerminalTheme}
currentThemeId={previewedOrVisibleThemeId}
globalThemeId={terminalTheme.id}
currentFontFamilyId={focusedFontFamilyId}
globalFontFamilyId={terminalFontFamilyId}
@@ -2223,6 +2339,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
fontFamilyId={terminalFontFamilyId}
fontSize={fontSize}
terminalTheme={terminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}
@@ -2317,14 +2434,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onSend={handleComposeSend}
onClose={() => {
setIsComposeBarOpen(false);
// Refocus the terminal pane (matching solo-session behavior)
if (focusedSessionId) {
requestAnimationFrame(() => {
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
});
}
refocusTerminalSession(focusedSessionId);
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}

View File

@@ -125,12 +125,38 @@ const hslToHex = (hslString: string): string => {
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Get background color from CSS variable
const getBackgroundColor = (): string => {
const bgValue = getComputedStyle(document.documentElement)
.getPropertyValue('--background')
// Read a CSS custom-property and convert from HSL to hex
const getCssColor = (varName: string, fallback: string): string => {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
return value ? hslToHex(value) : fallback;
};
interface EditorColors {
bg: string;
fg: string;
primary: string;
card: string;
mutedFg: string;
border: string;
}
/** Read all UI CSS variables that matter for the Monaco theme. */
const getEditorColors = (isDark: boolean): EditorColors => ({
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
});
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
const getThemeSignal = (): string => {
const root = document.documentElement;
return root.dataset.immersiveTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
};
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
@@ -163,49 +189,64 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
document.documentElement.classList.contains('dark')
);
// Track background color for custom theme
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
// Track a signal that changes whenever immersive-mode or base theme colors change
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes based on UI background color
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
useEffect(() => {
if (!monaco) return;
// Define dark theme with custom background
const colors = getEditorColors(isDarkTheme);
const themeColors: Record<string, string> = {
'editor.background': colors.bg,
'editor.foreground': colors.fg,
'editorCursor.foreground': colors.primary,
'editor.selectionBackground': colors.primary + '40',
'editor.inactiveSelectionBackground': colors.primary + '25',
'editorLineNumber.foreground': colors.mutedFg,
'editorLineNumber.activeForeground': colors.fg,
'editor.lineHighlightBackground': colors.fg + '08',
'editorWidget.background': colors.card,
'editorWidget.foreground': colors.fg,
'editorWidget.border': colors.border,
'input.background': colors.card,
'input.foreground': colors.fg,
'input.border': colors.border,
};
monaco.editor.defineTheme('netcatty-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
colors: themeColors,
});
// Define light theme with custom background
monaco.editor.defineTheme('netcatty-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
colors: themeColors,
});
// Apply the current theme
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, bgColor, customThemeName]);
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class and style
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
useEffect(() => {
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
setBgColor(getBackgroundColor());
setThemeSignal(getThemeSignal());
};
const observer = new MutationObserver(updateTheme);
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'style', 'data-immersive-theme'],
});
return () => observer.disconnect();
}, []);

View File

@@ -4,7 +4,7 @@
import React, { memo, useMemo } from 'react';
import { Check } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { TERMINAL_THEMES, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
@@ -58,15 +58,52 @@ interface ThemeListProps {
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
const { t } = useI18n();
const customThemes = useCustomThemes();
const deletedSelectedTheme = useMemo(
() => (selectedThemeId
&& !isUiMatchTerminalThemeId(selectedThemeId)
&& !TERMINAL_THEMES.some((theme) => theme.id === selectedThemeId)
&& !customThemes.some((theme) => theme.id === selectedThemeId)
? selectedThemeId
: null),
[customThemes, selectedThemeId],
);
const hiddenSelectedTheme = useMemo(
() => (isUiMatchTerminalThemeId(selectedThemeId)
? TERMINAL_THEMES.find(theme => theme.id === selectedThemeId) || null
: null),
[selectedThemeId],
);
const { darkThemes, lightThemes } = useMemo(() => {
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
const dark = USER_VISIBLE_TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = USER_VISIBLE_TERMINAL_THEMES.filter(t => t.type === 'light');
return { darkThemes: dark, lightThemes: light };
}, []);
return (
<>
{hiddenSelectedTheme && (
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
{t('terminal.hiddenTheme.title')}
</div>
<div className="text-sm font-medium text-foreground">{hiddenSelectedTheme.name}</div>
<div className="text-[11px] text-muted-foreground mt-1">
{t('terminal.hiddenTheme.desc')}
</div>
</div>
)}
{deletedSelectedTheme && (
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
Missing Theme
</div>
<div className="text-sm font-medium text-foreground">{deletedSelectedTheme}</div>
<div className="text-[11px] text-muted-foreground mt-1">
This custom theme is no longer available. Pick another theme to replace it.
</div>
</div>
)}
{/* Dark Themes Section */}
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">

View File

@@ -2,6 +2,7 @@ import React from 'react';
import {
AsidePanel,
AsidePanelContent,
type AsidePanelLayout,
} from './ui/aside-panel';
import { ScrollArea } from './ui/scroll-area';
import { ThemeList } from './ThemeList';
@@ -13,6 +14,7 @@ interface ThemeSelectPanelProps {
onClose: () => void;
onBack?: () => void;
showBackButton?: boolean;
layout?: AsidePanelLayout;
}
const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
@@ -22,6 +24,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
onClose,
onBack,
showBackButton = true,
layout = 'overlay',
}) => {
return (
<AsidePanel
@@ -30,6 +33,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
title="Select Color Theme"
showBackButton={showBackButton}
onBack={onBack}
layout={layout}
>
<AsidePanelContent className="p-0">
<ScrollArea className="h-full">

View File

@@ -12,7 +12,7 @@ import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
import { SyncStatusButton } from './SyncStatusButton';
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
@@ -21,6 +21,7 @@ const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as Re
interface TopTabsProps {
theme: 'dark' | 'light';
followAppTerminalTheme?: boolean;
hosts: Host[];
sessions: TerminalSession[];
orphanSessions: TerminalSession[];
@@ -35,6 +36,7 @@ interface TopTabsProps {
onRenameWorkspace: (workspaceId: string) => void;
onCloseWorkspace: (workspaceId: string) => void;
onCloseLogView: (logViewId: string) => void;
onCloseTabsBatch: (targetIds: string[]) => void;
onOpenQuickSwitcher: () => void;
onToggleTheme: () => void;
onOpenSettings: () => void;
@@ -43,6 +45,7 @@ interface TopTabsProps {
onStartSessionDrag: (sessionId: string) => void;
onEndSessionDrag: () => void;
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
showSftpTab: boolean;
}
// Detect local OS for local terminal tab icons
@@ -227,6 +230,7 @@ WindowControls.displayName = 'WindowControls';
const TopTabsInner: React.FC<TopTabsProps> = ({
theme,
followAppTerminalTheme = false,
hosts,
sessions,
orphanSessions,
@@ -241,6 +245,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onRenameWorkspace,
onCloseWorkspace,
onCloseLogView,
onCloseTabsBatch,
onOpenQuickSwitcher,
onToggleTheme,
onOpenSettings,
@@ -249,6 +254,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onStartSessionDrag,
onEndSessionDrag,
onReorderTabs,
showSftpTab,
}) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store
@@ -300,11 +306,23 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
updateScrollState();
const container = tabsContainerRef.current;
if (container) {
// Translate vertical wheel to horizontal scroll so users can reach
// off-screen tabs with a standard mouse wheel. Trackpad gestures that
// already carry horizontal delta are left alone so native two-finger
// swiping still works.
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0 && e.deltaX === 0) {
e.preventDefault();
container.scrollLeft += e.deltaY;
}
};
container.addEventListener('scroll', updateScrollState);
container.addEventListener('wheel', handleWheel, { passive: false });
const resizeObserver = new ResizeObserver(updateScrollState);
resizeObserver.observe(container);
return () => {
container.removeEventListener('scroll', updateScrollState);
container.removeEventListener('wheel', handleWheel);
resizeObserver.disconnect();
};
}
@@ -478,6 +496,37 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}).filter(Boolean);
}, [orderedTabs, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
// Bulk-close menu items shared by session and workspace context menus.
// Anchor is the tab the user right-clicked on (matches VSCode/JetBrains UX).
const renderBulkCloseItems = (anchorId: string) => {
const anchorIdx = orderedTabs.indexOf(anchorId);
const othersIds = orderedTabs.filter((id) => id !== anchorId);
const rightIds = anchorIdx >= 0 ? orderedTabs.slice(anchorIdx + 1) : [];
return (
<>
<ContextMenuSeparator />
<ContextMenuItem
disabled={othersIds.length === 0}
onClick={() => onCloseTabsBatch(othersIds)}
>
{t('tabs.closeOthers')}
</ContextMenuItem>
<ContextMenuItem
disabled={rightIds.length === 0}
onClick={() => onCloseTabsBatch(rightIds)}
>
{t('tabs.closeToRight')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => onCloseTabsBatch(orderedTabs)}
>
{t('tabs.closeAll')}
</ContextMenuItem>
</>
);
};
// Render the tabs
const renderOrderedTabs = () => {
return orderedTabItems.map((item) => {
@@ -496,6 +545,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuTrigger asChild>
<div
data-tab-id={session.id}
data-tab-type="session"
data-state={activeTabId === session.id ? 'active' : 'inactive'}
onClick={() => onSelectTab(session.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, session.id)}
@@ -504,7 +555,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, session.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
@@ -575,6 +626,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
{t('common.close')}
</ContextMenuItem>
{renderBulkCloseItems(session.id)}
</ContextMenuContent>
</ContextMenu>
);
@@ -595,6 +647,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuTrigger asChild>
<div
data-tab-id={workspace.id}
data-tab-type="workspace"
data-state={isActive ? 'active' : 'inactive'}
onClick={() => onSelectTab(workspace.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, workspace.id)}
@@ -603,7 +657,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, workspace.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
@@ -679,6 +733,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
{t('common.close')}
</ContextMenuItem>
{renderBulkCloseItems(workspace.id)}
</ContextMenuContent>
</ContextMenu>
);
@@ -693,9 +748,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<div
key={logView.id}
data-tab-id={logView.id}
data-tab-type="logView"
data-state={isActive ? 'active' : 'inactive'}
onClick={() => onSelectTab(logView.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
)}
style={{
backgroundColor: isActive
@@ -765,6 +822,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return (
<div
data-top-tabs-root
data-section="top-tabs"
className="relative w-full bg-secondary app-drag"
style={{
...dragRegionNoSelect,
@@ -782,9 +840,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* Fixed left tabs: Vaults and SFTP */}
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
<div
data-tab-id="vault"
data-tab-type="root"
data-state={isVaultActive ? 'active' : 'inactive'}
onClick={() => onSelectTab('vault')}
className={cn(
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"netcatty-tab relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
)}
style={{
backgroundColor: isVaultActive
@@ -809,40 +870,45 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
>
<FolderLock size={14} /> Vaults
</div>
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
)}
style={{
backgroundColor: isSftpActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isSftpActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP
</div>
{showSftpTab && (
<div
data-tab-id="sftp"
data-tab-type="root"
data-state={isSftpActive ? 'active' : 'inactive'}
onClick={() => onSelectTab('sftp')}
className={cn(
"netcatty-tab relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
)}
style={{
backgroundColor: isSftpActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isSftpActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP
</div>
)}
</div>
{/* Scrollable tabs container with fade masks */}
@@ -935,7 +1001,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onToggleTheme}
disabled={isImmersiveActive}
disabled={isImmersiveActive && !followAppTerminalTheme}
title="Toggle theme"
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
@@ -964,7 +1030,10 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.isMacClient === next.isMacClient &&
prev.onOpenSettings === next.onOpenSettings &&
prev.onSyncNow === next.onSyncNow &&
prev.isImmersiveActive === next.isImmersiveActive
prev.onToggleTheme === next.onToggleTheme &&
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
prev.isImmersiveActive === next.isImmersiveActive &&
prev.showSftpTab === next.showSftpTab
);
};

View File

@@ -36,10 +36,14 @@ import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
import { getEffectiveHostDistro, sanitizeHost, upsertHostById } from "../domain/host";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED, STORAGE_KEY_SHOW_RECENT_HOSTS } from "../infrastructure/config/storageKeys";
import {
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
} from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import {
@@ -101,6 +105,10 @@ const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
type DropTarget =
| { kind: "root" }
| { kind: "group"; path: string };
// Props without isActive - it's now subscribed internally
interface VaultViewProps {
hosts: Host[];
@@ -143,6 +151,8 @@ interface VaultViewProps {
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
groupConfigs: GroupConfig[];
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
showRecentHosts: boolean;
showOnlyUngroupedHostsInRoot: boolean;
// Optional: navigate to a specific section on mount or when changed
navigateToSection?: VaultSection | null;
onNavigateToSectionHandled?: () => void;
@@ -189,6 +199,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onRunSnippet,
groupConfigs,
onUpdateGroupConfigs,
showRecentHosts,
showOnlyUngroupedHostsInRoot,
navigateToSection,
onNavigateToSectionHandled,
}) => {
@@ -222,12 +234,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
false,
);
const [isBreadcrumbDragOver, setIsBreadcrumbDragOver] = useState(false);
const [showRecentHosts, _setShowRecentHosts] = useStoredBoolean(
STORAGE_KEY_SHOW_RECENT_HOSTS,
true,
);
const [dragOverDropTarget, setDragOverDropTarget] = useState<DropTarget | null>(null);
const [confirmedDropTarget, setConfirmedDropTarget] = useState<DropTarget | null>(null);
const dropTargetPulseTimeoutRef = useRef<number | null>(null);
// Handle external navigation requests
useEffect(() => {
@@ -237,6 +246,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
}, [navigateToSection, onNavigateToSectionHandled]);
useEffect(() => {
return () => {
if (dropTargetPulseTimeoutRef.current !== null) {
window.clearTimeout(dropTargetPulseTimeoutRef.current);
}
};
}, []);
// View mode, sorting, and tag filter state
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
@@ -253,6 +270,21 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [editingHost, setEditingHost] = useState<Host | null>(null);
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
// Close host panel if the host being edited was deleted.
// Track previous host IDs so we only close for actual deletions, not for
// unsaved new/duplicated hosts whose IDs were never in the hosts array.
const knownHostIdsRef = useRef(new Set(hosts.map(h => h.id)));
useEffect(() => {
const currentIds = new Set(hosts.map(h => h.id));
// Check against previous IDs before updating the ref
if (editingHost && knownHostIdsRef.current.has(editingHost.id) && !currentIds.has(editingHost.id)) {
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}
knownHostIdsRef.current = currentIds;
}, [hosts, editingHost]);
// Group panel state
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
const [editingGroupPath, setEditingGroupPath] = useState<string | null>(null);
@@ -263,7 +295,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
if (!group) return undefined;
return resolveGroupDefaults(group, groupConfigs);
}, [editingHost, newHostGroupPath, selectedGroupPath, groupConfigs]);
// Quick connect state
const [quickConnectTarget, setQuickConnectTarget] = useState<{
hostname: string;
@@ -846,6 +877,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
return hostGroup === selectedGroupPath;
});
} else if (showOnlyUngroupedHostsInRoot) {
filtered = filtered.filter((h) => {
const hostGroup = (h.group || "").trim();
return hostGroup === "";
});
}
if (search.trim()) {
const s = search.toLowerCase();
@@ -883,7 +919,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
});
return filtered;
}, [hosts, selectedGroupPath, search, selectedTags, sortMode]);
}, [hosts, selectedGroupPath, showOnlyUngroupedHostsInRoot, search, selectedTags, sortMode]);
// Pinned hosts for root-level display (not inside a subgroup)
// Respects active search and tag filters
@@ -928,19 +964,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
return filtered
.sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0))
.slice(0, 20);
.slice(0, 6);
}, [hosts, selectedGroupPath, search, selectedTags]);
// IDs of hosts already shown in Pinned/Recent sections at root level,
// so the main host list can exclude them to avoid duplicates.
const pinnedRecentIds = useMemo(() => {
const ids = new Set<string>();
for (const h of pinnedHosts) ids.add(h.id);
if (showRecentHosts) {
for (const h of recentHosts) ids.add(h.id);
}
return ids;
}, [pinnedHosts, recentHosts, showRecentHosts]);
// No longer deduplicate pinned/recent hosts from the main list,
// so hosts always appear in their groups regardless of pinned/recent status.
const pinnedRecentIds = useMemo(() => new Set<string>(), []);
const visibleDisplayedHosts = useMemo(
() => displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)),
[displayedHosts, selectedGroupPath, pinnedRecentIds],
);
// For tree view: apply search, tag filter, and sorting, but not group filtering
const treeViewHosts = useMemo(() => {
@@ -1104,6 +1137,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- findGroupNode is derived from buildGroupTree
}, [buildGroupTree, selectedGroupPath, customGroups]);
const shouldHideEmptyRootHostsSection = useMemo(() => {
if (selectedGroupPath || viewMode === "tree") return false;
if (search.trim() || selectedTags.length > 0) return false;
if (visibleDisplayedHosts.length > 0) return false;
return (
displayedGroups.length > 0 ||
pinnedHosts.length > 0 ||
(showRecentHosts && recentHosts.length > 0)
);
}, [
selectedGroupPath,
viewMode,
search,
selectedTags.length,
visibleDisplayedHosts.length,
displayedGroups.length,
pinnedHosts.length,
showRecentHosts,
recentHosts.length,
]);
// Known Hosts callbacks - use refs to keep stable references
// Store latest values in refs so callbacks don't need to depend on them
@@ -1421,9 +1474,46 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}, [managedSources]);
const isHostsSectionActive = currentSection === "hosts";
const hasHostsSidePanel =
isHostsSectionActive &&
((isGroupPanelOpen && !!editingGroupPath) || isHostPanelOpen);
const splitViewGridStyle = hasHostsSidePanel
? {
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 220px), 280px))",
justifyContent: "start" as const,
}
: undefined;
const moveHostToGroup = (hostId: string, groupPath: string | null) => {
const isSameDropTarget = useCallback((a: DropTarget | null, b: DropTarget | null) => {
if (!a || !b) return a === b;
if (a.kind !== b.kind) return false;
if (a.kind === "root") return true;
return a.path === b.path;
}, []);
const pulseDropTarget = useCallback((target: DropTarget) => {
setConfirmedDropTarget(target);
if (dropTargetPulseTimeoutRef.current !== null) {
window.clearTimeout(dropTargetPulseTimeoutRef.current);
}
dropTargetPulseTimeoutRef.current = window.setTimeout(() => {
setConfirmedDropTarget((current) => (isSameDropTarget(current, target) ? null : current));
dropTargetPulseTimeoutRef.current = null;
}, 900);
}, [isSameDropTarget]);
const setGroupDragOverDropTarget = useCallback((path: string | null) => {
setDragOverDropTarget(path ? { kind: "group", path } : null);
}, []);
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
const targetGroup = groupPath || "";
const hostToMove = hosts.find((h) => h.id === hostId);
if (!hostToMove || (hostToMove.group || "") === targetGroup) {
setDragOverDropTarget(null);
return;
}
// Find the most specific (deepest) managed source that matches the target group
const targetManagedSource = managedSources
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
@@ -1450,7 +1540,23 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
};
}),
);
};
setDragOverDropTarget(null);
pulseDropTarget(groupPath ? { kind: "group", path: groupPath } : { kind: "root" });
toast.success(
t("vault.hosts.moveToGroup.success", {
host: hostToMove.label,
group: groupPath || t("vault.hosts.allHosts"),
}),
);
}, [hosts, managedSources, onUpdateHosts, pulseDropTarget, t]);
const getDropTargetClasses = (target: DropTarget) =>
cn(
isSameDropTarget(dragOverDropTarget, target) &&
"!bg-[#e7ebf0] dark:!bg-white/[0.10]",
isSameDropTarget(confirmedDropTarget, target) &&
"!bg-[#dde3ea] dark:!bg-white/[0.14]",
);
const handleUnmanageGroup = useCallback((groupPath: string) => {
const source = managedSources.find(s => s.groupName === groupPath);
@@ -1479,13 +1585,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Component no longer handles visibility - that's done by VaultViewWrapper
return (
<div ref={rootRef} className="absolute inset-0 min-h-0 flex">
<div ref={rootRef} className="absolute inset-0 min-h-0 flex" data-section="vault-view">
{/* Sidebar */}
<TooltipProvider delayDuration={100}>
<div className={cn(
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
sidebarCollapsed ? "w-14" : "w-52"
)}>
<div
className={cn(
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
sidebarCollapsed ? "w-14" : "w-52"
)}
data-section="vault-sidebar"
>
<div className={cn(
"py-4 flex items-center",
sidebarCollapsed ? "px-2 justify-center" : "px-4"
@@ -1648,12 +1757,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</TooltipProvider>
{/* Main Area */}
<div className="flex-1 flex flex-col min-h-0 relative">
<div
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
data-section="vault-main"
>
<header
className={cn(
"border-b border-border/50 bg-secondary/80 backdrop-blur app-drag",
!isHostsSectionActive && "hidden",
)}
data-section="vault-hosts-header"
>
<div className="h-14 px-4 py-2 flex items-center gap-3">
<div className="relative flex-1 app-no-drag">
@@ -1757,14 +1870,25 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<CheckSquare size={16} />
</Button>
</div>
{/* New Host split button */}
<div className="flex items-center app-no-drag">
{/* New Host split button — collapses with an animation when the
host details / new-host aside panel is open, since the button
would be a no-op in that state. */}
<div
className={cn(
"flex items-center app-no-drag overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
isHostPanelOpen
? "max-w-0 opacity-0 -ml-2 pointer-events-none"
: "max-w-[260px] opacity-100",
)}
aria-hidden={isHostPanelOpen}
>
<Dropdown>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
size="sm"
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none app-no-drag"
onClick={handleNewHost}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Plus size={14} className="mr-2" /> {t("vault.hosts.newHost")}
</Button>
@@ -1772,6 +1896,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<Button
size="sm"
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none app-no-drag"
tabIndex={isHostPanelOpen ? -1 : 0}
>
<ChevronDown size={14} />
</Button>
@@ -1808,22 +1933,37 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</DropdownContent>
</Dropdown>
</div>
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
onClick={onCreateLocalTerminal}
{/* Terminal + Serial — collapse together with an animation when
the host details / new-host aside panel is open, freeing
horizontal space for the panel. */}
<div
className={cn(
"flex items-center gap-3 overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
isHostPanelOpen
? "max-w-0 opacity-0 -ml-3 pointer-events-none"
: "max-w-[320px] opacity-100",
)}
aria-hidden={isHostPanelOpen}
>
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
onClick={() => setIsSerialModalOpen(true)}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
onClick={onCreateLocalTerminal}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
onClick={() => setIsSerialModalOpen(true)}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
</div>
</div>
</header>
@@ -1833,24 +1973,34 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
"flex-1 overflow-auto px-4 py-4 space-y-6",
!isHostsSectionActive && "hidden",
)}
data-section="vault-host-list"
onDragEndCapture={() => setDragOverDropTarget(null)}
>
<section className="space-y-2">
{viewMode !== "tree" && (
<div className="flex items-center gap-2 text-sm font-semibold">
<button
className={cn(
"text-primary hover:underline transition-all rounded px-1 -mx-1",
isBreadcrumbDragOver && "ring-2 ring-primary bg-primary/10",
"text-primary hover:underline transition-colors duration-150 rounded px-1 -mx-1",
getDropTargetClasses({ kind: "root" }),
)}
onClick={() => setSelectedGroupPath(null)}
onDragOver={(e) => {
e.preventDefault();
setIsBreadcrumbDragOver(true);
setDragOverDropTarget({ kind: "root" });
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget((current) =>
current?.kind === "root" ? null : current,
);
}}
onDragLeave={() => setIsBreadcrumbDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsBreadcrumbDragOver(false);
setDragOverDropTarget(null);
const groupPath = e.dataTransfer.getData("group-path");
const hostId = e.dataTransfer.getData("host-id");
if (groupPath) moveGroup(groupPath, null);
@@ -1898,9 +2048,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</h3>
<div className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0",
)}>
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}>
{pinnedHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
@@ -1998,9 +2152,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</h3>
<div className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0",
)}>
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}>
{recentHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
@@ -2099,9 +2257,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
displayedGroups.length === 0 ? "hidden" : "",
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0",
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
onDragOver={(e) => {
e.preventDefault();
}}
@@ -2120,10 +2282,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuTrigger asChild>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer transition-colors duration-150",
viewMode === "grid"
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
getDropTargetClasses({ kind: "group", path: node.path }),
)}
draggable
onDragStart={(e) =>
@@ -2136,10 +2299,21 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget({ kind: "group", path: node.path });
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget((current) =>
current?.kind === "group" && current.path === node.path ? null : current,
);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget(null);
const hostId =
e.dataTransfer.getData("host-id");
const groupPath =
@@ -2211,6 +2385,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
</section>
{!shouldHideEmptyRootHostsSection && (
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
@@ -2218,7 +2393,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
</span>
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
{t("vault.hosts.header.live", { count: sessions.length })}
@@ -2306,6 +2481,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={(path) =>
getDropTargetClasses({ kind: "group", path })
}
setDragOverDropTarget={setGroupDragOverDropTarget}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<div className="space-y-6">
@@ -2323,9 +2502,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<div
className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0",
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
>
{group.hosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
const safeHost = sanitizeHost(host);
@@ -2464,11 +2647,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<div
className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0",
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
>
{displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
{visibleDisplayedHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
@@ -2600,6 +2787,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</div>
)}
</section>
)}
</div>
{currentSection === "snippets" && (
@@ -2726,12 +2914,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
allHosts={hosts}
groups={allGroupPaths}
terminalThemeId={terminalThemeId}
groupConfigs={groupConfigs}
terminalFontSize={terminalFontSize}
onSave={handleSaveGroupConfig}
onCancel={() => {
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
}}
layout="inline"
/>
)}
@@ -2749,14 +2939,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
groupDefaults={editingHostGroupDefaults}
groupConfigs={groupConfigs}
onSave={(host) => {
// Check if host already exists in the list (for updates vs. new/duplicate)
const hostExists = hosts.some((h) => h.id === host.id);
onUpdateHosts(
hostExists
? hosts.map((h) => (h.id === host.id ? host : h))
: [...hosts, host],
);
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
@@ -2771,6 +2956,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Array.from(new Set([...customGroups, groupPath])),
);
}}
layout="inline"
/>
)}
@@ -2781,16 +2967,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
allTags={allTags}
groups={allGroupPaths}
onSave={(host) => {
onUpdateHosts(
hosts.map((h) => (h.id === host.id ? host : h)),
);
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
onCancel={() => {
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
layout="inline"
/>
)}

View File

@@ -8,7 +8,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
className={cn('relative flex-1 overflow-x-hidden overflow-y-hidden', className)}
initial="instant"
resize="smooth"
role="log"
@@ -20,7 +20,7 @@ export type ConversationContentProps = ComponentProps<typeof StickToBottom.Conte
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content
className={cn('flex flex-col gap-4 p-4', className)}
className={cn('flex min-w-0 max-w-full flex-col gap-4 overflow-x-hidden p-4', className)}
{...props}
/>
);

View File

@@ -62,7 +62,7 @@ export const MessageResponse = memo(
// Style the rendered markdown
// Code: base styles (code-block overrides are in index.css)
'[&_code]:text-[12px] [&_code]:font-mono',
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%] [&_p_code]:whitespace-normal [&_p_code]:[overflow-wrap:anywhere]',
'[&_p]:my-1.5',
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -40,6 +41,7 @@ function formatToolResult(result: unknown): string {
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
className?: string;
args?: Record<string, unknown>;
result?: unknown;
isError?: boolean;

View File

@@ -6,12 +6,11 @@
* and a bottom toolbar with muted controls + subtle send button.
*/
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
import type { FormEvent } from 'react';
import type { UploadedFile } from '../../application/state/useFileUpload';
import {
PromptInput,
PromptInputFooter,
@@ -21,7 +20,11 @@ import {
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
import { ScrollArea } from '../ui/scroll-area';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
interface ChatInputProps {
value: string;
@@ -48,6 +51,14 @@ interface ChatInputProps {
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** User skills currently selected for the next send */
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Available user skills for /skill-slug insertion */
userSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Callback to add a selected user skill */
onAddUserSkill?: (slug: string) => void;
/** Callback to remove a selected user skill */
onRemoveUserSkill?: (slug: string) => void;
/** Permission mode (only shown for Catty Agent) */
permissionMode?: AIPermissionMode;
/** Callback when user changes permission mode */
@@ -72,38 +83,76 @@ const ChatInput: React.FC<ChatInputProps> = ({
onAddFiles,
onRemoveFile,
hosts = [],
selectedUserSkills = [],
userSkills = [],
onAddUserSkill,
onRemoveUserSkill,
permissionMode,
onPermissionModeChange,
}) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashSkill' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Active highlight index for @ mention / slash skill keyboard navigation
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
// Derived booleans for readability
const showModelPicker = activeMenu === 'model';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showSlashSkillPicker = activeMenu === 'slashSkill';
const showPermPicker = activeMenu === 'perm';
const closeAllMenus = useCallback(() => {
setActiveMenu(null);
setMenuPos(null);
setInputPanelPos(null);
setHoveredModelId(null);
setShowHostSubmenu(false);
setSlashQuery('');
setSlashRange(null);
}, []);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputShellRef = useRef<HTMLDivElement>(null);
const modelBtnRef = useRef<HTMLButtonElement>(null);
const permBtnRef = useRef<HTMLButtonElement>(null);
const attachBtnRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
const beforeCaret = text.slice(0, caretPosition);
const match = /(^|\s)\/([a-z0-9-]*)$/i.exec(beforeCaret);
if (!match) return null;
const start = beforeCaret.length - match[0].length + match[1].length;
return {
start,
end: beforeCaret.length,
query: String(match[2] || '').toLowerCase(),
};
}, []);
const getInputPanelMenuPos = useCallback(() => {
const rect = inputShellRef.current?.getBoundingClientRect();
if (!rect) return null;
const horizontalMargin = 12;
const safeRight = window.innerWidth - horizontalMargin;
const width = Math.min(rect.width, safeRight - rect.left);
return {
left: rect.left,
bottom: window.innerHeight - rect.top + 8,
width,
};
}, []);
const handleInputChange = useCallback((newValue: string) => {
onChange(newValue);
const caretPosition = textareaRef.current?.selectionStart ?? newValue.length;
// Detect if user just typed @
if (
hosts.length > 0 &&
@@ -111,16 +160,28 @@ const ChatInput: React.FC<ChatInputProps> = ({
newValue.endsWith('@')
) {
// Position the popover near the textarea
const el = textareaRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
}
const pos = getInputPanelMenuPos();
if (pos) setInputPanelPos(pos);
setActiveMenu('atMention');
} else if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
return;
}
}, [onChange, value, hosts.length, showAtMention]);
const slashTrigger = findSlashTrigger(newValue, caretPosition);
if (userSkills.length > 0 && slashTrigger) {
const pos = getInputPanelMenuPos();
if (pos) setInputPanelPos(pos);
setSlashQuery(slashTrigger.query);
setSlashRange({ start: slashTrigger.start, end: slashTrigger.end });
setActiveMenu('slashSkill');
return;
}
if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
} else if (showSlashSkillPicker) {
closeAllMenus();
}
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, userSkills.length, showSlashSkillPicker, closeAllMenus, getInputPanelMenuPos]);
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
// Replace the trailing @ with @hostname
@@ -133,10 +194,117 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [value, onChange, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashSkill') => {
const pos = getInputPanelMenuPos();
if (!pos) return;
setInputPanelPos(pos);
if (menu === 'slashSkill') {
setSlashQuery('');
setSlashRange(null);
}
setActiveMenu(menu);
}, [getInputPanelMenuPos]);
const filteredUserSkills = useMemo(() => userSkills.filter((skill) => {
if (!slashQuery) return true;
const lowerQuery = slashQuery.toLowerCase();
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
}), [userSkills, slashQuery]);
const removeSlashQueryFromInput = useCallback(() => {
if (!slashRange) return value;
const before = value.slice(0, slashRange.start);
const after = value.slice(slashRange.end);
if (/\s$/.test(before) && /^\s/.test(after)) {
return `${before}${after.slice(1)}`;
}
return `${before}${after}`;
}, [slashRange, value]);
const insertUserSkillToken = useCallback((skill: { slug: string }) => {
onAddUserSkill?.(skill.slug);
if (slashRange) {
onChange(removeSlashQueryFromInput());
}
closeAllMenus();
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
// Reset active highlight when a menu opens or when the *identity* of the
// visible items changes. Watching only `.length` misses cases where the
// filter produces a different set with the same count (e.g. user types
// another character into the slash query) — Enter would then commit an
// unexpected item. Derive a stable key from the visible ids instead.
const atMentionKey = useMemo(
() => hosts.map((h) => h.sessionId).join('|'),
[hosts],
);
const slashSkillKey = useMemo(
() => filteredUserSkills.map((s) => s.id).join('|'),
[filteredUserSkills],
);
useEffect(() => {
if (showAtMention) setActiveMenuIndex(0);
}, [showAtMention, atMentionKey]);
useEffect(() => {
if (showSlashSkillPicker) setActiveMenuIndex(0);
}, [showSlashSkillPicker, slashSkillKey]);
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return;
// @ mention popover keyboard navigation
if (showAtMention && hosts.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % hosts.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + hosts.length) % hosts.length);
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const host = hosts[Math.min(activeMenuIndex, hosts.length - 1)];
if (host) handleSelectAtMention(host);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
}
// / skill popover keyboard navigation
if (showSlashSkillPicker && filteredUserSkills.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % filteredUserSkills.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + filteredUserSkills.length) % filteredUserSkills.length);
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const skill = filteredUserSkills[Math.min(activeMenuIndex, filteredUserSkills.length - 1)];
if (skill) insertUserSkillToken(skill);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
}
}, [showAtMention, hosts, showSlashSkillPicker, filteredUserSkills, activeMenuIndex, handleSelectAtMention, insertUserSkillToken, closeAllMenus]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const pastedFiles = Array.from(e.clipboardData.items)
.map((item) => item.getAsFile())
.filter(Boolean) as File[];
.map((item: DataTransferItem) => item.getAsFile())
.filter((f): f is File => !!f);
if (pastedFiles.length > 0) {
e.preventDefault();
onAddFiles?.(pastedFiles);
@@ -166,21 +334,40 @@ const ChatInput: React.FC<ChatInputProps> = ({
// Permission mode chip removed — agents run in autonomous mode
// selectedModelId may be "model/thinking" for codex
const selectedBaseModelId = selectedModelId?.split('/')[0];
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
// selectedModelId may be "<modelId>/<thinkingLevel>" for codex ChatGPT models
// (e.g. "gpt-5.4/high"). Note: custom config.toml / OpenRouter model ids
// themselves can contain '/' (e.g. "qwen/qwen3.6-plus"), so don't just
// split on the first '/'. Match against the full id first; only treat the
// trailing segment as a thinking level when we find a preset whose
// declared thinkingLevels make the combined form equal to selectedModelId.
const { selectedPreset, selectedThinking } = (() => {
if (!selectedModelId) return { selectedPreset: undefined, selectedThinking: undefined };
const direct = modelPresets.find(m => m.id === selectedModelId);
if (direct) return { selectedPreset: direct, selectedThinking: undefined };
const viaThinking = modelPresets.find(
m => m.thinkingLevels?.some(level => `${m.id}/${level}` === selectedModelId),
);
if (viaThinking) {
const thinking = selectedModelId.slice(viaThinking.id.length + 1);
return { selectedPreset: viaThinking, selectedThinking: thinking };
}
return { selectedPreset: undefined, selectedThinking: undefined };
})();
const selectedBaseModelId = selectedPreset?.id;
const modelLabel = selectedPreset
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
: modelName || providerName || t('ai.chat.noModel');
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
const selectedSkillChipClassName =
'inline-flex h-7 items-center gap-1.5 rounded-full border border-primary/18 bg-primary/8 pl-2.5 pr-1.5 text-[11px] font-medium text-foreground/86 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]';
const iconButtonClassName =
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
return (
<div className="shrink-0 px-4 pb-4">
<div ref={inputShellRef} className="relative">
<PromptInput onSubmit={handleSubmit}>
{/* File attachment chips */}
{files.length > 0 && (
@@ -224,13 +411,44 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Textarea with expand toggle */}
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
{selectedUserSkills.length > 0 && (
<div className="px-3 pt-3 pb-1.5">
<div className="flex flex-wrap gap-2">
{selectedUserSkills.map((skill) => (
<div
key={skill.id}
className={selectedSkillChipClassName}
title={skill.description || skill.name || skill.slug}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
))}
</div>
</div>
)}
<PromptInputTextarea
ref={textareaRef}
value={value}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleTextareaKeyDown}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled}
className={expanded ? 'max-h-[220px]' : undefined}
className={[
selectedUserSkills.length > 0 ? 'pt-1.5' : undefined,
expanded ? 'max-h-[220px]' : undefined,
].filter(Boolean).join(' ')}
maxLength={100000}
/>
<button
type="button"
@@ -243,31 +461,93 @@ const ChatInput: React.FC<ChatInputProps> = ({
</div>
{/* @ mention popover */}
{showAtMention && hosts.length > 0 && menuPos && createPortal(
{showAtMention && hosts.length > 0 && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Mention host"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
aria-activedescendant={hosts[activeMenuIndex] ? `at-mention-${hosts[activeMenuIndex].sessionId}` : undefined}
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
>
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="option"
onClick={() => handleSelectAtMention(host)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
<ScrollArea className="max-h-[280px]">
<div className="p-1">
{hosts.map((host, idx) => {
const isActive = idx === activeMenuIndex;
const showHostnameLine = host.label
&& host.hostname !== host.label
&& !host.label.includes(host.hostname);
return (
<button
id={`at-mention-${host.sessionId}`}
key={host.sessionId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActiveMenuIndex(idx)}
onClick={() => handleSelectAtMention(host)}
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="truncate">{host.label || host.hostname}</span>
</div>
{showHostnameLine ? (
<div className="pl-3.5 text-[10px] text-muted-foreground/60 truncate">
{host.hostname}
</div>
) : null}
</button>
);
})}
</div>
</ScrollArea>
</div>
</>,
document.body,
)}
{/* / skill popover */}
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Insert user skill"
aria-activedescendant={filteredUserSkills[activeMenuIndex] ? `slash-skill-${filteredUserSkills[activeMenuIndex].id}` : undefined}
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
>
<ScrollArea className="max-h-[280px]">
<div className="p-1">
{filteredUserSkills.map((skill, idx) => {
const isActive = idx === activeMenuIndex;
return (
<button
id={`slash-skill-${skill.id}`}
key={skill.id}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActiveMenuIndex(idx)}
onClick={() => insertUserSkillToken(skill)}
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px]">
<Package size={12} className="text-muted-foreground/55 shrink-0" />
<span className="text-foreground/90">/{skill.slug}</span>
</div>
{skill.description ? (
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
{skill.description}
</div>
) : null}
</button>
);
})}
</div>
</ScrollArea>
</div>
</>,
document.body,
@@ -322,48 +602,30 @@ const ChatInput: React.FC<ChatInputProps> = ({
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<div
className="relative"
onMouseEnter={() => setShowHostSubmenu(true)}
onMouseLeave={() => setShowHostSubmenu(false)}
onFocus={() => setShowHostSubmenu(true)}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
<button
type="button"
role="menuitem"
aria-label="Mention host"
onClick={() => openInputPanelMenu('atMention')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
{userSkills.length > 0 && (
<button
type="button"
role="menuitem"
aria-label="Mention host"
aria-expanded={showHostSubmenu && hosts.length > 0}
aria-label="Insert user skill"
onClick={() => openInputPanelMenu('slashSkill')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
<Package size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuUserSkills')}</span>
<ChevronRight size={10} className="text-muted-foreground/50" />
</button>
{showHostSubmenu && hosts.length > 0 && (
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="menuitem"
onClick={() => {
const mention = `@${host.label || host.hostname} `;
onChange(value + mention);
closeAllMenus();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
</div>
)}
</div>
)}
</div>
</>,
document.body,
@@ -375,7 +637,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
if (!hasModelPicker) return;
if (!showModelPicker) {
const rect = modelBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
if (rect) {
// Clamp so the popover stays inside the viewport when
// the chip is near the right edge of a narrow AI side
// panel.
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8));
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
}
setActiveMenu('model');
} else {
closeAllMenus();
@@ -395,8 +663,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div
role="listbox"
aria-label="Select model"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
onMouseLeave={() => setHoveredModelId(null)}
>
{modelPresets.map(preset => {
@@ -420,12 +688,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}
}}
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
>
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
<span className="flex-1 text-foreground/85">{preset.name}</span>
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
<span className="flex-1 min-w-0 truncate text-foreground/85">{preset.name}</span>
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50 shrink-0" />}
</button>
{/* Thinking level sub-menu */}
{hasThinking && hoveredModelId === preset.id && (
@@ -555,6 +822,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
</div>
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
};

View File

@@ -177,13 +177,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
return (
<React.Fragment key={message.id}>
{message.toolResults?.map((tr) => (
<ToolCall
key={tr.toolCallId}
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>
<div key={tr.toolCallId}>
<ToolCall
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>
</div>
))}
</React.Fragment>
);
@@ -255,15 +256,16 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
<div key={tc.id}>
<ToolCall
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
);
})}
@@ -308,34 +310,35 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
<div key={tc.id}>
<ToolCall
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
);
})}
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
{Array.from(pendingApprovals.entries())
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
.map((entry) => {
const [id, req] = entry;
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
.map(([id, req]) => {
return (
<ToolCall
key={id}
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
onApprove={() => handleApprove(id)}
onReject={() => handleReject(id)}
/>
<div key={id}>
<ToolCall
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
onApprove={() => handleApprove(id)}
onReject={() => handleReject(id)}
/>
</div>
);
})}
{/* Streaming indicator — only when no content and no thinking yet */}

View File

@@ -0,0 +1,662 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
import {
buildAcpHistoryMessages,
buildAcpHistoryMessagesForBridge,
} from "./acpHistory.ts";
function message(
id: string,
role: ChatMessage["role"],
content: string,
extra: Partial<ChatMessage> = {},
): ChatMessage {
return {
id,
role,
content,
timestamp: 1,
...extra,
};
}
test("buildAcpHistoryMessages compacts older ACP context and keeps only recent raw turns", () => {
const messages: ChatMessage[] = [
message("u1", "user", "我希望最小改动,不要添加很多 test"),
message("a1", "assistant", "已按最小改动处理"),
message("u2", "user", "MCP 不允许使用Windows 上不要假设 pwsh.exe"),
message("a2", "assistant", "PR #738 已创建commit 4181a2c"),
message("u3", "user", "帮我上网查查优化方案,每轮都带历史太慢了"),
message("a3", "assistant", "建议 ACP history compaction"),
message("tool1", "tool", "", {
toolResults: [
{
toolCallId: "search",
content: `error: ${"large output ".repeat(500)}`,
isError: true,
},
],
}),
message("u4", "user", "好的"),
message("a4", "assistant", "准备实现"),
message("u5", "user", "继续"),
message("a5", "assistant", "继续处理"),
message("u6", "user", "现在提交"),
message("a6", "assistant", "还没提交"),
];
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Compact prior Netcatty UI context/);
assert.match(result[0].content, /最小改动/);
assert.match(result[0].content, /pwsh\.exe/);
assert.match(result[0].content, /PR #738/);
assert.ok(result[0].content.length <= 3000);
assert.ok(result.length <= 7);
assert.deepEqual(
result.slice(1).map((entry) => entry.content),
["好的", "准备实现", "继续", "继续处理", "现在提交", "还没提交"],
);
assert.ok(result.every((entry) => entry.content.length <= 3000));
});
test("buildAcpHistoryMessagesForBridge keeps fallback history available for stale ACP session recovery", () => {
const messages = [message("u1", "user", "继续处理这个历史压缩问题")];
assert.equal(buildAcpHistoryMessagesForBridge([], "acp-session-1"), undefined);
assert.deepEqual(
buildAcpHistoryMessagesForBridge(messages, "acp-session-1"),
buildAcpHistoryMessages(messages),
);
});
test("buildAcpHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
const messages: ChatMessage[] = [
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
message("a1", "assistant", "Understood."),
];
for (let index = 2; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `filler assistant message ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Keep this incremental and do not refactor unrelated files\./);
assert.deepEqual(
result.slice(-6).map((entry) => entry.content),
[
"filler user message 11",
"filler assistant message 11",
"filler user message 12",
"filler assistant message 12",
"filler user message 13",
"filler assistant message 13",
],
);
});
test("buildAcpHistoryMessages preserves short important user constraints outside the recent raw window", () => {
const messages: ChatMessage[] = [
message("u1", "user", "不要提交"),
message("a1", "assistant", "收到"),
];
for (let index = 2; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `filler assistant message ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /不要提交/);
});
test("buildAcpHistoryMessages does not treat pr inside ordinary words as important", () => {
// Original intent: `\bpr\b` in IMPORTANT_PATTERNS must NOT match 'pr'
// inside ordinary English words like 'approach' / 'improve' / 'prepare'.
// Those words land at priority=1 (kept only as space allows) while the
// 不要提交 line lands at priority=2 (always preferred). The check below
// doesn't assert that the ordinary words are absent from the compact
// section — they may legitimately survive when budget allows; that's
// intentional after we stopped blanket-dropping short user messages.
// What we DO verify: the priority-2 line is selected, which is only
// possible if the IMPORTANT_PATTERNS regex correctly distinguishes it
// from the surrounding short ordinary-word turns.
const messages: ChatMessage[] = [
message("u1", "user", "不要提交"),
message("a1", "assistant", "收到"),
message("u2", "user", "approach"),
message("a2", "assistant", "ack"),
message("u3", "user", "improve"),
message("a3", "assistant", "ack"),
message("u4", "user", "prepare"),
message("a4", "assistant", "ack"),
];
for (let index = 5; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `filler assistant message ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /不要提交/);
});
test("buildAcpHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
const messages: ChatMessage[] = [];
for (let index = 1; index <= 12; index += 1) {
messages.push(
message(
`u${index}`,
"user",
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
messages.push(
message("u13", "user", "Keep the existing layout and copy wording unchanged."),
message("a13", "assistant", "Understood."),
);
for (let index = 14; index <= 18; index += 1) {
messages.push(
message(
`u${index}`,
"user",
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Keep the existing layout and copy wording unchanged\./);
});
test("buildAcpHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
const messages: ChatMessage[] = [
message("u1", "user", "Please propose a migration plan for the sidebar state."),
message(
"a1",
"assistant",
"Plan: 1. Introduce a dedicated hook for the panel stack. 2. Move the derived view state into that hook. 3. Keep the existing UI copy and layout. 4. Add a regression test around back navigation.",
),
];
for (let index = 2; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
messages.push(message("u14", "user", "Apply step 2 of your plan now."));
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Move the derived view state into that hook\./);
});
test("buildAcpHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
// Regression: short load-bearing instructions like "Use ssh2" / "中文输出"
// would previously be dropped by a blanket length<10 heuristic, even
// though they don't match any TRIVIAL pattern.
const messages: ChatMessage[] = [
message("u1", "user", "Use ssh2"),
message("a1", "assistant", "Got it."),
message("u2", "user", "中文输出"),
message("a2", "assistant", "明白"),
];
// Push enough later turns so u1/u2 fall outside the recent raw window
// and have to survive via the durable-user compaction path.
for (let index = 3; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `filler assistant message ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Use ssh2/);
assert.match(result[0].content, /中文输出/);
});
test("buildAcpHistoryMessages still drops one-word filler user messages", () => {
// Sanity: removing the length<10 heuristic must not cause "ok" / "继续" /
// "thanks" filler to leak into the compact section.
const messages: ChatMessage[] = [
message("u1", "user", "ok"),
message("a1", "assistant", "ack"),
message("u2", "user", "继续"),
message("a2", "assistant", "继续处理"),
];
for (let index = 3; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `filler assistant message ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
// u1 / u2 fall outside the recent raw window. The compact context, if it
// exists, must not surface these trivial turns as durable user requests.
if (result.length > 0 && result[0].role === "user") {
assert.doesNotMatch(result[0].content, /User request: ok\b/);
assert.doesNotMatch(result[0].content, /User request: 继续/);
}
});
test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
// Regression: tool results used to only reach fallback replay via the
// 500-char compact summary. If the user's last interaction produced a
// large tool output (cat/rg/fetched file), any "use that output"-style
// follow-up lost the actual bytes. Now tool messages flow through the
// recent raw window at MAX_RAW_MESSAGE_CHARS (2000).
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
const messages: ChatMessage[] = [
message("u1", "user", "cat /etc/hosts"),
message("a1", "assistant", "", {
toolCalls: [{ id: "call1", name: "terminal", arguments: { cmd: "cat /etc/hosts" } }],
}),
message("tool1", "tool", "", {
toolResults: [
{ toolCallId: "call1", content: bigToolOutput, isError: false },
],
}),
message("u2", "user", "use that output"),
];
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Raw-window tool result carries both the [from ...] provenance label
// and the actual bytes (not just the 500-char compact summary).
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): DATA DATA DATA/);
// Confirm we kept enough bytes to exceed the compact-summary cap.
const toolResultIdx = flat.indexOf("Tool result [from terminal");
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
const toolResultChunk = flat.slice(toolResultIdx);
assert.ok(
toolResultChunk.length > 600,
`expected tool result chunk to exceed compact cap (~500 chars), got ${toolResultChunk.length}`,
);
});
test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
// Regression: if the raw window starts mid-tool-interaction, the
// preceding assistant tool_call message may be outside the 6-item
// slice. Without the call's name/args inline on the result line, the
// AI sees opaque bytes and "use that output" becomes ambiguous.
const messages: ChatMessage[] = [
// Early filler to push the tool_call off the raw window
message("u0", "user", "prior chatter"),
message("a0", "assistant", "prior reply"),
message("u1", "user", "cat /etc/hosts"),
message("a1", "assistant", "", {
toolCalls: [
{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } },
],
}),
message("tool1", "tool", "", {
toolResults: [
{ toolCallId: "call1", content: "127.0.0.1 localhost", isError: false },
],
}),
message("u2", "user", "use that output"),
message("a2", "assistant", "acknowledged"),
message("u3", "user", "now do the same for /etc/resolv.conf"),
];
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// The tool_result line must carry the originating tool_call's name and
// args, so even if a1 was pushed out of the raw window, the result is
// self-describing.
assert.match(flat, /Tool result \[from terminal_exec/);
assert.match(flat, /cat \/etc\/hosts/);
});
test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
// Regression target: codex review flagged that the compaction path
// scanned messages.entries() over the full transcript. Build a very
// long chat (>> MAX_DURABLE_SCAN_TURNS user turns) and verify that
// only messages within the recent user-turn window contribute
// durable candidates.
const messages: ChatMessage[] = [];
// An ancient high-priority constraint that MUST be aged out.
messages.push(message("old-important", "user", "不要提交 old-marker-xyz"));
messages.push(message("old-ack", "assistant", "收到"));
// 300 filler turns between the ancient constraint and the window —
// well past MAX_DURABLE_SCAN_TURNS (100).
for (let i = 0; i < 300; i += 1) {
messages.push(
message(`u${i}`, "user", `filler user message ${i}`),
message(`a${i}`, "assistant", `filler assistant message ${i}`),
);
}
// A recent constraint that should survive.
messages.push(message("recent-important", "user", "不要提交 recent-marker-abc"));
for (let i = 0; i < 5; i += 1) {
messages.push(
message(`t${i}`, "user", `tail user message ${i}`),
message(`ta${i}`, "assistant", `tail assistant message ${i}`),
);
}
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Recent priority-2 constraint is kept.
assert.match(flat, /recent-marker-abc/);
// Ancient one past the scan window is dropped — proof the bound holds.
assert.doesNotMatch(flat, /old-marker-xyz/);
});
test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
// Regression: the previous bound was MAX_DURABLE_SCAN_MESSAGES=200 on
// the raw message array. In a tool-heavy chat, each user turn can
// expand to 5+ messages (user + assistant w/ toolCalls + N tool
// results + follow-up assistant), so 200 messages might be only
// ~40 user turns. An instruction like "不要提交" from turn 5 would
// fall out of the scan before the turn count justified aging it out.
//
// Now the bound is MAX_DURABLE_SCAN_TURNS=100 user turns. Build a
// chat with only 30 user turns but many messages per turn — the
// early constraint must still survive.
const messages: ChatMessage[] = [];
messages.push(message("early-important", "user", "不要提交 EARLY_CONSTRAINT_MARKER"));
messages.push(message("early-ack", "assistant", "收到"));
// 35 additional turns, each with 6 messages (bloats the total
// message count to >200 without exceeding 100 user turns).
for (let turn = 1; turn < 36; turn += 1) {
messages.push(message(`u${turn}`, "user", `turn ${turn} request`));
messages.push(message(`a${turn}-plan`, "assistant", "let me check", {
toolCalls: [
{ id: `c${turn}a`, name: "terminal_exec", arguments: { cmd: "echo a" } },
{ id: `c${turn}b`, name: "terminal_exec", arguments: { cmd: "echo b" } },
{ id: `c${turn}c`, name: "terminal_exec", arguments: { cmd: "echo c" } },
],
}));
messages.push(message(`t${turn}a`, "tool", "", {
toolResults: [{ toolCallId: `c${turn}a`, content: `result a of turn ${turn}`, isError: false }],
}));
messages.push(message(`t${turn}b`, "tool", "", {
toolResults: [{ toolCallId: `c${turn}b`, content: `result b of turn ${turn}`, isError: false }],
}));
messages.push(message(`t${turn}c`, "tool", "", {
toolResults: [{ toolCallId: `c${turn}c`, content: `result c of turn ${turn}`, isError: false }],
}));
messages.push(message(`a${turn}-done`, "assistant", `turn ${turn} done`));
}
// Sanity: the message count is over 200 even though user turns are 30.
assert.ok(messages.length > 200, `setup: expected > 200 messages, got ${messages.length}`);
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Under the old raw-count bound, the early constraint would age out;
// under the turn-based bound it survives.
assert.match(flat, /EARLY_CONSTRAINT_MARKER/);
});
test("buildAcpHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
// Regression: isSubstantiveAssistantMessage previously required length
// >= 40 OR a small English keyword match OR a numbered list. Short
// load-bearing replies like "Use ssh2" / "rebase instead" / "中文输出"
// satisfied none of those and were silently dropped. After a stale-
// session recovery, "do what you suggested earlier" would then replay
// only the user's question without the assistant's actual decision.
const messages: ChatMessage[] = [
message("u1", "user", "which client should I use"),
message("a1", "assistant", "Use ssh2"),
message("u2", "user", "output language?"),
message("a2", "assistant", "中文输出"),
message("u3", "user", "merge or rebase?"),
message("a3", "assistant", "rebase instead"),
];
// Pad so u1..a3 fall outside the recent raw window (last 6 items) and
// must flow through the durable-assistant compact pass.
for (let index = 4; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
assert.match(flat, /Use ssh2/);
assert.match(flat, /中文输出/);
assert.match(flat, /rebase instead/);
});
test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
// Sanity: removing the length/keyword gate must not let assistant
// filler leak into the compact durable-assistant section.
const messages: ChatMessage[] = [
message("u1", "user", "prompt 1"),
message("a1", "assistant", "ack"),
message("u2", "user", "prompt 2"),
message("a2", "assistant", "明白"),
message("u3", "user", "prompt 3"),
message("a3", "assistant", "got it"),
];
for (let index = 4; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `more filler ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
assert.doesNotMatch(flat, /Assistant context: ack\b/);
assert.doesNotMatch(flat, /Assistant context: got it\b/);
assert.doesNotMatch(flat, /Assistant context: 明白/);
});
test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
// Regression: the raw-window fix covered the last 6 items, but once
// a tool result fell into the compact section (summarizeToolMessage
// path) the `[from <name>(<args>)]` provenance label was absent.
// With multiple older tool outputs, all surfacing as identical
// `Tool result (callN): ...`, follow-ups like "use the resolv.conf
// output" have no way to map to the right call.
const messages: ChatMessage[] = [
// Two distinct tool interactions, both pushed well outside the
// recent raw window by later turns.
message("u1", "user", "show hosts"),
message("a1", "assistant", "", {
toolCalls: [{ id: "call-hosts", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
}),
message("tool1", "tool", "", {
toolResults: [{ toolCallId: "call-hosts", content: "127.0.0.1 localhost", isError: false }],
}),
message("u2", "user", "show resolv.conf"),
message("a2", "assistant", "", {
toolCalls: [{ id: "call-resolv", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
}),
message("tool2", "tool", "", {
toolResults: [{ toolCallId: "call-resolv", content: "nameserver 8.8.8.8", isError: false }],
}),
// Important user text so summarizeMessage picks these up via the
// important-text branch; tool results themselves are always
// summarized regardless of IMPORTANT_PATTERNS.
message("u3", "user", "fallback plan"),
];
// Filler to push the early tool results out of the 6-item raw window
// and into the compact summary section (scanned = last 20).
for (let index = 4; index <= 10; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Both older tool results must now carry provenance labels so a
// follow-up can disambiguate them.
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/hosts/);
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/resolv\.conf/);
});
test("buildAcpHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
// Regression: the scanned loop (last 20) overlaps with recentRaw (last 6).
// Without skipping raw-window items, the same last-6 turns would be
// summarized in the compact section AND appended verbatim in the raw
// section — doubling the budget cost of important user turns / large
// tool output and crowding out older durable context.
//
// Setup: enough filler upfront that u3 ends up OUTSIDE the raw window
// (so it can be asserted absent from raw), then a distinctive "raw
// only" marker that should appear only in the last-6 raw slice.
const messages: ChatMessage[] = [];
for (let index = 1; index <= 6; index += 1) {
messages.push(
message(`uf${index}`, "user", `filler user ${index}`),
message(`af${index}`, "assistant", `filler assistant ${index}`),
);
}
// These are the last 4 user/assistant messages — guaranteed to be in
// the last-6 raw slice. The IMPORTANT markers below would ordinarily
// also get summarized into the compact section, duplicating the cost.
messages.push(
message("u-rec1", "user", "commit now IMPORTANT_RAW_MARKER please"),
message("a-rec1", "assistant", "", {
toolCalls: [{ id: "c1", name: "git", arguments: { op: "commit" } }],
}),
message("tool-rec", "tool", "", {
toolResults: [{ toolCallId: "c1", content: "committed abc123 RAW_TOOL_MARKER", isError: false }],
}),
message("u-rec2", "user", "now push"),
);
const result = buildAcpHistoryMessages(messages);
const compact = result.find((m) => m.content.includes("[Compact prior Netcatty UI context]"));
assert.ok(compact, "expected a compact context message");
// Both markers belong to messages inside the raw window — they must
// not be summarized into compact (which would double-bill them).
assert.doesNotMatch(compact.content, /IMPORTANT_RAW_MARKER/);
assert.doesNotMatch(compact.content, /RAW_TOOL_MARKER/);
// Raw section still carries them verbatim.
const raw = result.filter((m) => !m.content.includes("[Compact prior Netcatty UI context]"));
const rawFlat = raw.map((m) => m.content).join("\n");
assert.match(rawFlat, /IMPORTANT_RAW_MARKER/);
assert.match(rawFlat, /RAW_TOOL_MARKER/);
});
test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
// Regression: keying toolCallIndex by raw toolCall.id alone let a later
// assistant tool_call with the same id overwrite the older one. An
// older tool_result in the replay history would then be annotated
// with the wrong command (e.g. a /etc/hosts result labeled as
// /etc/resolv.conf). Now each tool_result is indexed by its own
// messageId + toolCallId and resolved to the most recent preceding
// call with that id.
const messages: ChatMessage[] = [
message("u1", "user", "show hosts"),
message("a1", "assistant", "", {
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
}),
message("tool-hosts", "tool", "", {
toolResults: [{ toolCallId: "call1", content: "127.0.0.1 localhost HOSTS_BYTES", isError: false }],
}),
// A later assistant turn reuses the id "call1" for a different call.
message("u2", "user", "show resolv"),
message("a2", "assistant", "", {
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
}),
message("tool-resolv", "tool", "", {
toolResults: [{ toolCallId: "call1", content: "nameserver 8.8.8.8 RESOLV_BYTES", isError: false }],
}),
message("u3", "user", "ok"),
];
// Pad so the first interaction lands in the compact summary pass.
for (let index = 4; index <= 10; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Each tool_result must be annotated with ITS OWN preceding call's
// args — not whichever assistant tool_call happened to win the
// last-write on the shared id.
//
// Extract the two Tool-result lines and match each to its expected
// args. Use non-greedy .*? — the args JSON can contain parentheses.
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*HOSTS_BYTES/);
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*RESOLV_BYTES/);
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
});
test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
const messages: ChatMessage[] = [
message("u1", "user", "ok"),
message(
"a1",
"assistant",
"Plan: 1. Move parser setup into a dedicated hook. 2. Keep storage schema unchanged. 3. Add a regression test.",
),
];
for (let index = 2; index <= 7; index += 1) {
messages.push(
message(`u${index}`, "user", index % 2 === 0 ? "ok" : "continue"),
message(`a${index}`, "assistant", "ack"),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Move parser setup into a dedicated hook\./);
});

438
components/ai/acpHistory.ts Normal file
View File

@@ -0,0 +1,438 @@
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
type AcpHistoryMessage = { role: "user" | "assistant"; content: string };
type RawHistoryMessage = AcpHistoryMessage & { sourceId: string };
type DurableUserLine = {
line: string;
messageIndex: number;
priority: number;
};
const MAX_RECENT_RAW_MESSAGES = 6;
const MAX_MESSAGES_TO_SCAN = 20;
// Bound the scan by user turns, not raw message count: a tool-heavy ACP
// chat can produce 5+ messages per logical turn (user + assistant +
// several tool_results + follow-up assistant), so a plain
// message-count cap ages out early constraints much sooner than intended.
const MAX_DURABLE_SCAN_TURNS = 100;
const MAX_COMPACT_CONTEXT_CHARS = 3000;
const MAX_RAW_MESSAGE_CHARS = 2000;
const MAX_TOOL_SUMMARY_CHARS = 500;
const MAX_DURABLE_USER_CONTEXT_CHARS = 1400;
const MAX_DURABLE_ASSISTANT_CONTEXT_CHARS = 900;
const MAX_RECENT_SUMMARY_CONTEXT_CHARS = 1200;
const MAX_DURABLE_USER_MESSAGE_CHARS = 280;
const MAX_DURABLE_ASSISTANT_MESSAGE_CHARS = 360;
const MAX_TOOL_CALL_LABEL_CHARS = 200;
type ToolCallInfo = { name: string; arguments: unknown };
const IMPORTANT_PATTERNS = [
/不要|别|不能|不允许|必须|希望|只|最小|先|暂时|fallback|pwsh|powershell|cmd\.exe|windows|mcp|skills|cli|commit|\bpr\b|打包|内存|历史|压缩|慢/i,
/error|failed|failure|exit code|exception|cannot|unable|timeout|crash|fallback|commit|pull request|PR #\d+/i,
];
const DURABLE_CONSTRAINT_PATTERNS = [
/\bdo not\b|\bdon't\b|\bkeep\b|\bpreserve\b|\bavoid\b|\bonly\b|\bunchanged\b|\blocal only\b|\bwithout\b|\bleave\b/i,
/不要|别|保留|保持|维持|不改|别改|不要改|仅限本地/i,
];
const TRIVIAL_USER_MESSAGE_PATTERNS = [
/^(ok|okay|yes|no|thanks|thank you|continue|继续|好的|收到|行|嗯|好|继续处理|继续吧|开始吧)[.!? ]*$/i,
];
const TRIVIAL_ASSISTANT_MESSAGE_PATTERNS = [
/^(ok|okay|understood|got it|working|proceeding|ready|ack(?: \d+)?|收到|明白|继续处理|准备实现|开始处理|处理中)[.!? ]*$/i,
];
function truncateText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
}
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function isImportantText(value: string): boolean {
return IMPORTANT_PATTERNS.some((pattern) => pattern.test(value));
}
function isDurableConstraintText(value: string): boolean {
return DURABLE_CONSTRAINT_PATTERNS.some((pattern) => pattern.test(value));
}
function isTrivialUserMessage(value: string): boolean {
const normalized = normalizeWhitespace(value);
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return false;
// Don't blanket-drop short messages — short user turns are often
// load-bearing constraints ("Use ssh2", "中文输出", "no logs", "more
// verbose") that the IMPORTANT/DURABLE regexes can't realistically
// enumerate. The trivial-phrase regex already catches actual filler
// ("ok", "yes", "thanks", "继续").
return TRIVIAL_USER_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
}
function getDurableUserPriority(value: string): number {
const normalized = normalizeWhitespace(value);
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return 2;
return 1;
}
function isSubstantiveAssistantMessage(value: string): boolean {
const normalized = normalizeWhitespace(value);
if (!normalized) return false;
// Mirror the user-side loosening: don't blanket-drop short assistant
// messages just because they're under 40 chars or don't match the small
// English keyword list. Short but load-bearing decisions ("Use ssh2",
// "rebase instead", "中文输出") aren't realistically enumerable and
// they're the exact things a later "do what you suggested" references.
// TRIVIAL_ASSISTANT_MESSAGE_PATTERNS still catches the actual filler
// ("ok", "ack", "got it", "明白").
return !TRIVIAL_ASSISTANT_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
}
function getDurableAssistantPriority(value: string): number {
const normalized = normalizeWhitespace(value);
if (isImportantText(normalized)) return 2;
return 1;
}
function appendUniqueLine(
target: string[],
seen: Set<string>,
line: string,
maxSectionChars: number,
sectionCharsRef: { value: number },
): void {
const normalized = normalizeWhitespace(line);
if (!normalized || seen.has(normalized)) return;
const nextChars = sectionCharsRef.value + normalized.length;
if (nextChars > maxSectionChars) return;
seen.add(normalized);
target.push(normalized);
sectionCharsRef.value = nextChars;
}
function summarizeToolMessage(
message: ChatMessage,
toolCallIndex: Map<string, ToolCallInfo>,
): string[] {
if (!message.toolResults?.length) return [];
return message.toolResults.map((result) => {
const prefix = result.isError ? "Tool error" : "Tool result";
const content = normalizeWhitespace(result.content || "");
// Same provenance problem as the raw-window path: once a tool result
// lands in the compact section (older than the 6-item raw window),
// its paired assistant tool_call is almost always gone. Without the
// call label, multiple older results collapse into indistinguishable
// "Tool result (callN): ..." lines and follow-ups like "use the
// resolv.conf output" can't be resolved. Inline the name+args here
// the same way toRawHistoryMessage does.
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
const callLabel = callInfo
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
: "";
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(content, MAX_TOOL_SUMMARY_CHARS)}`;
});
}
function summarizeMessage(
message: ChatMessage,
toolCallIndex: Map<string, ToolCallInfo>,
): string[] {
if (message.role === "system") return [];
if (message.role === "tool") return summarizeToolMessage(message, toolCallIndex);
const lines: string[] = [];
if (message.content && isImportantText(message.content)) {
const label = message.role === "user" ? "User" : "Assistant";
lines.push(`${label}: ${truncateText(normalizeWhitespace(message.content), MAX_TOOL_SUMMARY_CHARS)}`);
}
if (message.role === "assistant" && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
const args = JSON.stringify(toolCall.arguments ?? {});
const summary = `Tool call: ${toolCall.name}(${truncateText(args, 220)})`;
if (isImportantText(summary)) lines.push(summary);
}
}
return lines;
}
function summarizeDurableUserMessage(message: ChatMessage): string | null {
if (message.role !== "user" || !message.content) return null;
if (isTrivialUserMessage(message.content)) return null;
return `User request: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
}
function summarizeDurableAssistantMessage(message: ChatMessage): string | null {
if (message.role !== "assistant" || !message.content) return null;
if (!isSubstantiveAssistantMessage(message.content)) return null;
return `Assistant context: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_ASSISTANT_MESSAGE_CHARS)}`;
}
/**
* Build a per-tool-result provenance index. Keys are
* `${toolResultMessageId}:${toolCallId}` rather than the bare toolCall.id
* so that provider-reused ids (e.g. "call1" across unrelated turns) don't
* cause later calls to overwrite older ones in the lookup — each
* tool_result resolves to the most recent assistant tool_call that
* preceded it with matching id, which preserves historical correctness
* when rebuilding older compact summaries.
*/
function buildToolCallIndex(messages: ChatMessage[]): Map<string, ToolCallInfo> {
const provenance = new Map<string, ToolCallInfo>();
// Rolling map of the latest tool_call seen (by id) up to the current
// point in the message stream.
const latestByCallId = new Map<string, ToolCallInfo>();
for (const message of messages) {
if (message.role === "assistant" && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
if (!toolCall.id) continue;
latestByCallId.set(toolCall.id, { name: toolCall.name, arguments: toolCall.arguments });
}
continue;
}
if (message.role === "tool" && message.toolResults?.length) {
for (const result of message.toolResults) {
const info = latestByCallId.get(result.toolCallId);
if (info) {
provenance.set(`${message.id}:${result.toolCallId}`, info);
}
}
}
}
return provenance;
}
function lookupToolCallInfo(
index: Map<string, ToolCallInfo>,
toolMessageId: string,
toolCallId: string,
): ToolCallInfo | undefined {
return index.get(`${toolMessageId}:${toolCallId}`);
}
function toRawHistoryMessage(
message: ChatMessage,
toolCallIndex: Map<string, ToolCallInfo>,
): RawHistoryMessage[] {
if (message.role === "user") {
return message.content
? [{ sourceId: message.id, role: "user", content: truncateText(message.content, MAX_RAW_MESSAGE_CHARS) }]
: [];
}
if (message.role === "assistant") {
const parts: string[] = [];
if (message.content) parts.push(message.content);
if (message.toolCalls?.length) {
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
}
return parts.length
? [{ sourceId: message.id, role: "assistant", content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS) }]
: [];
}
if (message.role === "tool" && message.toolResults?.length) {
// Keep tool output in the recent raw window (up to MAX_RAW_MESSAGE_CHARS
// per message, ~2000). Without this, follow-up turns after stale-session
// recovery would only see the 500-char compact summary in
// summarizeToolMessage, losing the actual bytes the user might reference
// ("use that output", "what did cat show?"). ACP only supports user/
// assistant roles, so we flatten to "assistant" — the tool results were
// produced during the assistant's turn.
//
// Inline the originating tool_call's name+args. Tool calls and their
// results live in separate messages; if the last six raw items start
// in the middle of a tool interaction, the preceding assistant tool
// call can be outside the window. Without the call label the result
// is opaque bytes and "use that output" becomes ambiguous.
const parts = message.toolResults.map((result) => {
const prefix = result.isError ? "Tool error" : "Tool result";
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
const callLabel = callInfo
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
: "";
return `${prefix}${callLabel} (${result.toolCallId}): ${result.content || ""}`;
});
return [{
sourceId: message.id,
role: "assistant",
content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS),
}];
}
return [];
}
function buildCompactContext(
messages: ChatMessage[],
durableScanStart: number,
recentRawSourceIds: Set<string>,
toolCallIndex: Map<string, ToolCallInfo>,
): AcpHistoryMessage[] {
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
const summaryLines: string[] = [];
const durableUserCandidates: DurableUserLine[] = [];
const selectedDurableUserLines: DurableUserLine[] = [];
const durableAssistantCandidates: DurableUserLine[] = [];
const selectedDurableAssistantLines: DurableUserLine[] = [];
const seen = new Set<string>();
const durableChars = { value: 0 };
const durableAssistantChars = { value: 0 };
const summaryChars = { value: 0 };
for (let messageIndex = durableScanStart; messageIndex < messages.length; messageIndex += 1) {
const message = messages[messageIndex];
if (recentRawSourceIds.has(message.id)) continue;
const durableUserLine = summarizeDurableUserMessage(message);
if (durableUserLine) {
durableUserCandidates.push({
line: durableUserLine,
messageIndex,
priority: getDurableUserPriority(message.content || ""),
});
}
const durableAssistantLine = summarizeDurableAssistantMessage(message);
if (durableAssistantLine) {
durableAssistantCandidates.push({
line: durableAssistantLine,
messageIndex,
priority: getDurableAssistantPriority(message.content || ""),
});
}
}
durableUserCandidates
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
.forEach((candidate) => {
const normalized = normalizeWhitespace(candidate.line);
if (!normalized || seen.has(normalized)) return;
const nextChars = durableChars.value + normalized.length;
if (nextChars > MAX_DURABLE_USER_CONTEXT_CHARS) return;
seen.add(normalized);
selectedDurableUserLines.push(candidate);
durableChars.value = nextChars;
});
durableAssistantCandidates
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
.forEach((candidate) => {
const normalized = normalizeWhitespace(candidate.line);
if (!normalized || seen.has(normalized)) return;
const nextChars = durableAssistantChars.value + normalized.length;
if (nextChars > MAX_DURABLE_ASSISTANT_CONTEXT_CHARS) return;
seen.add(normalized);
selectedDurableAssistantLines.push(candidate);
durableAssistantChars.value = nextChars;
});
const durableUserLines = selectedDurableUserLines
.sort((left, right) => left.messageIndex - right.messageIndex)
.map((candidate) => candidate.line);
const durableAssistantLines = selectedDurableAssistantLines
.sort((left, right) => left.messageIndex - right.messageIndex)
.map((candidate) => candidate.line);
for (const line of [...durableUserLines, ...durableAssistantLines]) {
seen.add(normalizeWhitespace(line));
}
// Skip messages that are already appended verbatim in the raw window —
// otherwise the same last-6 turns get summarized here AND re-sent as
// raw, doubling the budget cost of important user turns / large tool
// output and crowding out older durable context the replay is meant
// to preserve. Matches the recentRawSourceIds skip in the durable pass.
for (const message of scanned) {
if (recentRawSourceIds.has(message.id)) continue;
for (const line of summarizeMessage(message, toolCallIndex)) {
appendUniqueLine(summaryLines, seen, line, MAX_RECENT_SUMMARY_CONTEXT_CHARS, summaryChars);
}
}
if (!durableUserLines.length && !durableAssistantLines.length && !summaryLines.length) return [];
const contentLines = [
"[Compact prior Netcatty UI context]",
"The external ACP agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
];
if (durableUserLines.length) {
contentLines.push("Earlier user requests that may still apply:");
contentLines.push(...durableUserLines.map((line) => `- ${line}`));
}
if (durableAssistantLines.length) {
contentLines.push("Earlier assistant context that may still matter:");
contentLines.push(...durableAssistantLines.map((line) => `- ${line}`));
}
if (summaryLines.length) {
contentLines.push("Recent noteworthy context:");
contentLines.push(...summaryLines.map((line) => `- ${line}`));
}
return [{
role: "user",
content: truncateText(
contentLines.join("\n"),
MAX_COMPACT_CONTEXT_CHARS,
),
}];
}
/**
* Find the index of the first message to include in the scan window,
* bounded by MAX_DURABLE_SCAN_TURNS user turns (not raw message count).
* Walking backwards stops at the target turn count, so the cost is
* bounded even when the transcript is huge.
*/
function computeDurableScanStart(messages: ChatMessage[]): number {
let userTurns = 0;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i].role === "user") {
userTurns += 1;
if (userTurns >= MAX_DURABLE_SCAN_TURNS) return i;
}
}
return 0;
}
export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMessage[] {
// Compute the scan start once, then do all subsequent work over the
// already-sliced tail. This avoids O(N) walks over the whole transcript
// on every send — previously buildToolCallIndex + the flatMap-to-take-
// last-6 raw history both traversed every message in the chat.
const durableScanStart = computeDurableScanStart(messages);
const scannedTail = messages.slice(durableScanStart);
// The tool-call provenance index only needs entries for tool_results
// that might appear in our output. Building from the scanned tail is
// correct for any tool_result whose paired assistant tool_call is
// also within the window, which covers >99% of realistic patterns
// (tool_calls and tool_results are always adjacent or near-adjacent).
// If an ancient tool_call's result stays within the window while the
// call itself is outside, that single result loses its [from X(Y)]
// label — an acceptable trade for eliminating the per-send O(N) walk.
const toolCallIndex = buildToolCallIndex(scannedTail);
const rawHistory = scannedTail
.flatMap((message) => toRawHistoryMessage(message, toolCallIndex))
.slice(-MAX_RECENT_RAW_MESSAGES);
const compactContext = buildCompactContext(
messages,
durableScanStart,
new Set(rawHistory.map((message) => message.sourceId)),
toolCallIndex,
);
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
return [...compactContext, ...recentRaw];
}
export function buildAcpHistoryMessagesForBridge(
messages: ChatMessage[],
_existingSessionId?: string | null,
): AcpHistoryMessage[] | undefined {
// The main process bridge only consumes this payload during stale-session
// fallback replay, so keep it available even when a session id exists.
const historyMessages = buildAcpHistoryMessages(messages);
return historyMessages.length ? historyMessages : undefined;
}

View File

@@ -0,0 +1,177 @@
import assert from "node:assert/strict";
import test from "node:test";
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
import {
applyDraftEntrySelection,
applyHistorySessionSelection,
normalizePanelView,
resolveDisplayedPanelView,
resolveDisplayedSession,
} from "./aiPanelViewState.ts";
function createSession(id: string): AISession {
return {
id,
title: `Session ${id}`,
messages: [],
createdAt: 1,
updatedAt: 1,
agentId: "catty",
scope: {
type: "terminal",
targetId: "terminal-1",
},
};
}
test("draft view never falls back to most recent history", () => {
const panelView: AIPanelView = { mode: "draft" };
const sessions = [createSession("session-2"), createSession("session-1")];
assert.equal(resolveDisplayedSession(panelView, sessions), null);
});
test("session view returns the selected session", () => {
const selectedSession = createSession("session-2");
const panelView: AIPanelView = { mode: "session", sessionId: selectedSession.id };
const sessions = [createSession("session-1"), selectedSession];
assert.equal(resolveDisplayedSession(panelView, sessions), selectedSession);
});
test("missing session target resolves to null instead of history fallback", () => {
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
const sessions = [createSession("session-2"), createSession("session-1")];
assert.equal(resolveDisplayedSession(panelView, sessions), null);
});
test("missing session target normalizes back to draft view", () => {
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(normalizePanelView(panelView, sessions), { mode: "draft" });
});
test("missing explicit panel view resumes the most recent matching history when no draft exists", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
{ mode: "session", sessionId: "session-2" },
);
});
test("missing explicit panel view restores the persisted active session instead of the newest", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "workspace"),
{ mode: "session", sessionId: "session-1" },
);
});
test("persisted session id that no longer exists in history falls back to newest", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session", "workspace"),
{ mode: "session", sessionId: "session-2" },
);
});
test("null persisted session id falls back to newest history entry", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, null, "workspace"),
{ mode: "session", sessionId: "session-2" },
);
});
test("terminal scope without explicit view always starts from draft even when history exists", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "terminal"),
{ mode: "draft" },
);
});
test("missing explicit panel view prefers the draft when unsent input exists", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, true, sessions),
{ mode: "draft" },
);
});
test("draft state is used when there is no implicit history to resume", () => {
assert.deepEqual(
resolveDisplayedPanelView(undefined, true, []),
{ mode: "draft" },
);
});
test("history selection switches to the chosen session without touching draft state", () => {
const calls: string[] = [];
applyHistorySessionSelection("session-2", {
showSessionView: (sessionId) => {
calls.push(`view:${sessionId}`);
},
setActiveSessionId: (sessionId) => {
calls.push(`active:${sessionId}`);
},
closeHistory: () => {
calls.push("close-history");
},
});
assert.deepEqual(calls, [
"view:session-2",
"active:session-2",
"close-history",
]);
});
test("draft entry ensures a draft exists before switching the panel to draft mode", () => {
const calls: string[] = [];
applyDraftEntrySelection({
ensureDraft: () => {
calls.push("ensure-draft");
},
showDraftView: () => {
calls.push("show-draft");
},
});
assert.deepEqual(calls, [
"ensure-draft",
"show-draft",
]);
});
test("draft entry can preserve the current session view while ensuring draft state", () => {
const calls: string[] = [];
applyDraftEntrySelection({
ensureDraft: () => {
calls.push("ensure-draft");
},
showDraftView: () => {
calls.push("show-draft");
},
preserveSessionView: true,
});
assert.deepEqual(calls, [
"ensure-draft",
]);
});

View File

@@ -0,0 +1,94 @@
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: "draft" };
interface HistorySessionSelectionActions {
showSessionView: (sessionId: string) => void;
setActiveSessionId: (sessionId: string) => void;
closeHistory?: () => void;
}
interface DraftEntrySelectionActions {
ensureDraft: () => void;
showDraftView: () => void;
preserveSessionView?: boolean;
}
export function resolveDisplayedPanelView(
panelView: AIPanelView | undefined,
hasDraft: boolean,
sessions: AISession[],
persistedSessionId?: string | null,
scopeType: "terminal" | "workspace" = "workspace",
): AIPanelView {
if (panelView) {
return normalizePanelView(panelView, sessions);
}
if (hasDraft) {
return DEFAULT_PANEL_VIEW;
}
// New terminal sessions should always start from a blank draft. History is
// still available in the drawer, but never auto-resumed into a fresh SSH tab.
if (scopeType === "terminal") {
return DEFAULT_PANEL_VIEW;
}
// Honour the persisted active-session selection (survives cold mount)
// before falling back to the newest history entry.
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
return { mode: "session", sessionId: persistedSessionId };
}
if (sessions[0]) {
return { mode: "session", sessionId: sessions[0].id };
}
return DEFAULT_PANEL_VIEW;
}
export function normalizePanelView(
panelView: AIPanelView,
sessions: AISession[],
): AIPanelView {
if (panelView.mode !== "session") {
return panelView;
}
return sessions.some((session) => session.id === panelView.sessionId)
? panelView
: DEFAULT_PANEL_VIEW;
}
export function resolveDisplayedSession(
panelView: AIPanelView,
sessions: AISession[],
): AISession | null {
if (panelView.mode !== "session") {
return null;
}
return sessions.find((session) => session.id === panelView.sessionId) ?? null;
}
export function applyHistorySessionSelection(
sessionId: string,
actions: HistorySessionSelectionActions,
): void {
actions.showSessionView(sessionId);
actions.setActiveSessionId(sessionId);
actions.closeHistory?.();
}
export function applyDraftEntrySelection(
actions: DraftEntrySelectionActions,
): void {
actions.ensureDraft();
if (!actions.preserveSessionView) {
actions.showDraftView();
}
}

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
endDraftSend,
tryBeginDraftSend,
} from "./draftSendGate.ts";
test("draft send gate allows only one in-flight draft send at a time", () => {
const gate = { current: false };
assert.equal(tryBeginDraftSend(gate), true);
assert.equal(tryBeginDraftSend(gate), false);
endDraftSend(gate);
assert.equal(tryBeginDraftSend(gate), true);
});

View File

@@ -0,0 +1,12 @@
export function tryBeginDraftSend(gate: { current: boolean }): boolean {
if (gate.current) {
return false;
}
gate.current = true;
return true;
}
export function endDraftSend(gate: { current: boolean }): void {
gate.current = false;
}

View File

@@ -14,6 +14,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { streamText, stepCountIs, type ModelMessage } from 'ai';
import type {
AIPermissionMode,
AIToolIntegrationMode,
AISession,
ChatMessage,
ChatMessageAttachment,
@@ -120,6 +121,17 @@ export interface PanelBridge extends NetcattyBridge {
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
ok: boolean;
skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}>;
}>;
aiUserSkillsBuildContext?: (prompt: string, selectedSkillSlugs?: string[]) => Promise<{ ok: boolean; context?: string; error?: string }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}
@@ -137,6 +149,10 @@ export interface TerminalSessionInfo {
connected: boolean;
}
export interface DefaultTargetSessionHint extends TerminalSessionInfo {
source: 'scope-target' | 'only-connected-in-scope';
}
/** Typed accessor for the netcatty bridge on the window object. */
export function getNetcattyBridge(): PanelBridge | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -150,6 +166,45 @@ function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
const USER_SKILLS_CONTEXT_TIMEOUT_MS = 500;
interface UserSkillsContextResult {
ok: boolean;
context?: string;
error?: string;
}
function buildExplicitUserSkillsFallback(selectedUserSkillSlugs?: string[]): string {
if (!selectedUserSkillSlugs?.length) return '';
return `The user explicitly selected these Netcatty user skills for this request: ${selectedUserSkillSlugs.map((slug) => `/${slug}`).join(', ')}. Honor those selections even if their expanded skill content is unavailable.`;
}
async function resolveUserSkillsContext(
bridge: PanelBridge | undefined,
prompt: string,
selectedUserSkillSlugs?: string[],
): Promise<string> {
if (!bridge?.aiUserSkillsBuildContext) {
return buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
const buildContextPromise: Promise<UserSkillsContextResult> = bridge
.aiUserSkillsBuildContext(prompt, selectedUserSkillSlugs)
.catch(() => ({ ok: false, context: '' }));
const hasExplicitSelections = (selectedUserSkillSlugs?.length ?? 0) > 0;
const result = hasExplicitSelections
? await buildContextPromise
: await Promise.race([
buildContextPromise,
new Promise<UserSkillsContextResult>((resolve) =>
setTimeout(() => resolve({ ok: false, context: '' }), USER_SKILLS_CONTEXT_TIMEOUT_MS),
),
]);
return result.context || buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
@@ -234,6 +289,7 @@ export interface SendToCattyContext {
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
autoTitleSession: (sessionId: string, text: string) => void;
selectedUserSkillSlugs?: string[];
}
/** Context values needed by sendToExternalAgent that change frequently. */
@@ -242,8 +298,11 @@ export interface SendToExternalContext {
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
terminalSessions: TerminalSessionInfo[];
defaultTargetSession?: DefaultTargetSessionHint;
providers: ProviderConfig[];
selectedAgentModel?: string;
toolIntegrationMode: AIToolIntegrationMode;
selectedUserSkillSlugs?: string[];
}
// -------------------------------------------------------------------
@@ -296,14 +355,13 @@ export function useAIChatStreaming({
err: unknown,
) => {
if (abortSignal.aborted) return;
let errorStr: string;
if (err instanceof Error) errorStr = err.message;
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
else if (typeof err === 'string') errorStr = err;
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
// Log the full unsanitized error for debugging
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
const errorInfo = classifyError(errorStr);
console.error('[AIChatSidePanel] Stream error (full):', err);
// Pass the raw error to classifyError so it can inspect structured
// fields (statusCode, responseBody) from APICallError and friends;
// string-coercing here would strip the metadata we need to detect
// 413 / HTML-error-page / parse-failure scenarios.
const errorInfo = classifyError(err);
updateLastMessage(sessionId, msg => ({
...msg,
statusText: '',
@@ -501,11 +559,10 @@ export function useAIChatStreaming({
id: generateId(),
role: 'assistant',
content: '',
errorInfo: classifyError(
typedChunk.error instanceof Error ? typedChunk.error.message
: typeof typedChunk.error === 'string' ? typedChunk.error
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
),
// Pass the raw error so classifyError can detect 413 / HTML /
// schema-parse scenarios via structured fields (statusCode,
// responseBody) instead of lossy string conversion.
errorInfo: classifyError(typedChunk.error),
timestamp: Date.now(),
});
break;
@@ -535,6 +592,11 @@ export function useAIChatStreaming({
context: SendToExternalContext,
) => {
const bridge = getNetcattyBridge();
const userSkillsContext = await resolveUserSkillsContext(
bridge,
trimmed,
context.selectedUserSkillSlugs,
);
if (agentConfig.acpCommand && bridge) {
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
@@ -544,11 +606,6 @@ export function useAIChatStreaming({
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
}
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
// avoiding plaintext key transit across the IPC boundary.
const openaiProvider = context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey);
const agentProviderId = openaiProvider?.id;
// Mutable flag: set after tool-result, cleared when new assistant msg is created
let needsNewAssistantMsg = false;
const maybeCreateAssistantMsg = () => {
@@ -630,17 +687,23 @@ export function useAIChatStreaming({
onDone: () => {},
},
abortController.signal,
agentProviderId,
// Managed ACP agents (codex, claude) must resolve auth from their own
// CLI config/login state, so we deliberately pass no providerId here.
// See issue #705 for Codex; same reasoning for Claude.
undefined,
context.selectedAgentModel,
context.existingSessionId,
context.historyMessages,
attachedImages.length > 0 ? attachedImages : undefined,
context.toolIntegrationMode,
context.defaultTargetSession,
userSkillsContext,
);
} else {
// Fallback: spawn as raw process
await runExternalAgentTurn(
agentConfig,
trimmed,
userSkillsContext ? `${userSkillsContext}\n\nUser request:\n${trimmed}` : trimmed,
{
onTextDelta: (text: string) => {
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
@@ -674,6 +737,11 @@ export function useAIChatStreaming({
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
const userSkillsContext = await resolveUserSkillsContext(
bridge,
trimmed,
context.selectedUserSkillSlugs,
);
const getExecutorContext = context.getExecutorContext ?? (() => ({
sessions: context.terminalSessions,
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
@@ -701,6 +769,7 @@ export function useAIChatStreaming({
})),
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
userSkillsContext,
});
// Guard: activeProvider must exist for Catty agent path

View File

@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SESSION_HISTORY_ROW_CLASSNAMES,
} from "./sessionHistoryLayout.ts";
test("session history row keeps metadata pinned to the end while title truncates", () => {
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.row, /\bgrid\b/);
assert.ok(SESSION_HISTORY_ROW_CLASSNAMES.row.includes('grid-cols-[minmax(0,1fr)_auto]'));
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\btruncate\b/);
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\bmin-w-0\b/);
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bjustify-self-end\b/);
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bshrink-0\b/);
});

View File

@@ -0,0 +1,7 @@
export const SESSION_HISTORY_ROW_CLASSNAMES = {
row: 'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
title: 'text-[13px] truncate min-w-0',
meta: 'flex items-center gap-2 justify-self-end shrink-0',
time: 'text-[12px] text-muted-foreground/50 whitespace-nowrap',
deleteButton: 'opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer shrink-0',
} as const;

View File

@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AISession } from "../../infrastructure/ai/types.ts";
import { getSessionScopeMatchRank } from "./sessionScopeMatch.ts";
function createSession(id: string, targetId: string, hostIds: string[]): AISession {
return {
id,
title: id,
messages: [],
createdAt: 1,
updatedAt: 1,
agentId: "catty",
scope: {
type: "terminal",
targetId,
hostIds,
},
};
}
test("host-matched terminal session is excluded when another active terminal already displays it", () => {
const session = createSession("session-1", "terminal-other", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"terminal",
"terminal-current",
["host-a"],
new Set(["session-1"]),
),
0,
);
});
test("host-matched terminal session remains resumable when no terminal is displaying it", () => {
const session = createSession("session-1", "terminal-closed", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"terminal",
"terminal-current",
["host-a"],
new Set(["session-other"]),
),
1,
);
});
test("ownership is tracked by session id, not scope.targetId", () => {
// Session was created in terminal-A but a different terminal (B) is now
// displaying it after the user resumed it from history. Opening a third
// terminal (C) should not see this session as owned, because the new
// ownership check is keyed on session id, not the stale targetId.
const session = createSession("session-1", "terminal-A", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"terminal",
"terminal-C",
["host-a"],
// terminal-B is displaying session-1; pass session-1 as an
// active-id so C sees it as in-use
new Set(["session-1"]),
),
0,
);
});
test("session targeting the current scope is an exact match (rank 2)", () => {
const session = createSession("session-1", "terminal-current", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"terminal",
"terminal-current",
["host-a"],
new Set(),
),
2,
);
});
test("scope type mismatch returns 0 regardless of target or hosts", () => {
const session = createSession("session-1", "terminal-current", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"workspace",
"terminal-current",
["host-a"],
),
0,
);
});

View File

@@ -0,0 +1,28 @@
import type { AISession } from "../../infrastructure/ai/types";
export function getSessionScopeMatchRank(
session: AISession,
scopeType: "terminal" | "workspace",
scopeTargetId?: string,
scopeHostIds?: string[],
/**
* Session ids currently displayed by other terminal scopes. Tracked by
* session id rather than `scope.targetId` so that a host-matched session
* resumed from a different terminal is still recognised as in-use and
* not offered (or cleaned) as if it were orphaned.
*/
activeTerminalSessionIds?: Set<string>,
): number {
if (session.scope.type !== scopeType) return 0;
if (session.scope.targetId === scopeTargetId) return 2;
if (scopeType !== "terminal" || !scopeHostIds?.length || !session.scope.hostIds?.length) {
return 0;
}
if (activeTerminalSessionIds?.has(session.id)) {
return 0;
}
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
}

View File

@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getNextSelectedUserSkillSlugsMap,
getReadyUserSkillOptions,
pruneSelectedUserSkillSlugsMap,
} from "./userSkillsState.ts";
test("getReadyUserSkillOptions returns only ready skills and clears invalid payloads", () => {
assert.deepEqual(getReadyUserSkillOptions(null), []);
assert.deepEqual(getReadyUserSkillOptions({ ok: false }), []);
assert.deepEqual(
getReadyUserSkillOptions({
ok: true,
skills: [
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
status: "ready",
},
{
id: "beta",
slug: "beta",
name: "Beta",
description: "Beta helper",
status: "warning",
},
],
}),
[
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
},
],
);
});
test("pruneSelectedUserSkillSlugsMap removes stale slugs and empty scopes", () => {
assert.deepEqual(
pruneSelectedUserSkillSlugsMap(
{
"terminal:1": ["alpha", "missing"],
"workspace:1": ["missing"],
},
[
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
},
],
),
{
"terminal:1": ["alpha"],
},
);
});
test("getNextSelectedUserSkillSlugsMap preserves selections when refresh fails", () => {
const selected = {
"terminal:1": ["alpha", "missing"],
"workspace:1": ["beta"],
};
assert.equal(
getNextSelectedUserSkillSlugsMap(selected, null),
selected,
);
assert.equal(
getNextSelectedUserSkillSlugsMap(selected, { ok: false }),
selected,
);
});

View File

@@ -0,0 +1,73 @@
export interface UserSkillStatusItemLike {
id: string;
slug: string;
name: string;
description: string;
status: "ready" | "warning";
}
export interface UserSkillsStatusLike {
ok: boolean;
skills?: UserSkillStatusItemLike[];
}
export interface UserSkillOption {
id: string;
slug: string;
name: string;
description: string;
}
export function getReadyUserSkillOptions(
status: UserSkillsStatusLike | null | undefined,
): UserSkillOption[] {
if (!status?.ok || !Array.isArray(status.skills)) return [];
return status.skills
.filter((skill) => skill.status === "ready" && typeof skill.slug === "string" && skill.slug.length > 0)
.map((skill) => ({
id: skill.id,
slug: skill.slug,
name: skill.name,
description: skill.description,
}));
}
export function pruneSelectedUserSkillSlugsMap(
selectedByScope: Record<string, string[]>,
options: UserSkillOption[],
): Record<string, string[]> {
const validSlugs = new Set(options.map((option) => option.slug));
let changed = false;
const nextEntries: Array<[string, string[]]> = [];
for (const [scopeKey, slugs] of Object.entries(selectedByScope)) {
const filteredSlugs = slugs.filter((slug) => validSlugs.has(slug));
if (filteredSlugs.length !== slugs.length) changed = true;
if (filteredSlugs.length > 0) {
nextEntries.push([scopeKey, filteredSlugs]);
} else if (slugs.length > 0) {
changed = true;
}
}
if (!changed) {
return selectedByScope;
}
return Object.fromEntries(nextEntries);
}
export function getNextSelectedUserSkillSlugsMap(
selectedByScope: Record<string, string[]>,
status: UserSkillsStatusLike | null | undefined,
): Record<string, string[]> {
if (!status?.ok || !Array.isArray(status.skills)) {
return selectedByScope;
}
return pruneSelectedUserSkillSlugsMap(
selectedByScope,
getReadyUserSkillOptions(status),
);
}

View File

@@ -7,7 +7,7 @@ import React, { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { AsidePanel } from '../ui/aside-panel';
import { AsidePanel, type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
@@ -24,6 +24,7 @@ export interface ChainPanelProps {
onClearChain: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const ChainPanel: React.FC<ChainPanelProps> = ({
@@ -37,6 +38,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
onClearChain,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState('');
@@ -54,6 +56,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
title={t('hostDetails.chain.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onBack}>
{t('common.save')}

View File

@@ -5,7 +5,7 @@
import { FolderPlus,HelpCircle,Plus } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
@@ -42,6 +42,7 @@ export interface CreateGroupPanelProps {
onSave: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
@@ -53,6 +54,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
onSave,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
return (
@@ -62,6 +64,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
title={t('hostDetails.group.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onSave} disabled={!newGroupName.trim()}>
{t('common.save')}

View File

@@ -6,7 +6,7 @@ import { Plus,X } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { EnvVar } from '../../types';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
@@ -25,6 +25,7 @@ export interface EnvVarsPanelProps {
onSave: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
@@ -41,6 +42,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
onSave,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
return (
@@ -50,6 +52,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
title={t('hostDetails.envVars.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onSave}>
{t('common.save')}

View File

@@ -7,7 +7,7 @@ import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { ProxyConfig } from '../../types';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
@@ -19,6 +19,7 @@ export interface ProxyPanelProps {
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const ProxyPanel: React.FC<ProxyPanelProps> = ({
@@ -27,6 +28,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
onClearProxy,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
return (
@@ -36,6 +38,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
title={t('hostDetails.proxyPanel.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}>
{t('common.save')}

View File

@@ -7,11 +7,12 @@
* - CodexConnectionCard, ClaudeCodeCard
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import { AlertTriangle, Bot, FolderOpen, Globe, Link, Package, RefreshCcw } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AIPermissionMode,
AIProviderId,
AIToolIntegrationMode,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
@@ -24,6 +25,7 @@ import {
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Select, SettingRow } from "../settings-ui";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
@@ -31,6 +33,7 @@ import type {
AgentPathInfo,
CodexIntegrationStatus,
CodexLoginSession,
UserSkillsStatusResult,
} from "./ai/types";
import {
AGENT_DEFAULTS,
@@ -61,6 +64,8 @@ interface SettingsAITabProps {
setActiveModelId: (id: string) => void;
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
toolIntegrationMode: AIToolIntegrationMode;
setToolIntegrationMode: (mode: AIToolIntegrationMode) => void;
externalAgents: ExternalAgentConfig[];
setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
defaultAgentId: string;
@@ -138,6 +143,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setActiveModelId,
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
externalAgents,
setExternalAgents,
defaultAgentId,
@@ -182,6 +189,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
const [userSkillsStatus, setUserSkillsStatus] = useState<UserSkillsStatusResult | null>(null);
const [isLoadingUserSkills, setIsLoadingUserSkills] = useState(false);
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
@@ -299,18 +308,14 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
], [externalAgents, t]);
const hasOpenAiProviderKey = providers.some(
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
);
const refreshCodexIntegration = useCallback(async () => {
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
setIsCodexLoading(true);
setCodexError(null);
try {
const integration = await bridge.aiCodexGetIntegration();
const integration = await bridge.aiCodexGetIntegration(opts);
setCodexIntegration(integration);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
@@ -420,6 +425,54 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}
}, [refreshCodexIntegration]);
const refreshUserSkillsStatus = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiUserSkillsGetStatus) {
setUserSkillsStatus({
ok: false,
error: t('ai.userSkills.unavailable'),
});
return;
}
setIsLoadingUserSkills(true);
try {
const result = await bridge.aiUserSkillsGetStatus();
setUserSkillsStatus(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
} finally {
setIsLoadingUserSkills(false);
}
}, [t]);
useEffect(() => {
let cancelled = false;
void refreshUserSkillsStatus().then(() => {
if (cancelled) return;
});
return () => {
cancelled = true;
};
}, [refreshUserSkillsStatus]);
const handleOpenUserSkillsFolder = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiUserSkillsOpenFolder) return;
setIsLoadingUserSkills(true);
try {
const result = await bridge.aiUserSkillsOpenFolder();
setUserSkillsStatus(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
} finally {
setIsLoadingUserSkills(false);
}
}, []);
return (
<TabsContent
value="ai"
@@ -519,9 +572,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
integration={codexIntegration}
loginSession={codexLoginSession}
isLoading={isCodexLoading}
hasOpenAiProviderKey={hasOpenAiProviderKey}
error={codexError}
onRefresh={() => void refreshCodexIntegration()}
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
onConnect={() => void handleStartCodexLogin()}
onCancel={() => void handleCancelCodexLogin()}
onOpenUrl={handleOpenCodexLoginUrl}
@@ -585,6 +637,130 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</div>
)}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Link size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.toolAccess.title')}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<SettingRow
label={t('ai.toolAccess.mode')}
description={t('ai.toolAccess.description')}
>
<Select
value={toolIntegrationMode}
options={[
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
]}
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
className="w-48"
/>
</SettingRow>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Package size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.userSkills.title')}</h3>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void refreshUserSkillsStatus()}
disabled={isLoadingUserSkills}
>
<RefreshCcw size={14} className="mr-2" />
{t('ai.userSkills.reload')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleOpenUserSkillsFolder()}
disabled={isLoadingUserSkills}
>
<FolderOpen size={14} className="mr-2" />
{t('ai.userSkills.openFolder')}
</Button>
</div>
</div>
<div className="rounded-lg bg-muted/30 p-4 space-y-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
{t('ai.userSkills.description')}
</p>
{userSkillsStatus?.directoryPath ? (
<p className="text-xs text-muted-foreground">
{t('ai.userSkills.location')}:{" "}
<span className="font-mono">{userSkillsStatus.directoryPath}</span>
</p>
) : null}
</div>
<div className="text-sm text-muted-foreground">
{isLoadingUserSkills
? t('ai.userSkills.loading')
: userSkillsStatus?.ok
? t('ai.userSkills.summary', {
ready: String(userSkillsStatus.readyCount ?? 0),
warnings: String(userSkillsStatus.warningCount ?? 0),
})
: userSkillsStatus?.error || t('ai.userSkills.unavailable')}
</div>
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
<div className="space-y-3">
{userSkillsStatus.skills.map((skill) => (
<div
key={skill.id}
className="rounded-md border border-border/60 bg-background/70 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="font-medium">{skill.name}</div>
<div className="text-sm text-muted-foreground">{skill.description}</div>
<div className="text-xs text-muted-foreground font-mono break-all">
{skill.directoryName}
</div>
</div>
<span
className={
skill.status === "ready"
? "rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-medium text-emerald-600"
: "rounded-full bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600"
}
>
{skill.status === "ready"
? t('ai.userSkills.status.ready')
: t('ai.userSkills.status.warning')}
</span>
</div>
{skill.warnings.length > 0 ? (
<div className="mt-3 space-y-1 text-sm text-amber-700">
{skill.warnings.map((warning, index) => (
<div key={`${skill.id}-${index}`} className="flex items-start gap-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
) : null}
</div>
))}
</div>
) : userSkillsStatus?.ok ? (
<div className="text-sm text-muted-foreground">
{t('ai.userSkills.empty')}
</div>
) : null}
</div>
</div>
{/* -- Web Search Section -- */}
<WebSearchSettings
webSearchConfig={webSearchConfig}

View File

@@ -7,8 +7,6 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
import { STORAGE_KEY_SHOW_RECENT_HOSTS } from "../../../infrastructure/config/storageKeys";
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light" | "system";
@@ -27,6 +25,12 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
showRecentHosts: boolean;
setShowRecentHosts: (enabled: boolean) => void;
showOnlyUngroupedHostsInRoot: boolean;
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
showSftpTab: boolean;
setShowSftpTab: (enabled: boolean) => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
@@ -47,13 +51,14 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage,
customCSS,
setCustomCSS,
showRecentHosts,
setShowRecentHosts,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
} = props;
const [showRecentHosts, setShowRecentHosts] = useStoredBoolean(
STORAGE_KEY_SHOW_RECENT_HOSTS,
true,
);
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
const hexToHsl = useCallback((hex: string) => {
@@ -269,6 +274,21 @@ export default function SettingsAppearanceTab(props: {
>
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
</SettingRow>
<SettingRow
label={t('settings.vault.showOnlyUngroupedHostsInRoot')}
description={t('settings.vault.showOnlyUngroupedHostsInRootDesc')}
>
<Toggle
checked={showOnlyUngroupedHostsInRoot}
onChange={setShowOnlyUngroupedHostsInRoot}
/>
</SettingRow>
<SettingRow
label={t('settings.vault.showSftpTab')}
description={t('settings.vault.showSftpTabDesc')}
>
<Toggle checked={showSftpTab} onChange={setShowSftpTab} />
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.customCss")} />

View File

@@ -2,7 +2,9 @@ import React, { useCallback } from "react";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
@@ -25,6 +27,7 @@ export default function SettingsSyncTab(props: {
clearVaultData,
onSettingsApplied,
} = props;
const { t } = useI18n();
const onBuildPayload = useCallback((): SyncPayload => {
// If hook state is empty but localStorage has data, the async store
@@ -54,14 +57,19 @@ export default function SettingsSyncTab(props: {
}, [vault, portForwardingRules]);
const onApplyPayload = useCallback(
(payload: SyncPayload) => {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
});
},
[importDataFromString, importPortForwardingRules, onSettingsApplied],
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildPayload,
applyPayload: () =>
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
}),
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[importDataFromString, importPortForwardingRules, onBuildPayload, onSettingsApplied, t],
);
const clearAllLocalData = useCallback(() => {

View File

@@ -263,6 +263,8 @@ const ThemePreviewButton: React.FC<{
export default function SettingsTerminalTab(props: {
terminalThemeId: string;
setTerminalThemeId: (id: string) => void;
followAppTerminalTheme: boolean;
setFollowAppTerminalTheme: (value: boolean) => void;
terminalFontFamilyId: string;
setTerminalFontFamilyId: (id: string) => void;
terminalFontSize: number;
@@ -279,6 +281,8 @@ export default function SettingsTerminalTab(props: {
const {
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
@@ -491,11 +495,24 @@ export default function SettingsTerminalTab(props: {
return (
<SettingsTabContent value="terminal">
<SectionHeader title={t("settings.terminal.section.theme")} />
<ThemePreviewButton
theme={currentTheme}
onClick={() => setThemeModalOpen(true)}
buttonLabel={t("settings.terminal.theme.selectButton")}
/>
<div className="rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.theme.followApp")}
description={t("settings.terminal.theme.followApp.desc")}
>
<Toggle
checked={followAppTerminalTheme}
onChange={setFollowAppTerminalTheme}
/>
</SettingRow>
</div>
{!followAppTerminalTheme && (
<ThemePreviewButton
theme={currentTheme}
onClick={() => setThemeModalOpen(true)}
buttonLabel={t("settings.terminal.theme.selectButton")}
/>
)}
<ThemeSelectModal
open={themeModalOpen}
@@ -798,6 +815,20 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.clearWipesScrollback")}
description={t("settings.terminal.behavior.clearWipesScrollback.desc")}
>
<Toggle checked={terminalSettings.clearWipesScrollback ?? true} onChange={(v) => updateTerminalSetting("clearWipesScrollback", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.preserveSelectionOnInput")}
description={t("settings.terminal.behavior.preserveSelectionOnInput.desc")}
>
<Toggle checked={terminalSettings.preserveSelectionOnInput ?? false} onChange={(v) => updateTerminalSetting("preserveSelectionOnInput", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.osc52Clipboard")}
description={t("settings.terminal.behavior.osc52Clipboard.desc")}

View File

@@ -15,7 +15,6 @@ export const CodexConnectionCard: React.FC<{
integration: CodexIntegrationStatus | null;
loginSession: CodexLoginSession | null;
isLoading: boolean;
hasOpenAiProviderKey: boolean;
error: string | null;
onRefresh: () => void;
onConnect: () => void;
@@ -31,7 +30,6 @@ export const CodexConnectionCard: React.FC<{
integration,
loginSession,
isLoading,
hasOpenAiProviderKey,
error,
onRefresh,
onConnect,
@@ -42,6 +40,14 @@ export const CodexConnectionCard: React.FC<{
const { t } = useI18n();
const found = pathInfo?.available;
const customConfigIncomplete = Boolean(
integration?.state === "connected_custom_config"
&& integration.customConfig
&& integration.customConfig.envKey
&& !integration.customConfig.envKeyPresent
&& !integration.customConfig.hasHardcodedApiKey,
);
const status = isResolvingPath
? t('ai.codex.detecting')
: !found
@@ -52,9 +58,13 @@ export const CodexConnectionCard: React.FC<{
? t('ai.codex.connectedChatGPT')
: integration?.state === "connected_api_key"
? t('ai.codex.connectedApiKey')
: integration?.state === "not_logged_in"
? t('ai.codex.notConnected')
: t('ai.codex.statusUnknown');
: integration?.state === "connected_custom_config"
? customConfigIncomplete
? t('ai.codex.customConfigIncomplete')
: t('ai.codex.connectedCustomConfig')
: integration?.state === "not_logged_in"
? t('ai.codex.notConnected')
: t('ai.codex.statusUnknown');
const statusClassName = isResolvingPath
? "text-muted-foreground"
@@ -62,9 +72,11 @@ export const CodexConnectionCard: React.FC<{
? "text-amber-500"
: loginSession?.state === "running"
? "text-amber-500"
: integration?.isConnected
? "text-emerald-500"
: "text-muted-foreground";
: customConfigIncomplete
? "text-amber-500"
: integration?.isConnected
? "text-emerald-500"
: "text-muted-foreground";
const outputText = loginSession?.error
? loginSession.error
@@ -139,6 +151,9 @@ export const CodexConnectionCard: React.FC<{
{t('common.cancel')}
</Button>
</>
) : integration?.state === "connected_custom_config" ? (
// Nothing to log out of; config.toml is user-owned state.
null
) : integration?.isConnected ? (
<Button variant="outline" size="sm" onClick={onLogout}>
<LogOut size={14} className="mr-1.5" />
@@ -157,10 +172,23 @@ export const CodexConnectionCard: React.FC<{
</Button>
</div>
{hasOpenAiProviderKey && (
<p className="text-xs text-emerald-500">
{t('ai.codex.apiKeyHint')}
</p>
{integration?.state === "connected_custom_config" && integration.customConfig && (
<>
<p className="text-xs text-emerald-500">
{t('ai.codex.customConfigHint').replace(
'{provider}',
integration.customConfig.displayName || integration.customConfig.providerName,
)}
</p>
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
<p className="text-xs text-amber-500">
{t('ai.codex.customConfigMissingEnvKey').replace(
'{envKey}',
integration.customConfig.envKey,
)}
</p>
)}
</>
)}
</>
)}

View File

@@ -10,14 +10,27 @@ import type {
export type CodexIntegrationState =
| "connected_chatgpt"
| "connected_api_key"
| "connected_custom_config"
| "not_logged_in"
| "unknown";
export interface CodexCustomProviderConfig {
providerName: string;
displayName: string;
baseUrl: string | null;
envKey: string | null;
envKeyPresent: boolean;
hasHardcodedApiKey: boolean;
model: string | null;
authHash: string | null;
}
export interface CodexIntegrationStatus {
state: CodexIntegrationState;
isConnected: boolean;
rawOutput: string;
exitCode: number | null;
customConfig?: CodexCustomProviderConfig | null;
}
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
@@ -37,6 +50,28 @@ export interface AgentPathInfo {
available: boolean;
}
export interface UserSkillStatusItem {
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: "ready" | "warning";
warnings: string[];
}
export interface UserSkillsStatusResult {
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: UserSkillStatusItem[];
warnings?: string[];
error?: string;
}
export interface ProviderFormState {
name: string;
apiKey: string;
@@ -57,12 +92,14 @@ export interface FetchBridge {
}
export interface NetcattyAiBridge {
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: () => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string }) => Promise<AgentPathInfo>;
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
openExternal?: (url: string) => Promise<void>;
}

View File

@@ -318,6 +318,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
<div
key={tab.id}
data-tab-id={tab.id}
data-tab-type="sftp"
data-state={isActive ? 'active' : 'inactive'}
onClick={(e) => handleSelectTabClick(e, tab.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
@@ -325,7 +327,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
onDragOver={(e) => handleTabDragOver(e, tab.id)}
onDrop={(e) => handleTabDrop(e, tab.id)}
className={cn(
"relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"transition-[color,opacity,transform] duration-100 ease-out",
isActive
? "text-foreground border-b-2"

View File

@@ -46,6 +46,8 @@ interface UseSftpViewPaneCallbacksParams {
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<void>;
deleteLocalFile?: (path: string) => Promise<void>;
}
export const useSftpViewPaneCallbacks = ({

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import type { ShrinkFinding } from '../../domain/syncGuards';
import { Button } from '../ui/button';
import { useI18n } from '../../application/i18n/I18nProvider';
interface Props {
finding: Extract<ShrinkFinding, { suspicious: true }>;
onRestore: () => void;
onForcePush: () => void;
}
export const SyncBlockedBanner: React.FC<Props> = ({ finding, onRestore, onForcePush }) => {
const { t } = useI18n();
const entityLabel = t(`sync.entityType.${finding.entityType}`);
const percent = finding.baseCount > 0 ? Math.round((finding.lost / finding.baseCount) * 100) : 0;
const reasonText = finding.reason === 'bulk-shrink'
? t('sync.blocked.reason.bulkShrink', {
lost: finding.lost,
baseCount: finding.baseCount,
entityType: entityLabel,
percent,
})
: t('sync.blocked.reason.largeShrink', {
lost: finding.lost,
entityType: entityLabel,
});
return (
<div
role="alert"
className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-4"
>
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span>{t('sync.blocked.title')}</span>
</div>
<p className="text-sm">{reasonText}</p>
<p className="text-xs opacity-70">{t('sync.blocked.detail')}</p>
<div className="flex gap-2">
<Button variant="default" size="sm" onClick={onRestore}>
{t('sync.blocked.restoreButton')}
</Button>
<Button variant="outline" size="sm" onClick={onForcePush}>
{t('sync.blocked.forcePushButton')}
</Button>
</div>
</div>
);
};

View File

@@ -31,6 +31,7 @@ export interface TerminalContextMenuProps {
isAlternateScreen?: boolean;
onCopy?: () => void;
onPaste?: () => void;
onPasteSelection?: () => void;
onSelectAll?: () => void;
onClear?: () => void;
onSplitHorizontal?: () => void;
@@ -48,6 +49,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
isAlternateScreen = false,
onCopy,
onPaste,
onPasteSelection,
onSelectAll,
onClear,
onSplitHorizontal,
@@ -70,26 +72,32 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const copyShortcut = getShortcut('copy');
const pasteShortcut = getShortcut('paste');
const pasteSelectionShortcut = getShortcut('paste-selection');
const selectAllShortcut = getShortcut('select-all');
const splitHShortcut = getShortcut('split-horizontal');
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showContextMenu = rightClickBehavior === 'context-menu' && !isAlternateScreen;
// Handle right-click: intercept for paste/select-word unless Shift is held
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
// enabled so Shift+Right-Click opens the menu on the first click.
const handleRightClick = useCallback(
(e: React.MouseEvent) => {
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus
if (isAlternateScreen) return;
if (rightClickBehavior === 'paste') {
if (isAlternateScreen) {
e.preventDefault();
e.stopPropagation();
return;
}
// Shift+Right-Click or context-menu mode: let Radix open the menu
if (e.shiftKey || rightClickBehavior === 'context-menu') return;
// Paste / select-word: intercept and prevent the context menu
e.preventDefault();
if (rightClickBehavior === 'paste') {
onPaste?.();
} else if (rightClickBehavior === 'select-word') {
e.preventDefault();
e.stopPropagation();
onSelectWord?.();
}
},
@@ -102,12 +110,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
<ContextMenu>
<ContextMenuTrigger
asChild
disabled={!showContextMenu}
onContextMenu={!showContextMenu ? handleRightClick : undefined}
onContextMenu={handleRightClick}
>
{children}
</ContextMenuTrigger>
{showContextMenu && (
{!isAlternateScreen && (
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />
@@ -119,6 +126,13 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
{t('terminal.menu.paste')}
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onPasteSelection && (
<ContextMenuItem onClick={onPasteSelection} disabled={!hasSelection}>
<ClipboardPaste size={14} className="mr-2" />
{t('terminal.menu.pasteSelection')}
<ContextMenuShortcut>{pasteSelectionShortcut}</ContextMenuShortcut>
</ContextMenuItem>
)}
<ContextMenuItem onClick={onSelectAll}>
<TerminalIcon size={14} className="mr-2" />
{t('terminal.menu.selectAll')}

View File

@@ -15,7 +15,7 @@ import { createPortal } from 'react-dom';
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes';
import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
@@ -125,16 +125,21 @@ interface ThemeCustomizeModalProps {
open: boolean;
onClose: () => void;
currentThemeId?: string;
displayThemeId?: string;
currentFontFamilyId?: string;
currentFontSize?: number;
/** Called immediately when user selects a theme (for real-time preview) */
onThemeChange?: (themeId: string) => void;
/** Called when the theme should return to inherited/default state */
onThemeReset?: () => void;
/** Called immediately when user selects a font (for real-time preview) */
onFontFamilyChange?: (fontFamilyId: string) => void;
/** Called immediately when user changes font size (for real-time preview) */
onFontSizeChange?: (fontSize: number) => void;
/** Called when user clicks Save to persist settings */
onSave?: () => void;
/** Optional live preview callback for consumers that render outside this modal */
onPreviewThemeChange?: (theme: TerminalTheme | null) => void;
}
// Memoized preview component to avoid re-rendering on every state change
@@ -261,26 +266,39 @@ const TerminalPreview = memo(({
));
TerminalPreview.displayName = 'TerminalPreview';
const cloneTheme = (theme: TerminalTheme): TerminalTheme => ({
...theme,
colors: { ...theme.colors },
isCustom: true,
});
const serializeTheme = (theme: TerminalTheme): string => JSON.stringify(theme);
export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
open,
onClose,
currentThemeId = 'termius-dark',
currentThemeId,
displayThemeId,
currentFontFamilyId = 'menlo',
currentFontSize = DEFAULT_FONT_SIZE,
onThemeChange,
onThemeReset,
onFontFamilyChange,
onFontSizeChange,
onSave,
onPreviewThemeChange,
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const customThemes = useCustomThemes();
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
const resolvedThemeId = currentThemeId ?? displayThemeId ?? TERMINAL_THEMES[0].id;
const [activeTab, setActiveTab] = useState<TabType>('theme');
const [selectedTheme, setSelectedTheme] = useState(currentThemeId);
const [selectedTheme, setSelectedTheme] = useState(resolvedThemeId);
const [selectedFont, setSelectedFont] = useState(currentFontFamilyId);
const [fontSize, setFontSize] = useState(currentFontSize);
const [draftCustomThemes, setDraftCustomThemes] = useState<TerminalTheme[]>(() => customThemes.map(cloneTheme));
// Custom theme editor state
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
@@ -293,30 +311,37 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
font: currentFontFamilyId,
fontSize: currentFontSize,
});
const originalCustomThemesRef = useRef<TerminalTheme[]>([]);
const wasOpenRef = useRef(false);
// Combine built-in + custom themes
const allThemes = useMemo(
() => [...TERMINAL_THEMES, ...customThemes],
[customThemes]
() => [...TERMINAL_THEMES, ...draftCustomThemes],
[draftCustomThemes]
);
// Sync state when modal opens
useEffect(() => {
if (open) {
if (open && !wasOpenRef.current) {
// Store original values for potential cancel
originalValuesRef.current = {
theme: currentThemeId,
font: currentFontFamilyId,
fontSize: currentFontSize,
};
originalCustomThemesRef.current = customThemes.map((theme) => ({
...cloneTheme(theme),
}));
// Initialize selected values
setSelectedTheme(currentThemeId);
setSelectedTheme(resolvedThemeId);
setSelectedFont(currentFontFamilyId);
setFontSize(currentFontSize);
setDraftCustomThemes(customThemes.map(cloneTheme));
setEditingTheme(null);
setIsNewTheme(false);
}
}, [open, currentThemeId, currentFontFamilyId, currentFontSize]);
wasOpenRef.current = open;
}, [open, currentThemeId, resolvedThemeId, currentFontFamilyId, currentFontSize, customThemes]);
const currentFont = useMemo(
(): TerminalFont => availableFonts.find(f => f.id === selectedFont) || availableFonts[0],
@@ -326,6 +351,16 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
() => editingTheme || allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
[selectedTheme, allThemes, editingTheme]
);
const hiddenSelectedTheme = useMemo(
() => (isUiMatchTerminalThemeId(selectedTheme)
? TERMINAL_THEMES.find((theme) => theme.id === selectedTheme) || null
: null),
[selectedTheme]
);
useEffect(() => {
onPreviewThemeChange?.(open ? currentTheme : null);
}, [currentTheme, onPreviewThemeChange, open]);
// Handle theme selection - apply immediately for real-time preview
const handleThemeSelect = useCallback((themeId: string) => {
@@ -379,7 +414,7 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
const xml = reader.result as string;
const parsed = parseItermcolors(xml, name);
if (parsed) {
addTheme(parsed);
setDraftCustomThemes((prev) => [...prev, cloneTheme(parsed)]);
setSelectedTheme(parsed.id);
onThemeChange?.(parsed.id);
setActiveTab('theme');
@@ -394,16 +429,16 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
reader.readAsText(file);
// Reset file input so the same file can be re-imported
e.target.value = '';
}, [addTheme, onThemeChange, t]);
}, [onThemeChange, t]);
const handleEditTheme = useCallback((themeId: string) => {
const theme = customThemes.find(t => t.id === themeId);
const theme = draftCustomThemes.find(t => t.id === themeId);
if (theme) {
setEditingTheme({ ...theme, colors: { ...theme.colors } });
setIsNewTheme(false);
setActiveTab('custom');
}
}, [customThemes]);
}, [draftCustomThemes]);
const handleEditorBack = useCallback(() => {
@@ -412,40 +447,63 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
}, []);
const handleEditorDelete = useCallback((themeId: string) => {
deleteTheme(themeId);
setDraftCustomThemes((prev) => prev.filter((theme) => theme.id !== themeId));
if (selectedTheme === themeId) {
setSelectedTheme(TERMINAL_THEMES[0].id);
onThemeChange?.(TERMINAL_THEMES[0].id);
const originalThemeId = originalValuesRef.current.theme;
const fallbackThemeId = originalThemeId && originalThemeId !== themeId
? originalThemeId
: (displayThemeId && displayThemeId !== themeId ? displayThemeId : USER_VISIBLE_TERMINAL_THEMES[0].id);
setSelectedTheme(fallbackThemeId);
if (originalThemeId == null && displayThemeId && displayThemeId !== themeId) {
onThemeReset?.();
} else {
onThemeChange?.(fallbackThemeId);
}
}
setEditingTheme(null);
setIsNewTheme(false);
}, [deleteTheme, selectedTheme, onThemeChange]);
}, [displayThemeId, onThemeChange, onThemeReset, selectedTheme]);
// Save: just close (changes are already applied)
const handleSave = useCallback(() => {
// If editing a custom theme, save it first
if (editingTheme) {
if (isNewTheme) {
addTheme(editingTheme);
setSelectedTheme(editingTheme.id);
onThemeChange?.(editingTheme.id);
} else {
updateTheme(editingTheme.id, editingTheme);
const originalThemes = originalCustomThemesRef.current;
const originalMap = new Map(originalThemes.map((theme) => [theme.id, theme]));
const draftMap = new Map(draftCustomThemes.map((theme) => [theme.id, theme]));
for (const [id, originalTheme] of originalMap) {
if (!draftMap.has(id)) {
deleteTheme(id);
continue;
}
const nextTheme = draftMap.get(id)!;
if (serializeTheme(originalTheme) !== serializeTheme(nextTheme)) {
updateTheme(id, nextTheme);
}
}
for (const [id, draftTheme] of draftMap) {
if (!originalMap.has(id)) {
addTheme(draftTheme);
}
}
onSave?.();
onClose();
}, [editingTheme, isNewTheme, addTheme, updateTheme, onSave, onClose, onThemeChange]);
}, [addTheme, deleteTheme, draftCustomThemes, onClose, onSave, updateTheme]);
// Cancel: revert to original values
const handleCancel = useCallback(() => {
const original = originalValuesRef.current;
// Revert all changes
onThemeChange?.(original.theme);
if (original.theme) {
onThemeChange?.(original.theme);
} else {
onThemeReset?.();
}
onFontFamilyChange?.(original.font);
onFontSizeChange?.(original.fontSize);
onClose();
}, [onThemeChange, onFontFamilyChange, onFontSizeChange, onClose]);
}, [onThemeChange, onThemeReset, onFontFamilyChange, onFontSizeChange, onClose]);
// Handle ESC key - same as cancel, but skip when child editor is open
useEffect(() => {
@@ -465,7 +523,7 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
if (!open) return null;
// Separate built-in and custom themes for display in the theme list
const builtinThemes = TERMINAL_THEMES;
const builtinThemes = USER_VISIBLE_TERMINAL_THEMES;
const modalContent = (
<div
@@ -541,6 +599,17 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
<div className="flex-1 min-h-0 overflow-y-auto p-2">
{activeTab === 'theme' && (
<div className="space-y-1">
{hiddenSelectedTheme && (
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5 mb-2">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
{t('terminal.hiddenTheme.title')}
</div>
<div className="text-xs font-medium text-foreground">{hiddenSelectedTheme.name}</div>
<div className="text-[10px] text-muted-foreground mt-1">
{t('terminal.hiddenTheme.desc')}
</div>
</div>
)}
{/* Built-in themes */}
{builtinThemes.map(theme => (
<ThemeItem
@@ -551,12 +620,12 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
/>
))}
{/* Custom themes section */}
{customThemes.length > 0 && (
{draftCustomThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1.5 px-1 font-semibold">
{t('terminal.customTheme.section')}
</div>
{customThemes.map(theme => (
{draftCustomThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
@@ -617,12 +686,12 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
/>
{/* Custom themes list */}
{customThemes.length > 0 && (
{draftCustomThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1 px-1 font-semibold">
{t('terminal.customTheme.yourThemes')}
</div>
{customThemes.map(theme => (
{draftCustomThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
@@ -717,12 +786,16 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
theme={editingTheme}
isNew={isNewTheme}
onSave={(theme) => {
setDraftCustomThemes((prev) => {
if (isNewTheme) {
return [...prev, cloneTheme(theme)];
}
return prev.map((entry) => entry.id === theme.id ? cloneTheme(theme) : entry);
});
if (isNewTheme) {
addTheme(theme);
setSelectedTheme(theme.id);
onThemeChange?.(theme.id);
} else {
updateTheme(theme.id, theme);
if (selectedTheme === theme.id) {
onThemeChange?.(theme.id);
}

View File

@@ -6,11 +6,11 @@
* Changes apply in real-time.
*/
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes';
import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
@@ -126,6 +126,7 @@ const FontItem = memo(({
FontItem.displayName = 'FontItem';
interface ThemeSidePanelProps {
followAppTerminalTheme?: boolean;
currentThemeId: string;
globalThemeId: string;
currentFontFamilyId: string;
@@ -152,6 +153,7 @@ interface ThemeSidePanelProps {
}
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
followAppTerminalTheme = false,
currentThemeId,
globalThemeId,
currentFontFamilyId,
@@ -178,7 +180,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
const customThemes = useCustomThemes();
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
const [activeTab, setActiveTab] = useState<TabType>('theme');
const [activeTab, setActiveTab] = useState<TabType>(followAppTerminalTheme ? 'font' : 'theme');
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
const [isNewTheme, setIsNewTheme] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -191,6 +193,12 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
() => allThemes.find((theme) => theme.id === globalThemeId) || TERMINAL_THEMES[0],
[allThemes, globalThemeId],
);
const hiddenSelectedTheme = useMemo(
() => (isUiMatchTerminalThemeId(currentThemeId)
? TERMINAL_THEMES.find((theme) => theme.id === currentThemeId) || null
: null),
[currentThemeId],
);
const globalFont = useMemo(
() => availableFonts.find((font) => font.id === globalFontFamilyId) || availableFonts[0],
[availableFonts, globalFontFamilyId],
@@ -264,9 +272,27 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
setIsNewTheme(false);
}, [deleteTheme, currentThemeId, onThemeChange]);
const themeEditingLocked = followAppTerminalTheme;
useEffect(() => {
if (themeEditingLocked && activeTab !== 'font') {
setActiveTab('font');
}
}, [activeTab, themeEditingLocked]);
useEffect(() => {
if (!themeEditingLocked || !editingTheme) return;
setEditingTheme(null);
setIsNewTheme(false);
}, [editingTheme, themeEditingLocked]);
if (!isVisible) return null;
const builtinThemes = TERMINAL_THEMES;
const builtinThemes = USER_VISIBLE_TERMINAL_THEMES;
const footerLabel = themeEditingLocked
? `${availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId}${currentFontSize}px • ${currentFontWeight}`
: `${allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId}${availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId}${currentFontSize}px • ${currentFontWeight}`;
const panelVars = {
['--terminal-panel-bg' as never]: previewColors?.background ?? 'var(--background)',
['--terminal-panel-fg' as never]: previewColors?.foreground ?? 'var(--foreground)',
@@ -289,17 +315,19 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
>
{/* Tab Bar */}
<div className="flex p-1.5 gap-0.5 shrink-0 border-b" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<button
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Palette size={12} />
{t('terminal.themeModal.tab.theme')}
</button>
{!themeEditingLocked && (
<button
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Palette size={12} />
{t('terminal.themeModal.tab.theme')}
</button>
)}
<button
onClick={() => setActiveTab('font')}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
@@ -311,24 +339,37 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
<Type size={12} />
{t('terminal.themeModal.tab.font')}
</button>
<button
onClick={() => setActiveTab('custom')}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Sparkles size={12} />
{t('terminal.themeModal.tab.custom')}
</button>
{!themeEditingLocked && (
<button
onClick={() => setActiveTab('custom')}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Sparkles size={12} />
{t('terminal.themeModal.tab.custom')}
</button>
)}
</div>
{/* List Content */}
<ScrollArea className="flex-1 min-h-0">
<div className="py-1">
{activeTab === 'theme' && (
{!themeEditingLocked && activeTab === 'theme' && (
<div>
{hiddenSelectedTheme && (
<div className="mx-2 mb-2 rounded-lg border px-3 py-2.5" style={{ borderColor: 'var(--terminal-panel-border)', backgroundColor: 'var(--terminal-panel-hover)' }}>
<div className="text-[10px] uppercase tracking-wider mb-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.hiddenTheme.title')}
</div>
<div className="text-xs font-medium">{hiddenSelectedTheme.name}</div>
<div className="text-[10px] mt-1" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.hiddenTheme.desc')}
</div>
</div>
)}
{builtinThemes.map(theme => (
<ThemeItem
key={theme.id}
@@ -391,7 +432,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
)}
</div>
)}
{activeTab === 'custom' && !editingTheme && (
{!themeEditingLocked && activeTab === 'custom' && !editingTheme && (
<div>
<button
onClick={handleNewTheme}
@@ -505,6 +546,23 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
</div>
)}
{themeEditingLocked && canResetTheme && (
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="flex items-center justify-between gap-2">
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.globalTheme')}
</div>
<button
onClick={onThemeReset}
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--terminal-panel-fg)' }}
>
{t('common.useGlobal')}
</button>
</div>
</div>
)}
{/* Font Weight Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
@@ -550,7 +608,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
{/* Current selection info */}
<div className="px-2.5 py-1.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="text-[9px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} {currentFontSize}px {currentFontWeight}
{footerLabel}
</div>
</div>
</div>

View File

@@ -690,7 +690,9 @@ export function useTerminalAutocomplete(
}
}
// Tab: accept selected popup suggestion, or accept ghost text
// Tab: accept selected popup suggestion. Ghost text is accepted via → only —
// letting Tab pass through lets the shell's native completion (bash/zsh) run,
// which is otherwise shadowed by our single-Tab ghost accept.
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.suggestions.length > 0) {
e.preventDefault();
@@ -698,16 +700,10 @@ export function useTerminalAutocomplete(
if (selected) insertSuggestion(selected, false);
return false;
}
// Hide stale ghost text before Tab reaches the shell — the shell's
// completion will rewrite the line and the old ghost would mislead.
if (ghost?.isVisible()) {
e.preventDefault();
const ghostText = ghost.getGhostText();
if (ghostText) {
writeToTerminal(ghostText);
lastAcceptedCommandRef.current = ghost.getSuggestion();
ghost.hide();
clearState();
}
return false;
ghost.hide();
}
}

View File

@@ -0,0 +1,85 @@
import type { Terminal as XTerm } from "@xterm/xterm";
type CsiParam = number | number[];
type InternalTerminal = XTerm & {
_core?: {
scroll?: (eraseAttr: unknown, isWrapped?: boolean) => void;
_inputHandler?: {
_eraseAttrData?: () => unknown;
};
};
};
const getVisibleContentRowCount = (term: XTerm): number => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") {
return 0;
}
const baseY = buffer.baseY;
for (let row = term.rows - 1; row >= 0; row--) {
const line = buffer.getLine(baseY + row);
if (!line) {
continue;
}
if (line.translateToString(true).length > 0) {
return row + 1;
}
}
return 0;
};
export const preserveTerminalViewportInScrollback = (term: XTerm): void => {
const rowsToPreserve = getVisibleContentRowCount(term);
if (rowsToPreserve <= 0) {
return;
}
const internal = term as InternalTerminal;
const scroll = internal._core?.scroll;
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
if (typeof scroll !== "function" || eraseAttr === undefined) {
return;
}
for (let row = 0; row < rowsToPreserve; row++) {
scroll.call(internal._core, eraseAttr, false);
}
};
export const clearTerminalViewport = (term: XTerm): void => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") return;
const cursorY = buffer.cursorY;
const cursorX = buffer.cursorX;
if (cursorY === 0 && buffer.baseY === 0) return;
const internal = term as InternalTerminal;
const scroll = internal._core?.scroll;
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
if (typeof scroll !== "function" || eraseAttr === undefined) return;
// Push lines above cursor into scrollback so they are preserved.
// After cursorY scrolls the prompt line shifts to active-screen row 0.
for (let i = 0; i < cursorY; i++) {
scroll.call(internal._core, eraseAttr, false);
}
// Clear everything below the prompt and reposition the cursor on it.
// CSI coordinates are 1-indexed.
const col = cursorX + 1;
term.write(`\x1b[2;1H\x1b[J\x1b[1;${col}H`, () => {
term.scrollToBottom();
});
};
export const isEraseScrollbackSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 3;
export const isEraseViewportSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 2;

View File

@@ -89,11 +89,23 @@ export function useServerStats({
const hasFetchedRef = useRef(false);
const connectedAtRef = useRef(0);
const fetchGenerationRef = useRef(0);
// Auto-disable polling after a few consecutive failures. This covers
// hosts the banner classifier could not identify (e.g. Juniper JUNOS,
// Arista EOS, Cisco NX-OS — all of which advertise themselves as
// OpenSSH but do not support the POSIX stats shell command). Without
// this, the hook would keep retrying forever and generate an AAA
// session log every refresh interval.
const CONSECUTIVE_FAILURE_LIMIT = 3;
const consecutiveFailuresRef = useRef(0);
const givenUpRef = useRef(false);
const fetchStats = useCallback(async () => {
if (!enabled || !isSupportedOs || !isConnected || !isVisible || !sessionId) {
return;
}
if (givenUpRef.current) {
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) {
@@ -104,6 +116,21 @@ export function useServerStats({
setIsLoading(true);
setError(null);
const markFailure = (message: string) => {
consecutiveFailuresRef.current += 1;
setError(message);
if (consecutiveFailuresRef.current >= CONSECUTIVE_FAILURE_LIMIT) {
// Stop polling this session. The caller's useEffect sees the
// givenUp flag via the next render cycle and we also clear the
// interval locally so no further ticks fire.
givenUpRef.current = true;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
};
try {
const result = await bridge.getServerStats(sessionId);
@@ -112,6 +139,7 @@ export function useServerStats({
if (result.success && result.stats) {
hasFetchedRef.current = true;
consecutiveFailuresRef.current = 0;
setStats({
cpu: result.stats.cpu,
cpuCores: result.stats.cpuCores,
@@ -134,11 +162,17 @@ export function useServerStats({
lastUpdated: Date.now(),
});
} else if (result.error) {
setError(result.error);
markFailure(result.error);
} else {
// Response was not marked as success but has no error — treat as
// a soft failure. This happens e.g. when the stats shell pipeline
// returns a parse failure on a host that isn't a typical Linux
// distro (JUNOS, NX-OS, EOS).
markFailure('No stats returned');
}
} catch (err) {
if (isMountedRef.current && generation === fetchGenerationRef.current) {
setError(err instanceof Error ? err.message : 'Unknown error');
markFailure(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (isMountedRef.current && generation === fetchGenerationRef.current) {
@@ -147,6 +181,15 @@ export function useServerStats({
}
}, [sessionId, enabled, isSupportedOs, isConnected, isVisible]);
// When the session changes (e.g., same tab reconnects to a different host
// while staying connected), reset the failure counter. Without this, a
// JUNOS session that tripped the counter would permanently suppress
// polling even after the tab reconnects to a Linux host.
useEffect(() => {
consecutiveFailuresRef.current = 0;
givenUpRef.current = false;
}, [sessionId]);
// Initial fetch and periodic refresh
useEffect(() => {
isMountedRef.current = true;
@@ -158,9 +201,13 @@ export function useServerStats({
}
if (!enabled || !isSupportedOs || !isConnected) {
// Reset stats and fetch state when disabled or not connected
// Reset stats and fetch state when disabled or not connected.
// Also reset the give-up flag so that a reconnect (possibly to a
// different host at the same sessionId slot) gets a fresh chance.
hasFetchedRef.current = false;
connectedAtRef.current = 0;
consecutiveFailuresRef.current = 0;
givenUpRef.current = false;
setStats({
cpu: null,
@@ -222,15 +269,23 @@ export function useServerStats({
// (e.g., tab was hidden while connected and is now becoming visible).
const connectionAge = Date.now() - connectedAtRef.current;
const needsWarmup = !hasFetchedRef.current && connectionAge < 2000;
const initialTimer = setTimeout(fetchStats, needsWarmup ? 2000 : 0);
// If we already gave up on this session (exceeded the consecutive
// failure limit), don't even schedule new timers on effect reruns
// such as visibility/tab-focus/settings changes. The cleanup at
// disconnect/sessionId change clears the flag for a fresh attempt.
const initialTimer = givenUpRef.current
? null
: setTimeout(fetchStats, needsWarmup ? 2000 : 0);
// Set up periodic refresh
const intervalMs = Math.max(5, refreshInterval) * 1000; // Minimum 5 seconds
intervalRef.current = setInterval(fetchStats, intervalMs);
if (!givenUpRef.current) {
intervalRef.current = setInterval(fetchStats, intervalMs);
}
return () => {
isMountedRef.current = false;
clearTimeout(initialTimer);
if (initialTimer) clearTimeout(initialTimer);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;

View File

@@ -3,6 +3,7 @@ import { useCallback } from "react";
import type { RefObject } from "react";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { clearTerminalViewport } from "../clearTerminalViewport";
type TerminalBackendWriteApi = {
writeToSession: (sessionId: string, data: string) => void;
@@ -55,6 +56,24 @@ export const useTerminalContextActions = ({
}
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
const onPasteSelection = useCallback(() => {
const term = termRef.current;
if (!term) return;
const selection = term.getSelection();
if (!selection || !sessionRef.current) return;
let data = normalizeLineEndings(selection);
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
terminalBackend.writeToSession(sessionRef.current, data);
if (scrollOnPasteRef?.current) {
term.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
term.scrollToBottom();
});
}
}
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
const onSelectAll = useCallback(() => {
const term = termRef.current;
if (!term) return;
@@ -65,7 +84,7 @@ export const useTerminalContextActions = ({
const onClear = useCallback(() => {
const term = termRef.current;
if (!term) return;
term.clear();
clearTerminalViewport(term);
}, [termRef]);
const onSelectWord = useCallback(() => {
@@ -75,5 +94,5 @@ export const useTerminalContextActions = ({
onHasSelectionChange?.(true);
}, [onHasSelectionChange, termRef]);
return { onCopy, onPaste, onSelectAll, onClear, onSelectWord };
return { onCopy, onPaste, onPasteSelection, onSelectAll, onClear, onSelectWord };
};

View File

@@ -10,9 +10,23 @@ import {
sanitizeCredentialValue,
} from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
import { detectVendorFromSshVersion } from "../../../domain/host";
/** Timeout of distro detection task */
const DISTRO_DETECT_TIMEOUT = 8000; // ms
/**
* Per-connection token for stale-timer detection. The renderer reuses the
* same sessionId across reconnects within a tab, so comparing sessionIds
* cannot distinguish "the current attempt" from "a previous attempt on
* the same slot". We assign each startSSH call a fresh token object and
* store it in this module-local map, keyed by sessionId. A timer that
* was scheduled under an older token will see a different value here and
* bail out. The map entry for a sessionId is overwritten on each new
* connect and stays around until the app exits — since there is only one
* entry per active session, the memory cost is negligible.
*/
const connectionTokensBySessionId = new Map<string, object>();
const isConnectionTokenCurrent = (sessionId: string, token: object): boolean =>
connectionTokensBySessionId.get(sessionId) === token;
type TerminalBackendApi = {
backendAvailable: () => boolean;
@@ -38,6 +52,17 @@ type TerminalBackendApi = {
stdout?: string;
stderr?: string;
}>;
getSessionRemoteInfo?: (sessionId: string) => Promise<{
success: boolean;
remoteSshVersion?: string;
error?: string;
}>;
getSessionDistroInfo?: (sessionId: string) => Promise<{
success: boolean;
stdout?: string;
stderr?: string;
error?: string;
}>;
onSessionData: (sessionId: string, cb: (data: string) => void) => () => void;
onSessionExit: (
sessionId: string,
@@ -223,32 +248,73 @@ const attachSessionToTerminal = (
}
}
// Clean up the connection token for this sessionId so stale timers
// that haven't fired yet will fail the isConnectionTokenCurrent check
// (previously they would see the old token still in the map and pass).
connectionTokensBySessionId.delete(ctx.sessionId);
ctx.onSessionExit?.(ctx.sessionId, evt);
});
};
const runDistroDetection = async (
ctx: TerminalSessionStartersContext,
auth: { username: string; password?: string; key?: SSHKey; passphrase?: string },
sessionId: string,
connectionToken: object,
) => {
if (!ctx.terminalBackend.execAvailable()) return;
// Stale-session guard: the renderer reuses ctx.sessionId across
// reconnects in the same tab, so comparing sessionIds is not enough.
// We compare against a per-connection token instead; if a newer
// connect attempt has run, it will have replaced the token in the
// module-level map and this check will fail. Repeated after every
// await because the session can change during an async call.
const isStillCurrent = () => isConnectionTokenCurrent(sessionId, connectionToken);
if (!isStillCurrent()) return;
// Step 1: try to classify from the SSH server identification string
// captured at handshake time. This is free (no extra channel) and
// reliably identifies most network-device vendors (Cisco IOS, Huawei
// VRP, HPE Comware, MikroTik, Fortinet, etc.) so we can skip the
// POSIX-shell probe entirely for those hosts — which otherwise fails
// and, on devices like Cisco / Juniper with AAA logging, generates an
// extra session log entry per connect.
try {
const res = await ctx.terminalBackend.execCommand({
hostname: ctx.host.hostname,
username: auth.username || "root",
port: ctx.host.port || 22,
password: auth.password,
privateKey: auth.key?.privateKey,
passphrase: auth.passphrase ?? auth.key?.passphrase,
command: "cat /etc/os-release 2>/dev/null || uname -a",
timeout: DISTRO_DETECT_TIMEOUT,
});
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const distro = idMatch
? idMatch[1]
: (data.split(/\s+/)[0] || "").toLowerCase();
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
if (ctx.terminalBackend.getSessionRemoteInfo && sessionId) {
const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId);
if (!isStillCurrent()) return;
const vendor = detectVendorFromSshVersion(info?.remoteSshVersion);
if (vendor) {
ctx.onOsDetected?.(ctx.host.id, vendor);
return;
}
}
} catch (err) {
logger.warn("SSH banner vendor detection failed", err);
}
if (!isStillCurrent()) return;
// Step 2: unknown or generic OpenSSH/Dropbear — fall back to the
// /etc/os-release probe to pick a distro-specific icon. We deliberately
// use `getSessionDistroInfo` which runs the probe on the *existing*
// SSH connection's exec channel instead of spinning up a brand new
// SSH client the way `execCommand` would. That saves a full handshake
// round-trip on every connect, and on OpenSSH-fronted network devices
// that we couldn't identify from the banner (JUNOS, NX-OS, EOS) it
// avoids one extra AAA session log entry per connect.
try {
if (ctx.terminalBackend.getSessionDistroInfo && sessionId) {
const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId);
if (!isStillCurrent()) return;
if (!res?.success) return;
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const distro = idMatch
? idMatch[1]
: (data.split(/\s+/)[0] || "").toLowerCase();
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
}
} catch (err) {
logger.warn("OS probe failed", err);
}
@@ -262,12 +328,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startSSH = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.backendAvailable()) {
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
term.writeln(
@@ -299,8 +359,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(key?.privateKey);
let usedKey: SSHKey | undefined;
let usedPassword: string | undefined;
const isAuthError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
@@ -572,7 +630,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (hasKeyMaterial) {
try {
id = await startAttempt({ key });
usedKey = key;
} catch (err) {
if (isAuthError(err) && hasPassword) {
ctx.setProgressLogs((prev) => [
@@ -580,14 +637,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
"Key auth failed. Trying password...",
]);
id = await startAttempt({ password: effectivePassword });
usedPassword = effectivePassword;
} else {
throw err;
}
}
} else {
id = await startAttempt({ password: effectivePassword });
usedPassword = effectivePassword;
}
if (unsubscribeChainProgress) unsubscribeChainProgress();
@@ -614,17 +669,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}, 600);
}
// Run OS detection only after successful connection
setTimeout(
() =>
void runDistroDetection(ctx, {
username: effectiveUsername,
password: usedPassword,
key: usedKey,
passphrase: effectivePassphrase,
}),
600,
);
// Run OS detection only after successful connection. Mint a fresh
// token for this specific connection attempt and register it as
// the current one for this sessionId slot; any previous timer
// scheduled against an earlier token will see the replacement
// and bail out. The detection function re-checks the token after
// every async await so a reconnect mid-probe is also caught.
{
const connectionToken = {};
connectionTokensBySessionId.set(id, connectionToken);
setTimeout(() => {
if (!isConnectionTokenCurrent(id, connectionToken)) return;
void runDistroDetection(ctx, id, connectionToken);
}, 600);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const authError = isAuthError(err);
@@ -653,12 +711,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startTelnet = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.telnetAvailable()) {
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
term.writeln("\r\n[Telnet bridge unavailable. Please run the desktop build.]");
@@ -692,12 +744,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startMosh = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.moshAvailable()) {
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
term.writeln("\r\n[Mosh bridge unavailable. Please run the desktop build.]");
@@ -748,12 +794,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startLocal = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.localAvailable()) {
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
term.writeln(

View File

@@ -31,6 +31,12 @@ import {
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
clearTerminalViewport,
isEraseViewportSequence,
isEraseScrollbackSequence,
preserveTerminalViewportInScrollback,
} from "../clearTerminalViewport";
import type {
Host,
KeyBinding,
@@ -108,6 +114,10 @@ export type CreateXTermRuntimeContext = {
onAutocompleteKeyEvent?: (e: KeyboardEvent) => boolean;
// Autocomplete input handler — called on every character input
onAutocompleteInput?: (data: string) => void;
// Set to true while we're programmatically restoring a selection so that
// copy-on-select listeners can suppress redundant clipboard writes.
isRestoringSelectionRef?: RefObject<boolean>;
};
const detectPlatform = (): XTermPlatform => {
@@ -129,6 +139,21 @@ const detectPlatform = (): XTermPlatform => {
return "darwin";
};
/**
* Extract the primary font family from a CSS font-family string that may
* include fallback fonts. `document.fonts.check` returns `false` when *any*
* listed font is still loading, so passing the entire CJK fallback stack
* causes false negatives during early terminal creation which in turn makes
* `fontWeightBold` fall back to the normal weight and renders bold text too
* thin.
*/
export const primaryFontFamily = (fontFamily: string): string => {
// Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
const match = fontFamily.match(/^(?:"[^"]*"|'[^']*'|[^,])+/);
const first = match?.[0]?.trim();
return first || fontFamily;
};
export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime => {
const platform = detectPlatform();
const deviceMemoryGb =
@@ -161,7 +186,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const cursorStyle = settings?.cursorShape ?? "block";
const cursorBlink = settings?.cursorBlink ?? true;
const scrollback = settings?.scrollback ?? 10000;
// xterm.js treats scrollback=0 as "no scrollback buffer", which breaks mouse
// wheel scrolling (events become arrow-key sequences). The UI uses 0 to mean
// "no limit", so map it to a large value instead.
const rawScrollback = settings?.scrollback ?? 10000;
const scrollback = rawScrollback === 0 ? 999999 : rawScrollback;
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
const fontWeightBold = settings?.fontWeightBold ?? 700;
@@ -180,7 +209,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (typeof document === "undefined" || !document.fonts?.check) {
return fontWeightBold;
}
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
})();
@@ -394,18 +423,44 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return true;
}
// Preserve mouse selection across keystrokes when enabled. xterm.js
// unconditionally clears the selection on user input
// (SelectionService.ts: coreService.onUserInput → clearSelection).
// Capture the selection here, then re-apply it after xterm has
// processed the key + cleared. The microtask runs after both
// synchronous listeners, so by then either the selection is gone (and
// we restore) or it's still there (we no-op).
if (
ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
term.hasSelection()
) {
const sel = term.getSelectionPosition();
if (sel) {
const length =
(sel.end.y - sel.start.y) * term.cols + (sel.end.x - sel.start.x);
const savedStartX = sel.start.x;
const savedStartY = sel.start.y;
queueMicrotask(() => {
if (term.hasSelection()) return;
// Bail out if scrollback trim invalidated the row index.
if (savedStartY >= term.buffer.active.length) return;
const restoreFlag = ctx.isRestoringSelectionRef;
if (restoreFlag) restoreFlag.current = true;
try {
term.select(savedStartX, savedStartY, length);
} finally {
if (restoreFlag) restoreFlag.current = false;
}
});
}
}
// Autocomplete key handler (must be checked before other handlers)
if (ctx.onAutocompleteKeyEvent) {
const consumed = ctx.onAutocompleteKeyEvent(e);
if (!consumed) return false; // Event was consumed by autocomplete
}
if ((e.ctrlKey || e.metaKey) && e.key === "f" && e.type === "keydown") {
e.preventDefault();
ctx.setIsSearchOpen(true);
return false;
}
const currentScheme = ctx.hotkeySchemeRef.current;
// Use shared utility for platform detection when hotkey scheme is disabled
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
@@ -478,12 +533,23 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
break;
}
case "pasteSelection": {
const selection = term.getSelection();
const id = ctx.sessionRef.current;
if (selection && id) {
let data = normalizeLineEndings(selection);
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
}
break;
}
case "selectAll": {
term.selectAll();
break;
}
case "clearBuffer": {
term.clear();
clearTerminalViewport(term);
break;
}
case "searchTerminal": {
@@ -626,6 +692,20 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
let currentCwd: string | undefined = undefined;
const eraseScrollbackDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
if (isEraseViewportSequence(params)) {
preserveTerminalViewportInScrollback(term);
return false;
}
if (!isEraseScrollbackSequence(params)) {
return false;
}
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
// Honor it unless the user opts into the legacy "preserve history" behavior.
const wipeAllowed = ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true;
return !wipeAllowed;
});
// Register OSC 7 handler using xterm.js parser
// OSC 7 is the standard way for shells to report the current working directory
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
@@ -748,6 +828,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
dispose: () => {
cleanupMiddleClick?.();
keywordHighlighter.dispose();
eraseScrollbackDisposable.dispose();
osc7Disposable.dispose();
osc52Disposable.dispose();
try {

View File

@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldPreserveTerminalFocusOnMouseDown } from "./toolbarFocus.ts";
test("preserves terminal focus for non-editable overlay clicks", () => {
const buttonLikeTarget = {
tagName: "button",
isContentEditable: false,
closest() {
return null;
},
getAttribute() {
return null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(buttonLikeTarget as unknown as EventTarget), true);
});
test("allows native focus for direct editable targets", () => {
const inputTarget = {
tagName: "input",
isContentEditable: false,
closest() {
return null;
},
getAttribute() {
return null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(inputTarget as unknown as EventTarget), false);
});
test("allows native focus for descendants inside editable controls", () => {
const nestedTarget = {
tagName: "span",
isContentEditable: false,
closest(selector: string) {
return selector.includes("input") ? { tagName: "INPUT" } : null;
},
getAttribute() {
return null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(nestedTarget as unknown as EventTarget), false);
});
test("allows native focus for contenteditable regions", () => {
const editableTarget = {
tagName: "div",
isContentEditable: false,
closest() {
return null;
},
getAttribute(name: string) {
return name === "contenteditable" ? "true" : null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(editableTarget as unknown as EventTarget), false);
});

View File

@@ -0,0 +1,44 @@
type FocusTargetLike = {
tagName?: string | null;
isContentEditable?: boolean;
closest?: (selector: string) => unknown;
getAttribute?: (name: string) => string | null;
};
const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]';
/**
* The terminal's top overlay sits above the xterm textarea. Pointer clicks on
* that layer should usually keep focus in the terminal so typing can continue.
* Only allow native focus changes for genuinely editable controls.
*/
export const shouldPreserveTerminalFocusOnMouseDown = (target: EventTarget | null): boolean => {
if (!target || typeof target !== "object") return true;
const candidate = target as FocusTargetLike;
const tagName = typeof candidate.tagName === "string"
? candidate.tagName.toUpperCase()
: "";
if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") {
return false;
}
if (candidate.isContentEditable) {
return false;
}
if (typeof candidate.getAttribute === "function") {
const contentEditable = candidate.getAttribute("contenteditable");
const role = candidate.getAttribute("role");
if (contentEditable === "" || contentEditable === "true" || role === "textbox") {
return false;
}
}
if (typeof candidate.closest === "function" && candidate.closest(EDITABLE_SELECTOR)) {
return false;
}
return true;
};

View File

@@ -1,5 +1,5 @@
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
import React, { createContext, ReactNode, useCallback, useContext, useState } from 'react';
import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { ScrollArea } from './scroll-area';
@@ -44,6 +44,12 @@ interface AsidePanelProps {
children: ReactNode;
className?: string;
width?: string;
layout?: AsidePanelLayout;
/**
* Optional stable identifier emitted as `data-section` on the panel
* root. Used as a targeting hook for Custom CSS (Settings → Appearance).
*/
dataSection?: string;
}
interface AsidePanelHeaderProps {
@@ -171,14 +177,40 @@ interface AsidePanelStackProps {
initialItem: AsideContentItem;
className?: string;
width?: string;
layout?: AsidePanelLayout;
/**
* Optional stable identifier emitted as `data-section` on the panel
* root. Used as a targeting hook for Custom CSS.
*/
dataSection?: string;
}
export type AsidePanelLayout = 'overlay' | 'inline';
const resolveInlineWidth = (width: string) => {
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
if (arbitraryWidthMatch) {
return arbitraryWidthMatch[1];
}
switch (width) {
case 'w-full':
return '100%';
case 'w-screen':
return '100vw';
default:
return '380px';
}
};
export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
open,
onClose,
initialItem,
className,
width = 'w-[380px]',
layout = 'overlay',
dataSection,
}) => {
const [stack, setStack] = useState<AsideContentItem[]>([initialItem]);
@@ -205,6 +237,13 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
const currentItem = stack[stack.length - 1];
const canGoBack = stack.length > 1;
const inlineWidth = useMemo(() => resolveInlineWidth(width), [width]);
const inlineStyle = layout === 'inline'
? ({
width: inlineWidth,
['--aside-inline-width' as string]: inlineWidth,
} as React.CSSProperties)
: undefined;
// Reset stack when panel closes/opens
React.useEffect(() => {
@@ -218,10 +257,14 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
return (
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
layout === 'inline'
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && width,
className
)}>
)}
style={inlineStyle}
data-section={dataSection}>
<AsidePanelHeader
title={currentItem.title}
subtitle={currentItem.subtitle}
@@ -248,15 +291,29 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
children,
className,
width = 'w-[380px]',
layout = 'overlay',
dataSection,
}) => {
if (!open) return null;
const inlineWidth = resolveInlineWidth(width);
const inlineStyle = layout === 'inline'
? ({
width: inlineWidth,
['--aside-inline-width' as string]: inlineWidth,
} as React.CSSProperties)
: undefined;
return (
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
layout === 'inline'
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && width,
className
)}>
)}
style={inlineStyle}
data-section={dataSection}>
{title && (
<AsidePanelHeader
title={title}

View File

@@ -17,10 +17,30 @@ export function resolveGroupDefaults(
const config = configMap.get(ancestorPath);
if (config) {
for (const [key, value] of Object.entries(config)) {
if (
(key === 'theme' && config.themeOverride === false) ||
(key === 'fontFamily' && config.fontFamilyOverride === false) ||
(key === 'fontSize' && config.fontSizeOverride === false) ||
(key === 'fontWeight' && config.fontWeightOverride === false)
) {
continue;
}
if (key !== 'path' && value !== undefined) {
merged[key] = value;
}
}
if (config.themeOverride === false) {
delete merged.themeOverride;
}
if (config.fontFamilyOverride === false) {
delete merged.fontFamilyOverride;
}
if (config.fontSizeOverride === false) {
delete merged.fontSizeOverride;
}
if (config.fontWeightOverride === false) {
delete merged.fontWeightOverride;
}
}
}
return merged as Partial<GroupConfig>;
@@ -50,3 +70,11 @@ export function applyGroupDefaults(host: Host, groupDefaults: Partial<GroupConfi
}
return effective;
}
export function resolveGroupTerminalThemeId(
groupDefaults: Partial<GroupConfig> | undefined,
fallbackThemeId: string,
): string {
if (!groupDefaults) return fallbackThemeId;
return groupDefaults.theme || fallbackThemeId;
}

51
domain/host.test.ts Normal file
View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "./models.ts";
import { upsertHostById } from "./host.ts";
const makeHost = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Primary Host",
hostname: "127.0.0.1",
port: 22,
username: "root",
authType: "password",
createdAt: 1,
protocol: "ssh",
...overrides,
});
test("upsertHostById updates an existing host in place", () => {
const existing = makeHost();
const updated = makeHost({ label: "Updated Host" });
assert.deepEqual(upsertHostById([existing], updated), [updated]);
});
test("upsertHostById appends a duplicated host with a fresh id", () => {
const existing = makeHost({
id: "serial-original",
label: "Serial Config",
protocol: "serial",
hostname: "/dev/ttyUSB0",
port: 115200,
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 115200,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none",
localEcho: false,
lineMode: false,
},
});
const duplicate = makeHost({
...existing,
id: "serial-duplicate",
label: "Serial Config (copy)",
});
assert.deepEqual(upsertHostById([existing], duplicate), [existing, duplicate]);
});

View File

@@ -17,6 +17,26 @@ export const LINUX_DISTRO_OPTIONS = [
'kali',
] as const;
/**
* Known network-device vendor IDs that Netcatty can detect from the SSH
* server identification string. When a host is classified as one of these,
* features that assume a POSIX shell (e.g. the periodic server stats poll)
* are disabled, because the stats command would either be rejected outright
* or generate one AAA session log per poll on the remote device.
*/
export const NETWORK_DEVICE_OPTIONS = [
'cisco',
'juniper',
'huawei',
'hpe',
'mikrotik',
'fortinet',
'paloalto',
'zyxel',
] as const;
export type NetworkDeviceVendor = typeof NETWORK_DEVICE_OPTIONS[number];
export const normalizeDistroId = (value?: string) => {
const v = (value || '').toLowerCase().trim();
if (!v) return '';
@@ -33,10 +53,87 @@ export const normalizeDistroId = (value?: string) => {
if (v.includes('almalinux')) return 'almalinux';
if (v.includes('oracle')) return 'oracle';
if (v.includes('kali')) return 'kali';
// Network device vendor IDs may arrive here after detection — preserve them.
if ((NETWORK_DEVICE_OPTIONS as readonly string[]).includes(v)) return v;
if (v === 'linux' || v.includes('linux')) return 'linux';
return '';
};
/**
* Parse the SSH server identification string (the `software` portion of
* `SSH-<protocol>-<software>\r\n`, exposed by ssh2 as `conn._remoteVer`)
* and return a normalized network-device vendor ID, or '' if the ident
* does not match a known vendor.
*
* Matching patterns are sourced from the Nmap nmap-service-probes
* database (match lines beginning with `match ssh m|^SSH-`), cross-
* referenced with the ssh-audit project's software.py and vendor docs.
* Only rules whose pattern can be reproduced from those sources are
* included here.
*
* Empty string means "use the fallback linux/macos detection path" —
* that is what happens for OpenSSH, Dropbear, JUNOS, Cisco NX-OS, and
* Arista EOS, all of which either are POSIX systems or present as
* plain `OpenSSH_*` with no distinct vendor marker.
*/
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
const s = (softwareVersion || '').trim();
if (!s) return '';
// Cisco family — IOS, IOS XA, Wireless LAN Controller
if (/^Cisco[-_]/i.test(s)) return 'cisco';
if (/^CiscoIOS_/i.test(s)) return 'cisco';
if (/^CISCO_WLC\b/.test(s)) return 'cisco';
// Note: `IPSSH-*` is used by both Cisco and 3Com devices (per Nmap
// `match ssh m|^SSH-([\d.]+)-IPSSH-([\d.]+)\|`), so we cannot map it
// to a specific vendor icon from the banner alone. Users who want a
// custom icon for such devices can set one via the Host Details
// manual distro override. The stats-polling gate is still handled
// correctly via `host.deviceType === 'network'`.
// Juniper NetScreen firewall (JUNOS itself uses OpenSSH and is caught
// by the fallback failure-counter path in useServerStats).
if (/^NetScreen\b/.test(s)) return 'juniper';
// Huawei VRP and related products
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
if (/^VRP-/i.test(s)) return 'huawei';
// HPE / H3C — Comware switches, Integrated Lights-Out (iLO), legacy 3Com
if (/^Comware-/i.test(s)) return 'hpe';
if (/^3Com\s*OS/i.test(s)) return 'hpe';
if (/^mpSSH_/i.test(s)) return 'hpe';
// MikroTik RouterOS
if (/^ROSSSH\b/.test(s)) return 'mikrotik';
// Fortinet FortiOS / FortiGate
if (/^FortiSSH_/i.test(s)) return 'fortinet';
// Palo Alto Networks PAN-OS
if (/^PaloAltoNetworks[_-]/i.test(s)) return 'paloalto';
// ZyXEL ZyWALL
if (/^Zyxel\s*SSH/i.test(s)) return 'zyxel';
return '';
};
/**
* Classify a distro/vendor ID into a high-level device class. Features that
* assume a POSIX shell (periodic stats polling, /etc/os-release probing, etc.)
* should only run when this returns `linux-like`.
*/
export type DeviceClass = 'linux-like' | 'network-device' | 'other';
export const classifyDistroId = (distroId?: string): DeviceClass => {
const v = (distroId || '').toLowerCase().trim();
if (!v) return 'other';
if ((NETWORK_DEVICE_OPTIONS as readonly string[]).includes(v)) return 'network-device';
if ((LINUX_DISTRO_OPTIONS as readonly string[]).includes(v)) return 'linux-like';
return 'other';
};
export const getEffectiveHostDistro = (
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
) => {
@@ -56,6 +153,13 @@ export const formatHostPort = (hostname: string, port?: number | null): string =
return `${display}:${port}`;
};
export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
const hostExists = hosts.some((entry) => entry.id === host.id);
return hostExists
? hosts.map((entry) => (entry.id === host.id ? host : entry))
: [...hosts, host];
};
export const sanitizeHost = (host: Host): Host => {
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);

Some files were not shown because too many files have changed in this diff Show More