mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-18 14:52:04 +00:00
feat(desktop): button tooltip keybind hints + keybinds settings tab + unified worktree dialog (#65204)
* feat(desktop): add useKeybindHint hook and TipKeybindLabel Add a shared hook that reads the current keybind combo for an action id from the $bindings store (rebindable) or KEYBIND_READONLY (fixed), returning a formatted string or null when unbound. Add TipKeybindLabel — a convenience component that auto-reads both its label (from i18n) and keybind combo from the action registry. Pass only actionId for the common case; pass text to override when the tooltip is context-dependent. * fix(desktop): replace native title= on buttons with themed Tip Migrate all <button>/<Button> elements using the native HTML title= attribute to the instant, themed <Tip> component. Native tooltips are unstyled, delayed (~500ms OS default), and visually inconsistent with the app's instant themed tooltips. Also adds <Tip> wrappers to icon-only buttons that were missing tooltips entirely (dialog close, search clear, overlay close, master-detail pane controls, keybind panel rebind/reset buttons). Adds an enforcement test (no-native-title.test.ts) that scans all .tsx files for <button>/<Button> with title= and fails if any are found. Updates DESIGN.md with the icon-only button tooltip rule and keybind hint guidance. * feat(desktop): wire keybind hints into button tooltips Add actionId to TitlebarTool, StatusbarItem, and SidebarNavItem so their tooltips show the current keybind combo via TipKeybindLabel. Fix the hardcoded NEW_SESSION_KBD in the sidebar to read from $bindings so it stays live on rebind. Wired surfaces: - Titlebar: sidebar toggle, flip panes, keybinds, settings - Statusbar: terminal toggle (view.showTerminal) - Status stack: open agents button (nav.agents) - Sidebar nav: new session, skills, messaging, artifacts * feat(desktop): move keybind panel to settings tab with search filter Move the keyboard shortcuts panel from a Radix Dialog into a proper Settings tab (/settings?tab=keybinds). The ⌘/ shortcut and titlebar keyboard button now navigate to this settings tab instead of toggling a dialog. Adds a search filter to filter shortcuts by label. - New: src/app/settings/keybind-settings.tsx (extracted from keybind-panel.tsx) - Delete: src/app/shell/keybind-panel.tsx (dialog wrapper removed) - Remove: $keybindPanelOpen atom and toggle/open/close functions - Add: IconKeyboard to lib/icons.ts - i18n: keybinds.search + settings.nav.keybinds (en, zh, zh-hant, ja) * refactor(desktop): unify worktree dialog into shared WorktreeDialog Extract the worktree creation dialog from StartWorkButton (sidebar) into a shared WorktreeDialog component. Both the sidebar's StartWorkButton and the composer's CodingStatusRow now use the same dialog, eliminating the duplicated UI. The shared dialog keeps the sidebar version's full feature set: - BaseBranchPicker (filterable base branch combobox) - Convert mode (check out an existing branch into a worktree) - Sanitized branch name input The coding row passes repoPath (from cwd) and onOpenWorktree (which carries the composer draft to the new session) so the unified dialog works everywhere there's a repo, not just inside an entered project.
This commit is contained in:
parent
f1315ae91e
commit
f08b1f3445
47 changed files with 1091 additions and 946 deletions
|
|
@ -117,6 +117,22 @@ that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
|
|||
(status-stack/table-footers), and the icon family `icon` / `icon-xs` /
|
||||
`icon-sm` / `icon-lg` / `icon-titlebar`.
|
||||
|
||||
**Icon-only buttons must have a tooltip.** Every button with an `icon*` size
|
||||
carries no visible text label, so it must be wrapped in `<Tip label={...}>`
|
||||
with a descriptive label (matching the button's `aria-label`). Never use the
|
||||
native HTML `title=` attribute — it's unstyled, delayed (~500ms OS default),
|
||||
and visually inconsistent with the instant themed `Tip`. An enforcement test
|
||||
(`src/components/ui/__tests__/no-native-title.test.ts`) fails on any `<button>`
|
||||
or `<Button>` that still carries `title=`.
|
||||
|
||||
**Keybind hints in tooltips.** When a button corresponds to a rebindable
|
||||
hotkey, use `<TipKeybindLabel actionId="..." />` as the `Tip` label — it
|
||||
auto-reads both the i18n label and the current keybind combo from the store,
|
||||
so the hint stays live when the user rebinds. Pass `text={...}` only when the
|
||||
tooltip is context-dependent (e.g. "Show" / "Hide" based on state). Never
|
||||
hardcode combos in components — always read from the `$bindings` store via
|
||||
`useKeybindHint` or `TipKeybindLabel`.
|
||||
|
||||
Notes:
|
||||
- Text buttons are square (no radius) and sized by padding + line-height (no
|
||||
fixed heights). Only icon buttons carry the shared 4px radius.
|
||||
|
|
@ -278,6 +294,9 @@ The detailed state contract lives in the scoped
|
|||
- [ ] Tokens (`--ui-*`, `shadow-nous`, `--stroke-nous`) — zero raw colors /
|
||||
one-off shadows?
|
||||
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
|
||||
- [ ] Icon-only buttons wrapped in `<Tip>` with a descriptive label?
|
||||
- [ ] No native `title=` on buttons — use `<Tip>` instead?
|
||||
- [ ] Keybind hints read from the store via `useKeybindHint` / `TipKeybindLabel`?
|
||||
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
|
||||
- [ ] Flat — no card-in-card, no gratuitous row dividers?
|
||||
- [ ] No automatic navigation, focus steal, or pane opening from background
|
||||
|
|
|
|||
|
|
@ -203,7 +203,6 @@ function ConversationPill({
|
|||
triggerHaptic('submit')
|
||||
onStopTurn()
|
||||
}}
|
||||
title={c.stopListening}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
|
|
@ -219,7 +218,6 @@ function ConversationPill({
|
|||
triggerHaptic('close')
|
||||
onEnd()
|
||||
}}
|
||||
title={c.endConversation}
|
||||
type="button"
|
||||
>
|
||||
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
|
||||
|
|
|
|||
|
|
@ -945,6 +945,7 @@ export function ChatBar({
|
|||
onOpen={toggleReview}
|
||||
onOpenWorktree={openInWorktree}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
repoPath={cwd}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,18 +1,10 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { WorktreeDialog } from '@/app/chat/sidebar/projects/worktree-dialog'
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { DiffCount } from '@/components/ui/diff-count'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -22,10 +14,8 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SanitizedInput } from '@/components/ui/sanitized-input'
|
||||
import type { HermesGitBranch } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { gitRef } from '@/lib/sanitize'
|
||||
import { $repoStatus, $repoWorktrees } from '@/store/coding-status'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $newWorktreeRequest } from '@/store/projects'
|
||||
|
|
@ -33,20 +23,6 @@ import { $newWorktreeRequest } from '@/store/projects'
|
|||
// Tiny uppercase section header, matching the composer "+" menu's labels.
|
||||
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
|
||||
|
||||
interface BranchActionCopy {
|
||||
branchCreateWorktree: string
|
||||
branchOpenExisting: string
|
||||
branchSwitchHome: string
|
||||
}
|
||||
|
||||
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
|
||||
if (branch.checkedOut) {
|
||||
return copy.branchOpenExisting
|
||||
}
|
||||
|
||||
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
|
||||
}
|
||||
|
||||
interface CodingStatusRowProps {
|
||||
/** Branch the current draft off into a fresh worktree + session, based on
|
||||
* `base` (a branch name; omitted = current HEAD). The composer owns the
|
||||
|
|
@ -64,6 +40,8 @@ interface CodingStatusRowProps {
|
|||
onOpenWorktree?: (path: string) => void
|
||||
/** Switch the current repo checkout to another branch. */
|
||||
onSwitchBranch?: (branch: string) => Promise<void>
|
||||
/** Repo root path for the worktree dialog. */
|
||||
repoPath?: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -79,7 +57,8 @@ export const CodingStatusRow = memo(function CodingStatusRow({
|
|||
onListBranches,
|
||||
onOpen,
|
||||
onOpenWorktree,
|
||||
onSwitchBranch
|
||||
onSwitchBranch,
|
||||
repoPath
|
||||
}: CodingStatusRowProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack.coding
|
||||
|
|
@ -87,113 +66,11 @@ export const CodingStatusRow = memo(function CodingStatusRow({
|
|||
const status = useStore($repoStatus)
|
||||
const worktrees = useStore($repoWorktrees)
|
||||
|
||||
const [branchOpen, setBranchOpen] = useState(false)
|
||||
const [branchName, setBranchName] = useState('')
|
||||
const [branchBase, setBranchBase] = useState<string | undefined>(undefined)
|
||||
const [branchPending, setBranchPending] = useState(false)
|
||||
const [convertMode, setConvertMode] = useState(false)
|
||||
const [branches, setBranches] = useState<HermesGitBranch[]>([])
|
||||
const [branchesLoading, setBranchesLoading] = useState(false)
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!onListBranches) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchesLoading(true)
|
||||
|
||||
try {
|
||||
setBranches(await onListBranches())
|
||||
} catch {
|
||||
setBranches([])
|
||||
} finally {
|
||||
setBranchesLoading(false)
|
||||
}
|
||||
}, [onListBranches])
|
||||
|
||||
// Open the name dialog for a chosen base. Deferred so the dropdown finishes
|
||||
// closing before the dialog grabs focus (Radix focus-trap handoff races
|
||||
// otherwise).
|
||||
const startBranch = (base: string | undefined) => {
|
||||
setBranchBase(base)
|
||||
setBranchName('')
|
||||
setConvertMode(false)
|
||||
setTimeout(() => setBranchOpen(true), 0)
|
||||
}
|
||||
|
||||
const startConvert = () => {
|
||||
setBranchBase(undefined)
|
||||
setBranchName('')
|
||||
setConvertMode(true)
|
||||
void loadBranches()
|
||||
setTimeout(() => setBranchOpen(true), 0)
|
||||
}
|
||||
|
||||
const enterConvert = () => {
|
||||
setConvertMode(true)
|
||||
void loadBranches()
|
||||
}
|
||||
|
||||
const convertBranch = async (branch: HermesGitBranch) => {
|
||||
if (branchPending || !branch || !onConvertBranch) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchPending(true)
|
||||
|
||||
try {
|
||||
await onConvertBranch(branch.name, branch.worktreePath, branch.isDefault)
|
||||
setBranchOpen(false)
|
||||
} catch (err) {
|
||||
notifyError(err, p.startWorkFailed)
|
||||
} finally {
|
||||
setBranchPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off
|
||||
// current HEAD. The rail only renders inside a repo, so the hotkey naturally
|
||||
// no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on
|
||||
// mount or unrelated re-renders.
|
||||
const worktreeReq = useStore($newWorktreeRequest)
|
||||
const lastWorktreeReqRef = useRef(worktreeReq)
|
||||
|
||||
useEffect(() => {
|
||||
if (worktreeReq === lastWorktreeReqRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastWorktreeReqRef.current = worktreeReq
|
||||
|
||||
if (!onBranchOff) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchBase(undefined)
|
||||
setBranchName('')
|
||||
setConvertMode(false)
|
||||
setBranchOpen(true)
|
||||
}, [onBranchOff, worktreeReq])
|
||||
|
||||
const submitBranch = async () => {
|
||||
const branch = branchName.trim()
|
||||
|
||||
if (branchPending || !branch || !onBranchOff) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchPending(true)
|
||||
|
||||
try {
|
||||
await onBranchOff(branch, branchBase)
|
||||
setBranchOpen(false)
|
||||
setBranchName('')
|
||||
} catch (err) {
|
||||
notifyError(err, p.startWorkFailed)
|
||||
} finally {
|
||||
setBranchPending(false)
|
||||
}
|
||||
}
|
||||
// Shared worktree dialog — replaces the old inline dialog. Opened by the
|
||||
// dropdown menu's "branch off" items and the global ⌘⇧B hotkey.
|
||||
const [worktreeOpen, setWorktreeOpen] = useState(false)
|
||||
const [worktreeBase, setWorktreeBase] = useState<string | undefined>(undefined)
|
||||
const resolvedRepoPath = repoPath?.trim() || undefined
|
||||
|
||||
const switchToBranch = async (branch: string) => {
|
||||
if (!onSwitchBranch) {
|
||||
|
|
@ -207,6 +84,34 @@ export const CodingStatusRow = memo(function CodingStatusRow({
|
|||
}
|
||||
}
|
||||
|
||||
// Global ⌘⇧B (workspace.newWorktree): open the shared worktree dialog. The
|
||||
// coding row only renders inside a repo, so the hotkey naturally no-ops
|
||||
// elsewhere. Guarded by a token ref so it fires on the keypress, not on
|
||||
// mount or unrelated re-renders.
|
||||
const worktreeReq = useStore($newWorktreeRequest)
|
||||
const lastWorktreeReqRef = useRef(worktreeReq)
|
||||
|
||||
useEffect(() => {
|
||||
if (worktreeReq === lastWorktreeReqRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastWorktreeReqRef.current = worktreeReq
|
||||
|
||||
if (!resolvedRepoPath || !onOpenWorktree) {
|
||||
return
|
||||
}
|
||||
|
||||
setWorktreeBase(undefined)
|
||||
setWorktreeOpen(true)
|
||||
}, [onOpenWorktree, resolvedRepoPath, worktreeReq])
|
||||
|
||||
// Open the worktree dialog from the dropdown menu with a pre-selected base.
|
||||
const startBranch = (base: string | undefined) => {
|
||||
setWorktreeBase(base)
|
||||
setTimeout(() => setWorktreeOpen(true), 0)
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -320,9 +225,10 @@ export const CodingStatusRow = memo(function CodingStatusRow({
|
|||
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
|
||||
<span className="truncate">{p.startWork}</span>
|
||||
</DropdownMenuItem>
|
||||
{/* Check an EXISTING branch out into a worktree (no new branch). */}
|
||||
{/* Create a fresh worktree off the current HEAD (the generic
|
||||
"spin up a worktree here", mirroring the sidebar's + button). */}
|
||||
{onConvertBranch && (
|
||||
<DropdownMenuItem onSelect={() => startConvert()}>
|
||||
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
|
||||
<span className="truncate">{p.convertBranch}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
|
@ -363,107 +269,15 @@ export const CodingStatusRow = memo(function CodingStatusRow({
|
|||
) : null}
|
||||
</StatusRow>
|
||||
|
||||
<Dialog onOpenChange={open => !branchPending && setBranchOpen(open)} open={branchOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}
|
||||
{!convertMode && branchBase && (
|
||||
<span className="mt-1 block text-(--ui-text-secondary)">{s.branchOffFrom(branchBase)}</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{convertMode ? (
|
||||
<Command
|
||||
className="rounded-md border border-(--ui-stroke-tertiary)"
|
||||
// The branch name is the authoritative key; filter on it directly.
|
||||
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
|
||||
>
|
||||
<CommandInput autoFocus disabled={branchPending} placeholder={p.convertBranchPlaceholder} />
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{branches.map(branch => (
|
||||
<CommandItem
|
||||
disabled={branchPending}
|
||||
key={branch.name}
|
||||
onSelect={() => void convertBranch(branch)}
|
||||
value={branch.name}
|
||||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
|
||||
{branchActionLabel(branch, p)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
) : (
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={branchPending}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submitBranch()
|
||||
} else if (event.key === 'Escape') {
|
||||
setBranchOpen(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={setBranchName}
|
||||
placeholder={p.branchPlaceholder}
|
||||
sanitize={gitRef}
|
||||
value={branchName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{convertMode ? (
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={branchPending}
|
||||
onClick={() => setConvertMode(false)}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<DialogFooter className="sm:justify-between">
|
||||
{onConvertBranch ? (
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={branchPending}
|
||||
onClick={enterConvert}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{p.convertBranchInstead}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={branchPending} onClick={() => setBranchOpen(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={branchPending || !branchName.trim()}
|
||||
onClick={() => void submitBranch()}
|
||||
type="button"
|
||||
>
|
||||
{p.startWork}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{resolvedRepoPath && onOpenWorktree && (
|
||||
<WorktreeDialog
|
||||
initialBase={worktreeBase}
|
||||
onOpenChange={setWorktreeOpen}
|
||||
onStarted={onOpenWorktree}
|
||||
open={worktreeOpen}
|
||||
repoPath={resolvedRepoPath}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { composerDockCard } from '@/components/chat/composer-dock'
|
|||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
|
|
@ -129,15 +130,17 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
<StatusSection
|
||||
accessory={
|
||||
group.type === 'subagent' ? (
|
||||
<Button
|
||||
className="text-muted-foreground/75 hover:text-foreground/90"
|
||||
onClick={openAgents}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.statusStack.agents}
|
||||
</Button>
|
||||
<Tip label={<TipKeybindLabel actionId="nav.agents" text={t.statusStack.agents} />}>
|
||||
<Button
|
||||
className="text-muted-foreground/75 hover:text-foreground/90"
|
||||
onClick={openAgents}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.statusStack.agents}
|
||||
</Button>
|
||||
</Tip>
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={group.type !== 'todo'}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { CodeEditor } from '@/components/chat/code-editor'
|
|||
import { FileDiffPanel } from '@/components/chat/diff-lines'
|
||||
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import {
|
||||
desktopFileDiff,
|
||||
|
|
@ -947,15 +948,16 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
onSelect={setUserMode}
|
||||
trailing={
|
||||
canEdit ? (
|
||||
<button
|
||||
className="flex items-center gap-1 text-[0.625rem] font-bold text-muted-foreground underline-offset-4 transition-colors hover:text-foreground"
|
||||
onClick={beginEdit}
|
||||
title={`${t.preview.edit} (e)`}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
{t.preview.edit}
|
||||
</button>
|
||||
<Tip label={`${t.preview.edit} (e)`}>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[0.625rem] font-bold text-muted-foreground underline-offset-4 transition-colors hover:text-foreground"
|
||||
onClick={beginEdit}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
{t.preview.edit}
|
||||
</button>
|
||||
</Tip>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -201,12 +201,12 @@ function CronJobSidebarRow({
|
|||
so the cron dots line up with the sessions above; the caret sits next
|
||||
to the label (matching the other sidebar disclosures) and the whole
|
||||
label area toggles the run peek. */}
|
||||
<Tip label={label}>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? c.hideRuns : c.showRuns}
|
||||
className="flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
onClick={onTogglePeek}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid w-3.5 shrink-0 place-items-center">
|
||||
|
|
@ -230,6 +230,7 @@ function CronJobSidebarRow({
|
|||
open={expanded}
|
||||
/>
|
||||
</button>
|
||||
</Tip>
|
||||
{/* Trailing cluster: countdown by default, quick actions on hover. */}
|
||||
<div className="flex items-center gap-0.5 justify-self-end pr-1">
|
||||
<span className="text-[0.6875rem] text-(--ui-text-tertiary) tabular-nums group-hover/cron:hidden">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
import { TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
|
@ -30,6 +31,7 @@ import { sessionMatchesSearch } from '@/lib/session-search'
|
|||
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $cronJobs } from '@/store/cron'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import {
|
||||
$dismissedAutoProjectIds,
|
||||
$panesFlipped,
|
||||
|
|
@ -138,23 +140,35 @@ import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu'
|
|||
const NON_SESSION_INITIAL_ROWS = 3
|
||||
const NON_SESSION_LOAD_STEP = 10
|
||||
|
||||
const NEW_SESSION_KBD = comboTokens('mod+n')
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
label: '',
|
||||
icon: props => <Codicon name="robot" {...props} />,
|
||||
action: 'new-session'
|
||||
action: 'new-session',
|
||||
keybindActionId: 'session.new'
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
label: '',
|
||||
icon: props => <Codicon name="symbol-misc" {...props} />,
|
||||
route: SKILLS_ROUTE
|
||||
route: SKILLS_ROUTE,
|
||||
keybindActionId: 'nav.skills'
|
||||
},
|
||||
{ id: 'messaging', label: '', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: '', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||
{
|
||||
id: 'messaging',
|
||||
label: '',
|
||||
icon: props => <Codicon name="comment" {...props} />,
|
||||
route: MESSAGING_ROUTE,
|
||||
keybindActionId: 'nav.messaging'
|
||||
},
|
||||
{
|
||||
id: 'artifacts',
|
||||
label: '',
|
||||
icon: props => <Codicon name="files" {...props} />,
|
||||
route: ARTIFACTS_ROUTE,
|
||||
keybindActionId: 'nav.artifacts'
|
||||
}
|
||||
]
|
||||
|
||||
// Two modes via the `compact` height variant (styles.css):
|
||||
|
|
@ -313,6 +327,8 @@ export function ChatSidebar({
|
|||
const currentCwd = useStore($currentCwd)
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const dismissedAutoProjects = useStore($dismissedAutoProjectIds)
|
||||
const newSessionCombo = useStore($bindings)['session.new']?.[0]
|
||||
const newSessionKbd = newSessionCombo ? comboTokens(newSessionCombo) : []
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
const [searchPending, setSearchPending] = useState(false)
|
||||
|
|
@ -1123,7 +1139,15 @@ export function ChatSidebar({
|
|||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
tooltip={
|
||||
item.keybindActionId
|
||||
? {
|
||||
children: (
|
||||
<TipKeybindLabel actionId={item.keybindActionId} text={s.nav[item.id] ?? item.label} />
|
||||
)
|
||||
}
|
||||
: (s.nav[item.id] ?? item.label)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
|
|
@ -1131,7 +1155,7 @@ export function ChatSidebar({
|
|||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
keys={newSessionKbd}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,7 @@
|
|||
import type * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -20,17 +10,13 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SanitizedInput } from '@/components/ui/sanitized-input'
|
||||
import type { HermesGitBranch } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { gitRef } from '@/lib/sanitize'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
import { copyPath, revealPath } from '@/store/projects'
|
||||
|
||||
import { SidebarCount, SidebarRowLead } from '../chrome'
|
||||
|
||||
import { BaseBranchPicker } from './base-branch-picker'
|
||||
import { WorktreeDialog } from './worktree-dialog'
|
||||
|
||||
// Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`),
|
||||
// so plain end-truncation (`truncate`) hides exactly the suffix that tells two
|
||||
|
|
@ -50,20 +36,6 @@ function LaneLabel({ label, title }: { label: string; title?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
interface BranchActionCopy {
|
||||
branchCreateWorktree: string
|
||||
branchOpenExisting: string
|
||||
branchSwitchHome: string
|
||||
}
|
||||
|
||||
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
|
||||
if (branch.checkedOut) {
|
||||
return copy.branchOpenExisting
|
||||
}
|
||||
|
||||
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
|
||||
}
|
||||
|
||||
// "+" affordance shared by repo and worktree headers — reveals on header hover.
|
||||
export function WorkspaceAddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
|
|
@ -148,203 +120,20 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov
|
|||
// pick any local or remote-tracking branch via a filterable combobox.
|
||||
export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const p = s.projects
|
||||
const p = t.sidebar.projects
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [convertMode, setConvertMode] = useState(false)
|
||||
const [branches, setBranches] = useState<HermesGitBranch[]>([])
|
||||
const [branchesLoading, setBranchesLoading] = useState(false)
|
||||
const [selectedBase, setSelectedBase] = useState('')
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!repoPath) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchesLoading(true)
|
||||
|
||||
try {
|
||||
setBranches(await listRepoBranches(repoPath))
|
||||
} catch {
|
||||
setBranches([])
|
||||
} finally {
|
||||
setBranchesLoading(false)
|
||||
}
|
||||
}, [repoPath])
|
||||
|
||||
const submit = async () => {
|
||||
const branch = name.trim()
|
||||
|
||||
if (pending || !repoPath || !branch) {
|
||||
return
|
||||
}
|
||||
|
||||
setPending(true)
|
||||
|
||||
try {
|
||||
// Pass the typed value as both the dir slug source and the branch, so the
|
||||
// branch is exactly what the user named (the dir is slugified git-side).
|
||||
const result = await startWorkInRepo(repoPath, { base: selectedBase || undefined, branch, name: branch })
|
||||
|
||||
if (result) {
|
||||
onStarted(result.path)
|
||||
setOpen(false)
|
||||
setName('')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, p.startWorkFailed)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const convert = async (branch: HermesGitBranch) => {
|
||||
if (pending || !repoPath || !branch) {
|
||||
return
|
||||
}
|
||||
|
||||
setPending(true)
|
||||
|
||||
try {
|
||||
let result: null | { branch: string; path: string }
|
||||
|
||||
if (branch.worktreePath) {
|
||||
result = { branch: branch.name, path: branch.worktreePath }
|
||||
} else if (branch.isDefault) {
|
||||
await switchBranchInRepo(repoPath, branch.name)
|
||||
result = { branch: branch.name, path: repoPath }
|
||||
} else {
|
||||
result = await startWorkInRepo(repoPath, { existingBranch: branch.name })
|
||||
}
|
||||
|
||||
if (result) {
|
||||
onStarted(result.path)
|
||||
setOpen(false)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, p.startWorkFailed)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const enterConvert = () => {
|
||||
setConvertMode(true)
|
||||
void loadBranches()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-label={p.startWork}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => {
|
||||
setConvertMode(false)
|
||||
setName('')
|
||||
setSelectedBase('')
|
||||
setOpen(true)
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="git-branch" size="0.75rem" />
|
||||
</button>
|
||||
<Dialog onOpenChange={next => !pending && setOpen(next)} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
|
||||
<DialogDescription>{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{convertMode ? (
|
||||
<Command
|
||||
className="rounded-md border border-(--ui-stroke-tertiary)"
|
||||
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
|
||||
>
|
||||
<CommandInput autoFocus disabled={pending} placeholder={p.convertBranchPlaceholder} />
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{branches.map(branch => (
|
||||
<CommandItem
|
||||
disabled={pending}
|
||||
key={branch.name}
|
||||
onSelect={() => void convert(branch)}
|
||||
value={branch.name}
|
||||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
|
||||
{branchActionLabel(branch, p)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
) : (
|
||||
<>
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={pending}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={setName}
|
||||
placeholder={p.branchPlaceholder}
|
||||
sanitize={gitRef}
|
||||
value={name}
|
||||
/>
|
||||
<BaseBranchPicker
|
||||
disabled={pending}
|
||||
onValueChange={setSelectedBase}
|
||||
repoPath={repoPath}
|
||||
value={selectedBase}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{convertMode ? (
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={pending}
|
||||
onClick={() => setConvertMode(false)}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={pending}
|
||||
onClick={enterConvert}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{p.convertBranchInstead}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={pending} onClick={() => setOpen(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={pending || !name.trim()} onClick={() => void submit()} type="button">
|
||||
{p.startWork}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<WorktreeDialog onOpenChange={setOpen} onStarted={onStarted} open={open} repoPath={repoPath} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
254
apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx
Normal file
254
apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { SanitizedInput } from '@/components/ui/sanitized-input'
|
||||
import type { HermesGitBranch } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { gitRef } from '@/lib/sanitize'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { listRepoBranches, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
|
||||
import { BaseBranchPicker } from './base-branch-picker'
|
||||
|
||||
interface BranchActionCopy {
|
||||
branchCreateWorktree: string
|
||||
branchOpenExisting: string
|
||||
branchSwitchHome: string
|
||||
}
|
||||
|
||||
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
|
||||
if (branch.checkedOut) {
|
||||
return copy.branchOpenExisting
|
||||
}
|
||||
|
||||
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
|
||||
}
|
||||
|
||||
export interface WorktreeDialogProps {
|
||||
/** Repo root path for git operations. */
|
||||
repoPath: string
|
||||
/** Called with the new/converted worktree path on success. */
|
||||
onStarted: (path: string) => void
|
||||
/** Controlled open state. */
|
||||
open: boolean
|
||||
/** Called when the user requests the dialog to close (cancel, Esc, backdrop). */
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Pre-select a base branch when opening (from "branch off from X" menus). */
|
||||
initialBase?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "new worktree" dialog — used by the sidebar's StartWorkButton and the
|
||||
* composer's ⌘⇧B shortcut. Features:
|
||||
* - Branch name input (sanitized as a git ref)
|
||||
* - Base branch picker (filterable combobox — the sidebar's BaseBranchPicker)
|
||||
* - Convert mode: check out an existing branch into a worktree
|
||||
*
|
||||
* The caller owns the open state so both the sidebar button and the global
|
||||
* hotkey can trigger the same dialog instance.
|
||||
*/
|
||||
export function WorktreeDialog({ repoPath, onStarted, open, onOpenChange, initialBase }: WorktreeDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const [name, setName] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [convertMode, setConvertMode] = useState(false)
|
||||
const [branches, setBranches] = useState<HermesGitBranch[]>([])
|
||||
const [branchesLoading, setBranchesLoading] = useState(false)
|
||||
const [selectedBase, setSelectedBase] = useState('')
|
||||
|
||||
// Reset to a fresh state each time the dialog opens, applying any pre-selected
|
||||
// base branch from the caller (e.g. "branch off from main" in the coding row's
|
||||
// dropdown menu). When `initialBase` changes while open (shouldn't happen in
|
||||
// practice), the effect re-syncs.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('')
|
||||
setConvertMode(false)
|
||||
setSelectedBase(initialBase ?? '')
|
||||
}
|
||||
}, [open, initialBase])
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!repoPath) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchesLoading(true)
|
||||
|
||||
try {
|
||||
setBranches(await listRepoBranches(repoPath))
|
||||
} catch {
|
||||
setBranches([])
|
||||
} finally {
|
||||
setBranchesLoading(false)
|
||||
}
|
||||
}, [repoPath])
|
||||
|
||||
const submit = async () => {
|
||||
const branch = name.trim()
|
||||
|
||||
if (pending || !repoPath || !branch) {
|
||||
return
|
||||
}
|
||||
|
||||
setPending(true)
|
||||
|
||||
try {
|
||||
const result = await startWorkInRepo(repoPath, { base: selectedBase || undefined, branch, name: branch })
|
||||
|
||||
if (result) {
|
||||
onStarted(result.path)
|
||||
onOpenChange(false)
|
||||
setName('')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, p.startWorkFailed)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const convert = async (branch: HermesGitBranch) => {
|
||||
if (pending || !repoPath || !branch) {
|
||||
return
|
||||
}
|
||||
|
||||
setPending(true)
|
||||
|
||||
try {
|
||||
let result: null | { branch: string; path: string }
|
||||
|
||||
if (branch.worktreePath) {
|
||||
result = { branch: branch.name, path: branch.worktreePath }
|
||||
} else if (branch.isDefault) {
|
||||
await switchBranchInRepo(repoPath, branch.name)
|
||||
result = { branch: branch.name, path: repoPath }
|
||||
} else {
|
||||
result = await startWorkInRepo(repoPath, { existingBranch: branch.name })
|
||||
}
|
||||
|
||||
if (result) {
|
||||
onStarted(result.path)
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, p.startWorkFailed)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const enterConvert = () => {
|
||||
setConvertMode(true)
|
||||
void loadBranches()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={next => !pending && onOpenChange(next)} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
|
||||
<DialogDescription>{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{convertMode ? (
|
||||
<Command
|
||||
className="rounded-md border border-(--ui-stroke-tertiary)"
|
||||
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
|
||||
>
|
||||
<CommandInput autoFocus disabled={pending} placeholder={p.convertBranchPlaceholder} />
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{branches.map(branch => (
|
||||
<CommandItem
|
||||
disabled={pending}
|
||||
key={branch.name}
|
||||
onSelect={() => void convert(branch)}
|
||||
value={branch.name}
|
||||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
|
||||
{branchActionLabel(branch, p)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
) : (
|
||||
<>
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={pending}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={setName}
|
||||
placeholder={p.branchPlaceholder}
|
||||
sanitize={gitRef}
|
||||
value={name}
|
||||
/>
|
||||
<BaseBranchPicker
|
||||
disabled={pending}
|
||||
onValueChange={setSelectedBase}
|
||||
repoPath={repoPath}
|
||||
value={selectedBase}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{convertMode ? (
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={pending}
|
||||
onClick={() => setConvertMode(false)}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={pending}
|
||||
onClick={enterConvert}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{p.convertBranchInstead}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={pending} onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={pending || !name.trim()} onClick={() => void submit()} type="button">
|
||||
{p.startWork}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -130,7 +130,6 @@ export function SidebarSessionRow({
|
|||
aria-label={r.actionsFor(title)}
|
||||
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title={r.sessionActions}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.875rem" />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { ResponsiveTabs } from '@/components/ui/tab-dropdown'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
|
||||
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
|
@ -94,17 +95,18 @@ function RowIconButton({
|
|||
title: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
|
||||
onClick={onClick}
|
||||
size="icon-xs"
|
||||
title={title}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
<Tip label={title}>
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
|
||||
onClick={onClick}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
|||
import { LayoutDashboard } from '@/lib/icons'
|
||||
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
import { toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
|
|
@ -299,7 +298,7 @@ registry.registerMany([
|
|||
id: 'keybinds.panel',
|
||||
label: 'Keyboard shortcuts',
|
||||
keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'],
|
||||
run: toggleKeybindPanel
|
||||
run: () => window.dispatchEvent(new CustomEvent('hermes:open-keybinds'))
|
||||
} satisfies PaletteContribution
|
||||
}
|
||||
])
|
||||
|
|
|
|||
|
|
@ -88,7 +88,6 @@ import { useSessionListActions } from '../session/hooks/use-session-list-actions
|
|||
import { useSessionStateCache } from '../session/hooks/use-session-state-cache'
|
||||
import { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
|
||||
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
|
||||
import { KeybindPanel } from '../shell/keybind-panel'
|
||||
import { titlebarControlsPosition } from '../shell/titlebar'
|
||||
import { TitlebarControls } from '../shell/titlebar-controls'
|
||||
import { UpdatesOverlay } from '../updates-overlay'
|
||||
|
|
@ -244,6 +243,15 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
|
||||
const openProviderSettings = useCallback(() => navigate(`${SETTINGS_ROUTE}?tab=providers`), [navigate])
|
||||
|
||||
// Palette "Keyboard shortcuts" entry dispatches a custom event (contributions
|
||||
// don't have router access); listen and navigate to the settings keybinds tab.
|
||||
useEffect(() => {
|
||||
const onOpenKeybinds = () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`)
|
||||
window.addEventListener('hermes:open-keybinds', onOpenKeybinds)
|
||||
|
||||
return () => window.removeEventListener('hermes:open-keybinds', onOpenKeybinds)
|
||||
}, [navigate])
|
||||
|
||||
// Post-turn rehydrate from stored history (same behavior as DesktopController,
|
||||
// including finished-todos restoration).
|
||||
const hydrateFromStoredSession = useCallback(
|
||||
|
|
@ -944,9 +952,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* The full hotkey map (⌘/ and the titlebar keyboard button). */}
|
||||
<KeybindPanel />
|
||||
|
||||
{/* Toasts above everything. */}
|
||||
<NotificationStack />
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } fro
|
|||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||
import { $repoStatus } from '@/store/coding-status'
|
||||
import { toggleCommandPalette } from '@/store/command-palette'
|
||||
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
||||
import { $capture, $comboIndex, endCapture, setBinding } from '@/store/keybinds'
|
||||
import {
|
||||
requestSessionSearchFocus,
|
||||
setFileBrowserOpen,
|
||||
|
|
@ -120,7 +120,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
}
|
||||
|
||||
handlersRef.current = {
|
||||
'keybinds.openPanel': toggleKeybindPanel,
|
||||
'keybinds.openPanel': () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`),
|
||||
|
||||
'composer.focus': () => requestComposerFocus('main'),
|
||||
'composer.modelPicker': () => setModelPickerOpen(true),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Codicon } from '@/components/ui/codicon'
|
|||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { RowButton } from '@/components/ui/row-button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneHeightOverride, $paneState, setPaneHeightOverride } from '@/store/panes'
|
||||
|
|
@ -196,20 +197,24 @@ export function DetailPane({
|
|||
<span className="min-w-0 truncate text-xs font-medium text-foreground">{title}</span>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{actions}
|
||||
<Button
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? t.common.expand : t.common.collapse}
|
||||
className={ICON_BUTTON}
|
||||
onClick={() => setPaneHeightOverride(id, collapsed ? undefined : 0)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={collapsed ? 'chevron-up' : 'chevron-down'} size="0.8125rem" />
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button aria-label={t.common.close} className={ICON_BUTTON} onClick={onClose} size="icon" variant="ghost">
|
||||
<Codicon name="close" size="0.8125rem" />
|
||||
<Tip label={collapsed ? t.common.expand : t.common.collapse}>
|
||||
<Button
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? t.common.expand : t.common.collapse}
|
||||
className={ICON_BUTTON}
|
||||
onClick={() => setPaneHeightOverride(id, collapsed ? undefined : 0)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={collapsed ? 'chevron-up' : 'chevron-down'} size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
{onClose && (
|
||||
<Tip label={t.common.close}>
|
||||
<Button aria-label={t.common.close} className={ICON_BUTTON} onClick={onClose} size="icon" variant="ghost">
|
||||
<Codicon name="close" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -266,7 +271,6 @@ export function ListStripMenu({
|
|||
'data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground'
|
||||
)}
|
||||
size="icon"
|
||||
title={label}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.8125rem" />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
|||
import { ErrorBanner } from '@/components/ui/error-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import {
|
||||
getMessagingPlatforms,
|
||||
type MessagingEnvVarInfo,
|
||||
|
|
@ -609,22 +610,25 @@ function MessagingField({
|
|||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{field.url && (
|
||||
<Button asChild className="size-8 shrink-0" title={m.openDocs} variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Tip label={m.openDocs}>
|
||||
<Button asChild className="size-8 shrink-0" variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
{field.is_set && (
|
||||
<Button
|
||||
className="size-8 shrink-0"
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
title={m.clearField(field.key)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Tip label={m.clearField(field.key)}>
|
||||
<Button
|
||||
className="size-8 shrink-0"
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { type CSSProperties, type ReactNode, useEffect } from 'react'
|
|||
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
|
@ -91,15 +92,17 @@ export function OverlayView({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
aria-label={closeLabel}
|
||||
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] -translate-y-1/2 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
onClick={closeOverlay}
|
||||
size="icon-titlebar"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
</Button>
|
||||
<Tip label={closeLabel}>
|
||||
<Button
|
||||
aria-label={closeLabel}
|
||||
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] -translate-y-1/2 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
onClick={closeOverlay}
|
||||
size="icon-titlebar"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
{/* No top padding here: the split-layout columns own their own
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Codicon } from '@/components/ui/codicon'
|
|||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { RowButton } from '@/components/ui/row-button'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -217,15 +218,16 @@ export function PanelRowMenu({ items, label = 'Actions' }: { items: PanelMenuIte
|
|||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="size-5 rounded-[4px] bg-transparent text-(--ui-text-tertiary) opacity-0 transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 focus-visible:ring-0 group-hover/row:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100 [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title={label}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.875rem" />
|
||||
</Button>
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="size-5 rounded-[4px] bg-transparent text-(--ui-text-tertiary) opacity-0 transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 focus-visible:ring-0 group-hover/row:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100 [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40" sideOffset={6}>
|
||||
{items.map(item => (
|
||||
|
|
@ -353,16 +355,17 @@ export function PanelAddButton({
|
|||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="h-7 w-full shrink-0 justify-center text-muted-foreground/70 hover:bg-(--ui-row-hover-background) hover:text-foreground"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
title={label}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
</Button>
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="h-7 w-full shrink-0 justify-center text-muted-foreground/70 hover:bg-(--ui-row-hover-background) hover:text-foreground"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { TreeSkeleton } from '@/components/chat/skeletons'
|
|||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useDelayedTrue } from '@/hooks/use-delayed-true'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
|
|
@ -151,28 +152,30 @@ function FilesystemTab({
|
|||
<div className="flex min-w-0 flex-1">
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={r.refreshTree}
|
||||
className={HEADER_ACTION_LABEL_REVEAL}
|
||||
disabled={loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
title={r.refreshTree}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={r.collapseAll}
|
||||
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
|
||||
disabled={!canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
title={r.collapseAll}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
<Tip label={r.refreshTree}>
|
||||
<Button
|
||||
aria-label={r.refreshTree}
|
||||
className={HEADER_ACTION_LABEL_REVEAL}
|
||||
disabled={loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={r.collapseAll}>
|
||||
<Button
|
||||
aria-label={r.collapseAll}
|
||||
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
|
||||
disabled={!canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</RightSidebarSectionHeader>
|
||||
<FileTreeBody
|
||||
collapseNonce={collapseNonce}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ExternalLink, Eye, EyeOff, Trash2 } from '@/lib/icons'
|
||||
|
|
@ -119,15 +120,16 @@ export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActio
|
|||
const copy = t.settings.envActions
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={copy.actionsFor(label)}
|
||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon-sm"
|
||||
title={copy.credentialActions}
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
<Tip label={copy.credentialActions}>
|
||||
<Button
|
||||
aria-label={copy.actionsFor(label)}
|
||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Download,
|
||||
Globe,
|
||||
Info,
|
||||
Keyboard,
|
||||
KeyRound,
|
||||
Package,
|
||||
RefreshCw,
|
||||
|
|
@ -33,6 +34,7 @@ import { AppearanceSettings } from './appearance-settings'
|
|||
import { ConfigSettings } from './config-settings'
|
||||
import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeybindSettings } from './keybind-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PluginsSettings } from './plugins-settings'
|
||||
|
|
@ -44,6 +46,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
||||
'providers',
|
||||
'gateway',
|
||||
'keybinds',
|
||||
'keys',
|
||||
'notifications',
|
||||
'plugins',
|
||||
|
|
@ -178,6 +181,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keybinds',
|
||||
icon: Keyboard,
|
||||
id: 'keybinds',
|
||||
label: t.settings.nav.keybinds,
|
||||
onSelect: () => setActiveView('keybinds')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys',
|
||||
children: [
|
||||
|
|
@ -268,6 +278,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||
<AboutSettings />
|
||||
) : activeView === 'gateway' ? (
|
||||
<GatewaySettings />
|
||||
) : activeView === 'keybinds' ? (
|
||||
<KeybindSettings />
|
||||
) : activeView.startsWith('config:') ? (
|
||||
<ConfigSettings
|
||||
activeSectionId={activeView.slice('config:'.length)}
|
||||
|
|
|
|||
274
apps/desktop/src/app/settings/keybind-settings.tsx
Normal file
274
apps/desktop/src/app/settings/keybind-settings.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Kbd, KbdCombo } from '@/components/ui/kbd'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
allKeybindActions,
|
||||
KEYBIND_CATEGORIES,
|
||||
KEYBIND_PANEL_ACTION,
|
||||
KEYBIND_READONLY,
|
||||
type KeybindActionMeta,
|
||||
type KeybindReadonly,
|
||||
KEYBINDS_AREA
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual } from '@/lib/storage'
|
||||
import {
|
||||
$bindings,
|
||||
$capture,
|
||||
beginCapture,
|
||||
bindingsFor,
|
||||
conflictsFor,
|
||||
endCapture,
|
||||
resetAllBindings,
|
||||
resetBinding
|
||||
} from '@/store/keybinds'
|
||||
|
||||
import { SettingsContent } from './primitives'
|
||||
|
||||
export function KeybindSettings() {
|
||||
const { t } = useI18n()
|
||||
const bindings = useStore($bindings)
|
||||
const k = t.keybinds
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
||||
// Subscribe so contributed actions appear/disappear live in the map.
|
||||
useContributions(KEYBINDS_AREA)
|
||||
const actionList = allKeybindActions()
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
|
||||
|
||||
const toggleCategory = (category: string) =>
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev)
|
||||
|
||||
if (next.has(category)) {
|
||||
next.delete(category)
|
||||
} else {
|
||||
next.add(category)
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
// Filter actions and readonly shortcuts by label match against the query.
|
||||
// When searching, categories auto-expand (collapsed state is ignored).
|
||||
const isSearching = query.trim().length > 0
|
||||
|
||||
const filteredActions = useMemo(() => {
|
||||
if (!isSearching) {
|
||||
return null
|
||||
}
|
||||
|
||||
const lower = query.toLowerCase()
|
||||
|
||||
return actionList.filter(action => {
|
||||
if (action.id === KEYBIND_PANEL_ACTION) {
|
||||
return false
|
||||
}
|
||||
|
||||
const label = k.actions[action.id] ?? action.id
|
||||
|
||||
return label.toLowerCase().includes(lower) || action.id.includes(lower)
|
||||
})
|
||||
}, [actionList, isSearching, query, k.actions])
|
||||
|
||||
const filteredReadonly = useMemo(() => {
|
||||
if (!isSearching) {
|
||||
return null
|
||||
}
|
||||
|
||||
const lower = query.toLowerCase()
|
||||
|
||||
return KEYBIND_READONLY.filter(shortcut => {
|
||||
const label = k.actions[shortcut.id] ?? shortcut.id
|
||||
|
||||
return label.toLowerCase().includes(lower) || shortcut.id.includes(lower)
|
||||
})
|
||||
}, [isSearching, query, k.actions])
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="flex items-center justify-between gap-3 pb-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{k.title}</h2>
|
||||
<p className="mt-0.5 text-[0.72rem] text-muted-foreground">
|
||||
{k.subtitle(openCombo ? formatCombo(openCombo) : '')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1 rounded-md text-[0.72rem] text-muted-foreground hover:text-foreground"
|
||||
onClick={resetAllBindings}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.8125rem" />
|
||||
{k.resetAll}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pb-3">
|
||||
<SearchField
|
||||
aria-label={k.search}
|
||||
containerClassName="w-full"
|
||||
onChange={setQuery}
|
||||
placeholder={k.search}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<div className="px-2 py-1.5">
|
||||
{filteredActions?.length === 0 && filteredReadonly?.length === 0 ? (
|
||||
<p className="px-2.5 py-4 text-center text-[0.82rem] text-muted-foreground">—</p>
|
||||
) : (
|
||||
<>
|
||||
{filteredActions?.map(action => (
|
||||
<KeybindRow action={action} key={action.id} />
|
||||
))}
|
||||
{filteredReadonly?.map(shortcut => (
|
||||
<ReadonlyRow key={shortcut.id} shortcut={shortcut} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-1.5">
|
||||
{KEYBIND_CATEGORIES.map(category => {
|
||||
const actions = actionList.filter(
|
||||
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
|
||||
)
|
||||
|
||||
const readonly = KEYBIND_READONLY.filter(shortcut => shortcut.category === category)
|
||||
|
||||
if (actions.length === 0 && readonly.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sectionOpen = !collapsed.has(category)
|
||||
|
||||
return (
|
||||
<section key={category}>
|
||||
<CategoryHeader
|
||||
label={k.categories[category] ?? category}
|
||||
onToggle={() => toggleCategory(category)}
|
||||
open={sectionOpen}
|
||||
/>
|
||||
{sectionOpen && actions.map(action => <KeybindRow action={action} key={action.id} />)}
|
||||
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} shortcut={shortcut} />)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) {
|
||||
return (
|
||||
<button
|
||||
className="group/kbd-cat flex w-fit items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">{label}</span>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/kbd-cat:opacity-100"
|
||||
open={open}
|
||||
size="0.6875rem"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function KeybindRow({ action }: { action: KeybindActionMeta }) {
|
||||
const { t } = useI18n()
|
||||
const k = t.keybinds
|
||||
const bindings = useStore($bindings)
|
||||
const capture = useStore($capture)
|
||||
|
||||
// bindingsFor resolves stored overrides for late-registered (contributed)
|
||||
// actions too — $bindings only carries built-ins, so a raw lookup would show
|
||||
// the default instead of the user's rebinding for a plugin/contrib action.
|
||||
const combos = bindingsFor(action.id, bindings)
|
||||
const capturing = capture === action.id
|
||||
const label = k.actions[action.id] ?? action.label ?? action.id
|
||||
const isDefault = arraysEqual(combos, [...action.defaults])
|
||||
|
||||
const conflict = combos
|
||||
.flatMap(combo => conflictsFor(action.id, combo).map(other => k.actions[other] ?? other))
|
||||
.find(Boolean)
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
|
||||
|
||||
{conflict && (
|
||||
<span className="flex size-4 items-center justify-center text-amber-500/90" title={k.conflictWith(conflict)}>
|
||||
<Codicon name="warning" size="0.8125rem" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Click the caps to rebind — the on-screen editor does the same thing. */}
|
||||
<Tip label={k.rebind}>
|
||||
<button
|
||||
aria-label={k.rebind}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg outline-none"
|
||||
onClick={() => (capturing ? endCapture() : beginCapture(action.id))}
|
||||
type="button"
|
||||
>
|
||||
{capturing ? (
|
||||
<Kbd variant="capturing">{k.pressKey}</Kbd>
|
||||
) : combos.length > 0 ? (
|
||||
combos.map(combo => <KbdCombo combo={combo} key={combo} />)
|
||||
) : (
|
||||
<Kbd variant="ghost">{k.set}</Kbd>
|
||||
)}
|
||||
</button>
|
||||
</Tip>
|
||||
|
||||
{/* Reset only shows once a binding diverges from its default; the spacer
|
||||
holds the column otherwise so rows stay aligned. */}
|
||||
{isDefault ? (
|
||||
<span aria-hidden className="size-6 shrink-0" />
|
||||
) : (
|
||||
<Tip label={k.reset}>
|
||||
<button
|
||||
aria-label={k.reset}
|
||||
className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-(--ui-control-active-background) hover:text-foreground group-hover:opacity-100"
|
||||
onClick={() => resetBinding(action.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.8125rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and
|
||||
// the trailing reset slot stays empty (spacer keeps the columns aligned).
|
||||
function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
||||
const { t } = useI18n()
|
||||
const k = t.keybinds
|
||||
const label = k.actions[shortcut.id] ?? shortcut.id
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{shortcut.keys.map(key => (
|
||||
<KbdCombo combo={key} key={key} />
|
||||
))}
|
||||
</div>
|
||||
<span aria-hidden className="size-6 shrink-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
|||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons'
|
||||
|
|
@ -370,17 +371,18 @@ function PetAction({
|
|||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
|
||||
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
<Tip label={label}>
|
||||
<button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
|
||||
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { EnvVarInfo } from '@/types/hermes'
|
|||
export type SettingsView =
|
||||
| 'about'
|
||||
| 'gateway'
|
||||
| 'keybinds'
|
||||
| 'keys'
|
||||
| 'notifications'
|
||||
| 'plugins'
|
||||
|
|
|
|||
|
|
@ -431,6 +431,7 @@ export function useStatusbarItems({
|
|||
hidden: gatewayState !== 'open'
|
||||
},
|
||||
{
|
||||
actionId: 'view.showTerminal',
|
||||
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
hidden: !chatOpen,
|
||||
icon: <Terminal className="size-3.5" />,
|
||||
|
|
|
|||
|
|
@ -1,224 +0,0 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Kbd, KbdCombo } from '@/components/ui/kbd'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
allKeybindActions,
|
||||
KEYBIND_CATEGORIES,
|
||||
KEYBIND_PANEL_ACTION,
|
||||
KEYBIND_READONLY,
|
||||
type KeybindActionMeta,
|
||||
type KeybindReadonly,
|
||||
KEYBINDS_AREA
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual } from '@/lib/storage'
|
||||
import {
|
||||
$bindings,
|
||||
$capture,
|
||||
$keybindPanelOpen,
|
||||
beginCapture,
|
||||
bindingsFor,
|
||||
closeKeybindPanel,
|
||||
conflictsFor,
|
||||
endCapture,
|
||||
resetAllBindings,
|
||||
resetBinding
|
||||
} from '@/store/keybinds'
|
||||
|
||||
// The full hotkey map. Quiet popover, click a row's chip to rebind.
|
||||
export function KeybindPanel() {
|
||||
const { t } = useI18n()
|
||||
const open = useStore($keybindPanelOpen)
|
||||
const bindings = useStore($bindings)
|
||||
const k = t.keybinds
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
||||
// Subscribe so contributed actions appear/disappear live in the map.
|
||||
useContributions(KEYBINDS_AREA)
|
||||
const actionList = allKeybindActions()
|
||||
|
||||
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
|
||||
|
||||
const toggleCategory = (category: string) =>
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev)
|
||||
|
||||
if (next.has(category)) {
|
||||
next.delete(category)
|
||||
} else {
|
||||
next.add(category)
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root onOpenChange={next => !next && closeKeybindPanel()} open={open}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/25 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed left-1/2 top-[9vh] z-[210] flex max-h-[82vh] w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 border-b border-(--ui-stroke-tertiary) px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<DialogPrimitive.Title className="text-sm font-semibold text-foreground">{k.title}</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="mt-0.5 text-[0.72rem] text-muted-foreground">
|
||||
{k.subtitle(openCombo ? formatCombo(openCombo) : '')}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
<HeaderButton icon="discard" label={k.resetAll} onClick={resetAllBindings} />
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5">
|
||||
{KEYBIND_CATEGORIES.map(category => {
|
||||
const actions = actionList.filter(
|
||||
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
|
||||
)
|
||||
|
||||
const readonly = KEYBIND_READONLY.filter(shortcut => shortcut.category === category)
|
||||
|
||||
if (actions.length === 0 && readonly.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sectionOpen = !collapsed.has(category)
|
||||
|
||||
return (
|
||||
<section key={category}>
|
||||
<CategoryHeader
|
||||
label={k.categories[category] ?? category}
|
||||
onToggle={() => toggleCategory(category)}
|
||||
open={sectionOpen}
|
||||
/>
|
||||
{sectionOpen && actions.map(action => <KeybindRow action={action} key={action.id} />)}
|
||||
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} shortcut={shortcut} />)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible category header — chevron fades in on hover, rotates when open
|
||||
// (matches the sessions sidebar section pattern).
|
||||
function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) {
|
||||
return (
|
||||
<button
|
||||
className="group/kbd-cat flex w-fit items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">{label}</span>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/kbd-cat:opacity-100"
|
||||
open={open}
|
||||
size="0.6875rem"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Button className="shrink-0 text-[0.72rem]" onClick={onClick} size="xs" variant="text">
|
||||
<Codicon name={icon} size="0.8125rem" />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function KeybindRow({ action }: { action: KeybindActionMeta }) {
|
||||
const { t } = useI18n()
|
||||
const k = t.keybinds
|
||||
const bindings = useStore($bindings)
|
||||
const capture = useStore($capture)
|
||||
|
||||
// bindingsFor resolves stored overrides for late-registered (contributed)
|
||||
// actions too — $bindings only carries built-ins, so a raw lookup would show
|
||||
// the default instead of the user's rebinding for a plugin/contrib action.
|
||||
const combos = bindingsFor(action.id, bindings)
|
||||
const capturing = capture === action.id
|
||||
const label = k.actions[action.id] ?? action.label ?? action.id
|
||||
const isDefault = arraysEqual(combos, [...action.defaults])
|
||||
|
||||
const conflict = combos
|
||||
.flatMap(combo => conflictsFor(action.id, combo).map(other => k.actions[other] ?? other))
|
||||
.find(Boolean)
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
|
||||
|
||||
{conflict && (
|
||||
<span className="flex size-4 items-center justify-center text-amber-500/90" title={k.conflictWith(conflict)}>
|
||||
<Codicon name="warning" size="0.8125rem" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Click the caps to rebind — the on-screen editor does the same thing. */}
|
||||
<button
|
||||
aria-label={k.rebind}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg outline-none"
|
||||
onClick={() => (capturing ? endCapture() : beginCapture(action.id))}
|
||||
title={k.rebind}
|
||||
type="button"
|
||||
>
|
||||
{capturing ? (
|
||||
<Kbd variant="capturing">{k.pressKey}</Kbd>
|
||||
) : combos.length > 0 ? (
|
||||
combos.map(combo => <KbdCombo combo={combo} key={combo} />)
|
||||
) : (
|
||||
<Kbd variant="ghost">{k.set}</Kbd>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Reset only shows once a binding diverges from its default; the spacer
|
||||
holds the column otherwise so rows stay aligned. */}
|
||||
{isDefault ? (
|
||||
<span aria-hidden className="size-6 shrink-0" />
|
||||
) : (
|
||||
<button
|
||||
aria-label={k.reset}
|
||||
className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-(--ui-control-active-background) hover:text-foreground group-hover:opacity-100"
|
||||
onClick={() => resetBinding(action.id)}
|
||||
title={k.reset}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.8125rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and
|
||||
// the trailing reset slot stays empty (spacer keeps the columns aligned).
|
||||
function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
||||
const { t } = useI18n()
|
||||
const k = t.keybinds
|
||||
const label = k.actions[shortcut.id] ?? shortcut.id
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{shortcut.keys.map(key => (
|
||||
<KbdCombo combo={key} key={key} />
|
||||
))}
|
||||
</div>
|
||||
<span aria-hidden className="size-6 shrink-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { type ComponentProps, type ReactNode, useState } from 'react'
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Tip, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared chrome styling for interactive statusbar items (button / link / menu
|
||||
|
|
@ -42,6 +42,8 @@ export interface StatusbarItem {
|
|||
menuContent?: ((close: () => void) => ReactNode) | ReactNode
|
||||
menuItems?: readonly StatusbarMenuItem[]
|
||||
onSelect?: (modifiers: StatusbarSelectModifiers) => void
|
||||
/** Keybind action id — when set, the tooltip shows the label + keybind hint. */
|
||||
actionId?: string
|
||||
title?: string
|
||||
to?: string
|
||||
variant?: 'action' | 'link' | 'menu' | 'text'
|
||||
|
|
@ -101,6 +103,8 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
return <>{item.render()}</>
|
||||
}
|
||||
|
||||
const tooltipLabel = item.actionId ? <TipKeybindLabel actionId={item.actionId} text={item.title} /> : item.title
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{item.icon}
|
||||
|
|
@ -129,7 +133,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent>{item.title}</TooltipContent>
|
||||
<TooltipContent>{tooltipLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
|
|
@ -185,7 +189,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
|
||||
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
|
||||
return (
|
||||
<Tip label={item.title}>
|
||||
<Tip label={tooltipLabel}>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
|
||||
|
|
@ -200,7 +204,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
|
||||
if (item.href || item.variant === 'link') {
|
||||
return (
|
||||
<Tip label={item.title}>
|
||||
<Tip label={tooltipLabel}>
|
||||
<a className={cn(STATUSBAR_ACTION_CLASS, item.className)} href={item.href} rel="noreferrer" target="_blank">
|
||||
{content}
|
||||
</a>
|
||||
|
|
@ -209,7 +213,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
}
|
||||
|
||||
return (
|
||||
<Tip label={item.title}>
|
||||
<Tip label={tooltipLabel}>
|
||||
<button
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
|
|||
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -19,7 +19,7 @@ import {
|
|||
toggleSidebarOpen
|
||||
} from '@/store/layout'
|
||||
|
||||
import { appViewForPath, isOverlayView } from '../routes'
|
||||
import { appViewForPath, isOverlayView, SETTINGS_ROUTE } from '../routes'
|
||||
|
||||
import { titlebarButtonClass } from './titlebar'
|
||||
|
||||
|
|
@ -33,6 +33,8 @@ export interface TitlebarTool {
|
|||
href?: string
|
||||
icon: ReactNode
|
||||
onSelect?: (event?: MouseEvent) => void
|
||||
/** Keybind action id — when set, the tooltip shows the label + keybind hint. */
|
||||
actionId?: string
|
||||
title?: string
|
||||
to?: string
|
||||
}
|
||||
|
|
@ -123,6 +125,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
|
||||
const leftToolbarTools: TitlebarTool[] = [
|
||||
{
|
||||
actionId: 'view.toggleSidebar',
|
||||
icon: <Codicon name="layout-sidebar-left" />,
|
||||
id: 'sidebar',
|
||||
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
|
||||
|
|
@ -132,6 +135,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
}
|
||||
},
|
||||
{
|
||||
actionId: 'view.flipPanes',
|
||||
icon: <Codicon name="arrow-swap" />,
|
||||
id: 'flip-panes',
|
||||
label: t.titlebar.swapSidebarSides,
|
||||
|
|
@ -145,6 +149,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
]
|
||||
|
||||
const rightSidebarTool: TitlebarTool = {
|
||||
actionId: 'view.toggleRightSidebar',
|
||||
icon: <Codicon name="layout-sidebar-right" />,
|
||||
id: 'right-sidebar',
|
||||
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
|
||||
|
|
@ -184,6 +189,17 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
actionId: 'keybinds.openPanel',
|
||||
icon: <Codicon name="keyboard" />,
|
||||
id: 'keybinds',
|
||||
label: t.titlebar.openKeybinds,
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
navigate(`${SETTINGS_ROUTE}?tab=keybinds`)
|
||||
}
|
||||
},
|
||||
{
|
||||
actionId: 'nav.settings',
|
||||
icon: <Codicon name="settings-gear" />,
|
||||
id: 'settings',
|
||||
label: t.titlebar.openSettings,
|
||||
|
|
@ -256,9 +272,15 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
|||
// for a11y.
|
||||
const className = cn(titlebarButtonClass, 'bg-transparent select-none', tool.className)
|
||||
|
||||
const tooltipLabel = tool.actionId ? (
|
||||
<TipKeybindLabel actionId={tool.actionId} text={tool.title ?? tool.label} />
|
||||
) : (
|
||||
(tool.title ?? tool.label)
|
||||
)
|
||||
|
||||
if (tool.href) {
|
||||
return (
|
||||
<Tip label={tool.title ?? tool.label}>
|
||||
<Tip label={tooltipLabel}>
|
||||
<Button asChild className={className} size="icon-titlebar" variant="ghost">
|
||||
<a
|
||||
aria-label={tool.label}
|
||||
|
|
@ -275,7 +297,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
|||
}
|
||||
|
||||
return (
|
||||
<Tip label={tool.title ?? tool.label}>
|
||||
<Tip label={tooltipLabel}>
|
||||
<Button
|
||||
aria-label={tool.label}
|
||||
aria-pressed={tool.active ?? undefined}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { ErrorBanner } from '@/components/ui/error-state'
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { TextTab } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import {
|
||||
authMcpServer,
|
||||
getActionStatus,
|
||||
|
|
@ -1140,16 +1141,17 @@ function ServerConfig({
|
|||
row's h-11 centering exactly (h-5 controls → mt-3, size-6 avatar →
|
||||
mt-2.5, h-4 switch → mt-3.5) no matter how tall the text column gets. */}
|
||||
<div className="flex items-start gap-2 pr-1.5">
|
||||
<Button
|
||||
aria-label={m.allServers}
|
||||
className={cn('mt-3', ICON_BUTTON)}
|
||||
onClick={onBack}
|
||||
size="icon"
|
||||
title={m.allServers}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="chevron-left" size="0.8125rem" />
|
||||
</Button>
|
||||
<Tip label={m.allServers}>
|
||||
<Button
|
||||
aria-label={m.allServers}
|
||||
className={cn('mt-3', ICON_BUTTON)}
|
||||
onClick={onBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="chevron-left" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<McpAvatar className="mt-2.5" name={name} status={status} />
|
||||
<div className="min-w-0 flex-1 pt-1">
|
||||
<h3 className="min-w-0 truncate text-[0.9375rem] font-semibold tracking-tight">{prettyName(name)}</h3>
|
||||
|
|
@ -1283,28 +1285,30 @@ function ServerIconActions({
|
|||
|
||||
return (
|
||||
<span className={cn('flex items-center gap-0.5', className)}>
|
||||
<Button
|
||||
aria-label={m.reload}
|
||||
className={ICON_BUTTON}
|
||||
disabled={probing}
|
||||
onClick={onProbe}
|
||||
size="icon"
|
||||
title={m.reload}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={probing} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={m.remove}
|
||||
className={cn(ICON_BUTTON, 'hover:text-destructive')}
|
||||
disabled={saving}
|
||||
onClick={onRemove}
|
||||
size="icon"
|
||||
title={m.remove}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="trash" size="0.8125rem" />
|
||||
</Button>
|
||||
<Tip label={m.reload}>
|
||||
<Button
|
||||
aria-label={m.reload}
|
||||
className={ICON_BUTTON}
|
||||
disabled={probing}
|
||||
onClick={onProbe}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={probing} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={m.remove}>
|
||||
<Button
|
||||
aria-label={m.remove}
|
||||
className={cn(ICON_BUTTON, 'hover:text-destructive')}
|
||||
disabled={saving}
|
||||
onClick={onRemove}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="trash" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Upload } from '@/lib/icons'
|
||||
|
||||
|
|
@ -76,15 +77,16 @@ export function ShareControls({ imported = false, onImport, onResetMap, shareCod
|
|||
open={open}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
aria-label={t.starmap.shareTitle}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
size="icon"
|
||||
title={t.starmap.shareTitle}
|
||||
variant="ghost"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
</Button>
|
||||
<Tip label={t.starmap.shareTitle}>
|
||||
<Button
|
||||
aria-label={t.starmap.shareTitle}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-md">
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@ export interface SidebarNavItem {
|
|||
icon: React.ComponentType<{ className?: string }>
|
||||
route?: string
|
||||
action?: 'new-session'
|
||||
/** Keybind action id — when set, the tooltip shows the keybind hint. */
|
||||
keybindActionId?: string
|
||||
}
|
||||
|
||||
export interface ClientSessionState {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
|
|||
)}
|
||||
disabled={isSavingLocale}
|
||||
size="sm"
|
||||
title={title}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Static-analysis guard: no <button> or <Button> element in the desktop renderer
|
||||
// may use the native HTML `title=` attribute. Native tooltips are unstyled,
|
||||
// delayed (~500ms OS default), and visually inconsistent with the app's instant
|
||||
// themed `Tip` component. Use `<Tip label={...}>` instead.
|
||||
//
|
||||
// This is a source-text scan, not a behavior test — it's the same category as
|
||||
// an ESLint rule, expressed as a vitest so it runs with the rest of the suite.
|
||||
// See DESIGN.md "Buttons — one component" for the rule.
|
||||
|
||||
// Recursively walk a directory and collect all .tsx file paths.
|
||||
function collectTsxFiles(dir: string): string[] {
|
||||
const results: string[] = []
|
||||
|
||||
for (const entry of readdirSync(dir)) {
|
||||
// Skip node_modules, dist, and __tests__ (this file itself)
|
||||
if (entry === 'node_modules' || entry === 'dist' || entry === '__tests__') {
|
||||
continue
|
||||
}
|
||||
|
||||
const fullPath = join(dir, entry)
|
||||
const stat = statSync(fullPath)
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
results.push(...collectTsxFiles(fullPath))
|
||||
} else if (entry.endsWith('.tsx')) {
|
||||
results.push(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
describe('no native title= on button elements', () => {
|
||||
// Scan every .tsx file under src/ for <button or <Button opening tags that
|
||||
// also carry a title= attribute (anywhere in the opening tag, which may span
|
||||
// multiple lines).
|
||||
it('uses <Tip> instead of native title= on all button elements', () => {
|
||||
const violations: string[] = []
|
||||
const srcDir = resolve(__dirname, '../..')
|
||||
|
||||
for (const filePath of collectTsxFiles(srcDir)) {
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
const relativePath = filePath.replace(srcDir + '/', '')
|
||||
|
||||
// Match <Button ...> or <button ...> opening tags (may span multiple lines).
|
||||
// We use a non-greedy match up to the closing > — this won't perfectly
|
||||
// handle every edge case (e.g. > inside a string literal), but it's good
|
||||
// enough for a lint-style guard.
|
||||
const tagPattern = /<(Button|button)\b([^>]*?)>/gsu
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = tagPattern.exec(content)) !== null) {
|
||||
const tagName = match[1]
|
||||
const attrs = match[2]
|
||||
|
||||
if (/\btitle=/.test(attrs)) {
|
||||
const lineNum = content.slice(0, match.index).split('\n').length
|
||||
violations.push(`${relativePath}:${lineNum} <${tagName}> has title= — use <Tip>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(violations, violations.join('\n')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
|||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -73,15 +74,17 @@ function DialogContent({
|
|||
|
||||
const closeButton = showCloseButton ? (
|
||||
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="absolute right-2.5 top-2.5 z-20 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">{t.common.close}</span>
|
||||
</Button>
|
||||
<Tip label={t.common.close}>
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="absolute right-2.5 top-2.5 z-20 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">{t.common.close}</span>
|
||||
</Button>
|
||||
</Tip>
|
||||
</DialogPrimitive.Close>
|
||||
) : null
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { type ReactNode, type RefObject, useState } from 'react'
|
|||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader2, Search } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -86,15 +87,17 @@ export function SearchField({
|
|||
{loading ? (
|
||||
<Loader2 className="pointer-events-none size-3.5 shrink-0 animate-spin text-muted-foreground/70" />
|
||||
) : value ? (
|
||||
<Button
|
||||
aria-label={t.ui.search.clear}
|
||||
className="shrink-0 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={clear}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.875rem" />
|
||||
</Button>
|
||||
<Tip label={t.ui.search.clear}>
|
||||
<Button
|
||||
aria-label={t.ui.search.clear}
|
||||
className="shrink-0 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={clear}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input'
|
|||
import { Separator } from '@/components/ui/separator'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PanelLeftIcon } from '@/lib/icons'
|
||||
|
|
@ -256,24 +256,25 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
|||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={t.ui.sidebar.toggle}
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[0.125rem] hover:after:bg-sidebar-border sm:flex',
|
||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
|
||||
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||
className
|
||||
)}
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
onClick={toggleSidebar}
|
||||
tabIndex={-1}
|
||||
title={t.ui.sidebar.toggle}
|
||||
{...props}
|
||||
/>
|
||||
<Tip label={t.ui.sidebar.toggle}>
|
||||
<button
|
||||
aria-label={t.ui.sidebar.toggle}
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[0.125rem] hover:after:bg-sidebar-border sm:flex',
|
||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
|
||||
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||
className
|
||||
)}
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
onClick={toggleSidebar}
|
||||
tabIndex={-1}
|
||||
{...props}
|
||||
/>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Tooltip as TooltipPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TooltipProvider({
|
||||
|
|
@ -111,4 +113,23 @@ function TipHintLabel({ text, hint }: TipHintLabelProps) {
|
|||
)
|
||||
}
|
||||
|
||||
export { Tip, TipHintLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
interface TipKeybindLabelProps {
|
||||
/** Keybind action id — pulls the label from i18n AND the combo from the store. */
|
||||
actionId: string
|
||||
/** Override the i18n label (for context-dependent text like "Show"/"Hide"). */
|
||||
text?: string
|
||||
}
|
||||
|
||||
/** TipHintLabel that auto-reads both its label and keybind from the action
|
||||
* registry. Pass only `actionId` for the common case; pass `text` to override
|
||||
* when the button's tooltip is context-dependent. */
|
||||
function TipKeybindLabel({ actionId, text }: TipKeybindLabelProps) {
|
||||
const { t } = useI18n()
|
||||
const hint = useKeybindHint(actionId)
|
||||
|
||||
const label = text ?? t.keybinds.actions[actionId] ?? actionId
|
||||
|
||||
return <TipHintLabel hint={hint ?? undefined} text={label} />
|
||||
}
|
||||
|
||||
export { Tip, TipHintLabel, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { Check, Copy, Maximize, RefreshCw, X, ZoomIn, ZoomOut } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -159,14 +160,15 @@ function Divider() {
|
|||
|
||||
function ToolbarButton({ children, label, onClick }: { children: ReactNode; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className="grid size-8 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<Tip label={label}>
|
||||
<button
|
||||
aria-label={label}
|
||||
className="grid size-8 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ export const en: Translations = {
|
|||
keybinds: {
|
||||
title: 'Keyboard shortcuts',
|
||||
subtitle: open => `Click a shortcut to rebind it · ${open} reopens this panel.`,
|
||||
search: 'Search shortcuts…',
|
||||
rebind: 'Rebind',
|
||||
reset: 'Reset to default',
|
||||
resetAll: 'Reset all',
|
||||
|
|
@ -315,6 +316,7 @@ export const en: Translations = {
|
|||
providerApiKeys: 'API keys',
|
||||
gateway: 'Gateway',
|
||||
apiKeys: 'Tools & Keys',
|
||||
keybinds: 'Keyboard Shortcuts',
|
||||
keysTools: 'Tools',
|
||||
keysSettings: 'Settings',
|
||||
mcp: 'MCP',
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ export const ja = defineLocale({
|
|||
providerApiKeys: 'API キー',
|
||||
gateway: 'ゲートウェイ',
|
||||
apiKeys: 'ツールとキー',
|
||||
keybinds: 'キーボードショートカット',
|
||||
keysTools: 'ツール',
|
||||
keysSettings: '設定',
|
||||
mcp: 'MCP',
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ export interface Translations {
|
|||
keybinds: {
|
||||
title: string
|
||||
subtitle: (open: string) => string
|
||||
search: string
|
||||
rebind: string
|
||||
reset: string
|
||||
resetAll: string
|
||||
|
|
@ -273,6 +274,7 @@ export interface Translations {
|
|||
providerApiKeys: string
|
||||
gateway: string
|
||||
apiKeys: string
|
||||
keybinds: string
|
||||
keysTools: string
|
||||
keysSettings: string
|
||||
mcp: string
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ export const zhHant = defineLocale({
|
|||
providerApiKeys: 'API 金鑰',
|
||||
gateway: '閘道',
|
||||
apiKeys: '工具與金鑰',
|
||||
keybinds: '鍵盤快捷鍵',
|
||||
keysTools: '工具',
|
||||
keysSettings: '設定',
|
||||
mcp: 'MCP',
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ export const zh: Translations = {
|
|||
keybinds: {
|
||||
title: '键盘快捷键',
|
||||
subtitle: open => `点击快捷键即可重新绑定 · ${open} 可重新打开此面板。`,
|
||||
search: '搜索快捷键…',
|
||||
rebind: '重新绑定',
|
||||
reset: '恢复默认',
|
||||
resetAll: '全部重置',
|
||||
|
|
@ -306,6 +307,7 @@ export const zh: Translations = {
|
|||
providerApiKeys: 'API 密钥',
|
||||
gateway: '网关',
|
||||
apiKeys: '工具与密钥',
|
||||
keybinds: '键盘快捷键',
|
||||
keysTools: '工具',
|
||||
keysSettings: '设置',
|
||||
mcp: 'MCP',
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
IconHelpCircle as HelpCircle,
|
||||
IconPhoto as ImageIcon,
|
||||
IconInfoCircle as Info,
|
||||
IconKeyboard as Keyboard,
|
||||
IconKey as KeyRound,
|
||||
IconLayersIntersect2 as Layers3,
|
||||
IconLayoutDashboard as LayoutDashboard,
|
||||
|
|
@ -165,6 +166,7 @@ export {
|
|||
HelpCircle,
|
||||
ImageIcon,
|
||||
Info,
|
||||
Keyboard,
|
||||
KeyRound,
|
||||
Layers3,
|
||||
LayoutDashboard,
|
||||
|
|
|
|||
28
apps/desktop/src/lib/keybinds/use-keybind-hint.ts
Normal file
28
apps/desktop/src/lib/keybinds/use-keybind-hint.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
|
||||
import { KEYBIND_READONLY } from './actions'
|
||||
import { formatCombo } from './combo'
|
||||
|
||||
// The formatted first combo for `actionId`, or null when unbound. Rebindable
|
||||
// actions read live from the store; readonly shortcuts (e.g. `composer.steer`)
|
||||
// fall back to their fixed combo. Returns null for unknown action ids so the
|
||||
// tooltip shows just the text label with no trailing hint.
|
||||
export function useKeybindHint(actionId: string): string | null {
|
||||
const bindings = useStore($bindings)
|
||||
|
||||
const rebindable = bindings[actionId]?.[0]
|
||||
|
||||
if (rebindable) {
|
||||
return formatCombo(rebindable)
|
||||
}
|
||||
|
||||
const readonly = KEYBIND_READONLY.find(entry => entry.id === actionId)
|
||||
|
||||
if (readonly) {
|
||||
return formatCombo(readonly.keys[0])
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -141,24 +141,3 @@ export function beginCapture(actionId: string): void {
|
|||
export function endCapture(): void {
|
||||
$capture.set(null)
|
||||
}
|
||||
|
||||
// ── Panel ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const $keybindPanelOpen = atom(false)
|
||||
|
||||
export function openKeybindPanel(): void {
|
||||
$keybindPanelOpen.set(true)
|
||||
}
|
||||
|
||||
export function closeKeybindPanel(): void {
|
||||
$keybindPanelOpen.set(false)
|
||||
$capture.set(null)
|
||||
}
|
||||
|
||||
export function toggleKeybindPanel(): void {
|
||||
if ($keybindPanelOpen.get()) {
|
||||
closeKeybindPanel()
|
||||
} else {
|
||||
openKeybindPanel()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue