mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
feat: glass ui pass
This commit is contained in:
parent
062eed654d
commit
d67a438fec
104 changed files with 5173 additions and 1919 deletions
|
|
@ -31,6 +31,14 @@ const {
|
|||
resolveTimeoutMs
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
let nodePty = null
|
||||
|
||||
try {
|
||||
nodePty = require('@homebridge/node-pty-prebuilt-multiarch')
|
||||
} catch {
|
||||
nodePty = null
|
||||
}
|
||||
|
||||
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
|
||||
if (USER_DATA_OVERRIDE) {
|
||||
const resolvedUserData = path.resolve(USER_DATA_OVERRIDE)
|
||||
|
|
@ -133,6 +141,7 @@ const APP_ICON_PATHS = [
|
|||
]
|
||||
|
||||
let rendererTitleBarTheme = null
|
||||
const terminalSessions = new Map()
|
||||
|
||||
function isHexColor(value) {
|
||||
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
|
||||
|
|
@ -2792,7 +2801,21 @@ ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
|
|||
|
||||
// Always-hidden noise (covers non-git projects too — gitignore would catch
|
||||
// these anyway when present, but we want the same hygiene without one).
|
||||
const FS_READDIR_HIDDEN = new Set(['.git', '.hg', '.svn', 'node_modules', '__pycache__', '.next', '.venv', 'venv'])
|
||||
const FS_READDIR_HIDDEN = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'.cache',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'build',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'target',
|
||||
'venv'
|
||||
])
|
||||
|
||||
function findGitRoot(start) {
|
||||
let dir = start
|
||||
|
|
@ -2818,6 +2841,76 @@ function findGitRoot(start) {
|
|||
return null
|
||||
}
|
||||
|
||||
function terminalShellCommand() {
|
||||
if (IS_WINDOWS) {
|
||||
return { args: [], command: process.env.COMSPEC || 'cmd.exe' }
|
||||
}
|
||||
|
||||
const configuredShell = process.env.SHELL || ''
|
||||
const shellPath =
|
||||
(path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) ||
|
||||
['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) ||
|
||||
'/bin/sh'
|
||||
const shellName = path.basename(shellPath)
|
||||
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
|
||||
|
||||
return { args: interactiveArgs, command: shellPath, name: shellName }
|
||||
}
|
||||
|
||||
function safeTerminalCwd(cwd) {
|
||||
const candidate = path.resolve(String(cwd || app.getPath('home')))
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(candidate)
|
||||
|
||||
return stat.isDirectory() ? candidate : path.dirname(candidate)
|
||||
} catch {
|
||||
return app.getPath('home')
|
||||
}
|
||||
}
|
||||
|
||||
function terminalShellEnv() {
|
||||
const env = { ...process.env }
|
||||
|
||||
// Electron is commonly launched through `npm run dev`; do not leak npm's
|
||||
// managed prefix into a user's interactive shell (nvm/proto warn loudly).
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
|
||||
env.COLORTERM = env.COLORTERM || 'truecolor'
|
||||
env.LC_CTYPE = env.LC_CTYPE || 'UTF-8'
|
||||
env.TERM = 'xterm-256color'
|
||||
env.TERM_PROGRAM = 'Hermes'
|
||||
env.TERM_PROGRAM_VERSION = app.getVersion()
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
function terminalChannel(id, suffix) {
|
||||
return `hermes:terminal:${id}:${suffix}`
|
||||
}
|
||||
|
||||
function disposeTerminalSession(id) {
|
||||
const sessionInfo = terminalSessions.get(id)
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
terminalSessions.delete(id)
|
||||
|
||||
try {
|
||||
sessionInfo.pty.kill()
|
||||
} catch {
|
||||
// Process may already be gone.
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
|
||||
const resolved = path.resolve(String(dirPath || ''))
|
||||
|
||||
|
|
@ -2859,6 +2952,72 @@ ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
|
|||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const { args, command, name } = terminalShellCommand()
|
||||
const cwd = safeTerminalCwd(payload?.cwd)
|
||||
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
|
||||
const ptyProcess = nodePty.spawn(command, args, {
|
||||
cols,
|
||||
cwd,
|
||||
env: terminalShellEnv(),
|
||||
name: 'xterm-256color',
|
||||
rows
|
||||
})
|
||||
|
||||
terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id })
|
||||
|
||||
const send = (suffix, payload) => {
|
||||
if (event.sender.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.sender.send(terminalChannel(id, suffix), payload)
|
||||
}
|
||||
|
||||
ptyProcess.onData(data => send('data', data))
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
terminalSessions.delete(id)
|
||||
send('exit', { code: exitCode, signal: signal || null })
|
||||
})
|
||||
event.sender.once('destroyed', () => disposeTerminalSession(id))
|
||||
|
||||
return { cwd, id, shell: name }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
sessionInfo.pty.write(String(data || ''))
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
|
||||
|
||||
sessionInfo.pty.resize(cols, rows)
|
||||
|
||||
return true
|
||||
})
|
||||
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
|
||||
|
||||
ipcMain.handle('hermes:updates:check', async () =>
|
||||
checkUpdates().catch(error => ({
|
||||
supported: true,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,24 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
terminal: {
|
||||
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
|
||||
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
|
||||
start: options => ipcRenderer.invoke('hermes:terminal:start', options),
|
||||
write: (id, data) => ipcRenderer.invoke('hermes:terminal:write', id, data),
|
||||
onData: (id, callback) => {
|
||||
const channel = `hermes:terminal:${id}:data`
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => ipcRenderer.removeListener(channel, listener)
|
||||
},
|
||||
onExit: (id, callback) => {
|
||||
const channel = `hermes:terminal:${id}:exit`
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => ipcRenderer.removeListener(channel, listener)
|
||||
}
|
||||
},
|
||||
onClosePreviewRequested: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:close-preview-requested', listener)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,11 @@
|
|||
"@assistant-ui/react-streamdown": "^0.1.11",
|
||||
"@audiowave/react": "^0.6.2",
|
||||
"@chenglou/pretext": "^0.0.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@nous-research/ui": "^0.13.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
|
|
@ -54,6 +58,11 @@
|
|||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.6",
|
||||
"@vscode/codicons": "^0.0.45",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
|
@ -62,7 +71,6 @@
|
|||
"ignore": "^7.0.5",
|
||||
"katex": "^0.16.45",
|
||||
"leva": "^0.10.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { ZoomableImage } from '@/components/chat/zoomable-image'
|
|||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationButton,
|
||||
|
|
@ -19,16 +18,16 @@ import {
|
|||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { FileImage, FileText, FolderOpen, Layers3, Link2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { sessionRoute } from '../routes'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
type ArtifactKind = 'image' | 'file' | 'link'
|
||||
type ArtifactFilter = 'all' | ArtifactKind
|
||||
|
|
@ -363,12 +362,10 @@ const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file'
|
|||
|
||||
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function ArtifactsView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: ArtifactsViewProps) {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -412,24 +409,6 @@ export function ArtifactsView({
|
|||
void refreshArtifacts()
|
||||
}, [refreshArtifacts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarToolGroup('artifacts', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
|
||||
id: 'refresh-artifacts',
|
||||
label: refreshing ? 'Refreshing artifacts' : 'Refresh artifacts',
|
||||
onSelect: () => void refreshArtifacts()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarToolGroup('artifacts', [])
|
||||
}, [refreshArtifacts, refreshing, setTitlebarToolGroup])
|
||||
|
||||
useEffect(() => {
|
||||
setImagePage(1)
|
||||
setFilePage(1)
|
||||
|
|
@ -523,127 +502,115 @@ export function ArtifactsView({
|
|||
}
|
||||
|
||||
return (
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">{counts.all} found</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterButton
|
||||
active={kindFilter === 'all'}
|
||||
icon={Layers3}
|
||||
label={`All (${counts.all})`}
|
||||
onClick={() => setKindFilter('all')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'image'}
|
||||
icon={FileImage}
|
||||
label={`Images (${counts.image})`}
|
||||
onClick={() => setKindFilter('image')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'file'}
|
||||
icon={FileText}
|
||||
label={`Files (${counts.file})`}
|
||||
onClick={() => setKindFilter('file')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'link'}
|
||||
icon={Link2}
|
||||
label={`Links (${counts.link})`}
|
||||
onClick={() => setKindFilter('link')}
|
||||
/>
|
||||
<div className="ml-auto w-full max-w-sm min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 rounded-lg pl-8 pr-8 text-sm"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search artifacts..."
|
||||
value={query}
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setQuery('')}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
<>
|
||||
<FilterButton
|
||||
active={kindFilter === 'all'}
|
||||
icon={Layers3}
|
||||
label={`All (${counts.all})`}
|
||||
onClick={() => setKindFilter('all')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'image'}
|
||||
icon={FileImage}
|
||||
label={`Images (${counts.image})`}
|
||||
onClick={() => setKindFilter('image')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'file'}
|
||||
icon={FileText}
|
||||
label={`Files (${counts.file})`}
|
||||
onClick={() => setKindFilter('file')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'link'}
|
||||
icon={Link2}
|
||||
label={`Links (${counts.link})`}
|
||||
onClick={() => setKindFilter('link')}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search artifacts..."
|
||||
searchValue={query}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!artifacts ? (
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
) : visibleArtifacts.length === 0 ? (
|
||||
<div className="grid h-full place-items-center px-6 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">No artifacts found</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Generated images and file outputs will appear here as sessions produce them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!artifacts ? (
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
) : visibleArtifacts.length === 0 ? (
|
||||
<div className="grid h-full place-items-center px-6 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">No artifacts found</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Generated images and file outputs will appear here as sessions produce them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col gap-4 px-2 pb-2">
|
||||
{visibleImageArtifacts.length > 0 && (
|
||||
<section className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel="images"
|
||||
onPageChange={setImagePage}
|
||||
page={currentImagePage}
|
||||
pageSize={24}
|
||||
total={visibleImageArtifacts.length}
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 px-2 pb-2">
|
||||
{visibleImageArtifacts.length > 0 && (
|
||||
<section className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel="images"
|
||||
onPageChange={setImagePage}
|
||||
page={currentImagePage}
|
||||
pageSize={24}
|
||||
total={visibleImageArtifacts.length}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] items-start gap-2 pt-1.5">
|
||||
{pagedImageArtifacts.map(artifact => (
|
||||
<ArtifactImageCard
|
||||
artifact={artifact}
|
||||
failedImage={failedImageIds.has(artifact.id)}
|
||||
key={artifact.id}
|
||||
onImageError={markImageFailed}
|
||||
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] items-start gap-2 pt-1.5">
|
||||
{pagedImageArtifacts.map(artifact => (
|
||||
<ArtifactImageCard
|
||||
artifact={artifact}
|
||||
failedImage={failedImageIds.has(artifact.id)}
|
||||
key={artifact.id}
|
||||
onImageError={markImageFailed}
|
||||
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visibleFileArtifacts.length > 0 && (
|
||||
<section className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel={itemsLabel(kindFilter)}
|
||||
onPageChange={setFilePage}
|
||||
page={currentFilePage}
|
||||
pageSize={100}
|
||||
total={visibleFileArtifacts.length}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]">
|
||||
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{visibleFileArtifacts.length > 0 && (
|
||||
<section className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel={itemsLabel(kindFilter)}
|
||||
onPageChange={setFilePage}
|
||||
page={currentFilePage}
|
||||
pageSize={100}
|
||||
total={visibleFileArtifacts.length}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) shadow-sm">
|
||||
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</PageSearchShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -712,8 +679,8 @@ function FilterButton({
|
|||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'h-8 gap-1.5 rounded-md px-2.5 text-xs',
|
||||
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
'h-7 gap-1.5 rounded-md px-2 text-[length:var(--conversation-caption-font-size)]',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
|
|
@ -737,13 +704,12 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
|||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'group/artifact overflow-hidden rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]',
|
||||
'bg-muted/20'
|
||||
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) shadow-sm'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-44 w-full items-center justify-center overflow-hidden border-b border-border/50 bg-[color-mix(in_srgb,var(--dt-muted)_58%,var(--dt-background))] p-1.5',
|
||||
'relative flex h-40 w-full items-center justify-center overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-1.5',
|
||||
failedImage && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
|
|
@ -763,15 +729,15 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
|||
|
||||
<div className="space-y-1.5 p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-0.5 flex items-center gap-1 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<div className="mb-0.5 flex items-center gap-1 text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
||||
<FileImage className="size-3" />
|
||||
{artifact.kind}
|
||||
</div>
|
||||
<div className="truncate text-xs font-medium">{artifact.label}</div>
|
||||
<div className="mt-0.5 truncate text-[0.62rem] text-muted-foreground">{artifact.value}</div>
|
||||
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">{artifact.label}</div>
|
||||
<div className="mt-0.5 truncate text-[0.625rem] text-(--ui-text-tertiary)">{artifact.value}</div>
|
||||
</div>
|
||||
|
||||
<div className="truncate text-[0.62rem] text-muted-foreground">
|
||||
<div className="truncate text-[0.625rem] text-(--ui-text-tertiary)">
|
||||
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
|
||||
</div>
|
||||
|
||||
|
|
@ -803,7 +769,7 @@ function ArtifactCellAction({
|
|||
if (href) {
|
||||
return (
|
||||
<ExternalLink
|
||||
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-sm leading-snug font-medium text-foreground/90 no-underline transition-colors hover:text-foreground hover:underline"
|
||||
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline transition-colors hover:text-foreground hover:underline"
|
||||
href={href}
|
||||
showExternalIcon={false}
|
||||
title={title}
|
||||
|
|
@ -816,7 +782,7 @@ function ArtifactCellAction({
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-sm leading-snug font-medium text-foreground/90 no-underline transition-colors hover:text-foreground hover:underline',
|
||||
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline transition-colors hover:text-foreground hover:underline',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
onClick={onClick}
|
||||
|
|
@ -840,7 +806,7 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
|
|||
onClick={isLink ? undefined : () => void ctx.onOpen(artifact.href)}
|
||||
title={label}
|
||||
>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
||||
<span className="mt-0.5 grid size-6 shrink-0 place-items-center self-start rounded-md bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
<span className={cn('min-w-0 flex-1', isLink ? 'wrap-anywhere' : 'truncate')}>
|
||||
|
|
@ -859,7 +825,7 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
|
|||
return (
|
||||
<div className="group/location flex min-w-0 items-center gap-1.5">
|
||||
<div
|
||||
className={cn('min-w-0 flex-1 truncate text-xs text-muted-foreground/85', isLink ? 'font-medium' : 'font-mono')}
|
||||
className={cn('min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)', isLink ? 'font-normal' : 'font-mono')}
|
||||
title={artifact.value}
|
||||
>
|
||||
{value}
|
||||
|
|
@ -882,7 +848,7 @@ function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
|
|||
<ArtifactCellAction onClick={() => ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{artifact.sessionTitle}</span>
|
||||
<span className="truncate text-xs font-normal text-muted-foreground/75">
|
||||
<span className="truncate text-[0.6875rem] font-normal text-(--ui-text-tertiary)">
|
||||
{formatArtifactTime(artifact.timestamp)}
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -924,8 +890,8 @@ function ArtifactTable({
|
|||
filter: ArtifactFilter
|
||||
}) {
|
||||
return (
|
||||
<table className="w-full min-w-176 table-fixed text-left text-xs">
|
||||
<thead className="border-b border-border/50 bg-muted/35 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<table className="w-full min-w-176 table-fixed text-left text-[length:var(--conversation-caption-font-size)]">
|
||||
<thead className="border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
||||
<tr>
|
||||
{ARTIFACT_COLUMNS.map(col => (
|
||||
<th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}>
|
||||
|
|
@ -934,9 +900,9 @@ function ArtifactTable({
|
|||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/45">
|
||||
<tbody className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{artifacts.map(artifact => (
|
||||
<tr className="group/artifact transition-colors hover:bg-muted/30" key={artifact.id}>
|
||||
<tr className="group/artifact transition-colors hover:bg-(--chrome-action-hover)" key={artifact.id}>
|
||||
{ARTIFACT_COLUMNS.map(col => {
|
||||
const Cell = col.Cell
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { FileText, FolderOpen, ImageIcon, Link, X } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
|
@ -16,17 +17,17 @@ export function AttachmentList({
|
|||
}) {
|
||||
return (
|
||||
<div className="flex max-w-full flex-wrap gap-1.5 px-1 pt-1" data-slot="composer-attachments">
|
||||
{attachments.map(a => (
|
||||
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
|
||||
{attachments.map(attachment => (
|
||||
<AttachmentPill attachment={attachment} key={attachment.id} onRemove={onRemove} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind]
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
const canPreview = attachment.kind !== 'folder'
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
|
||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||
|
||||
async function openPreview() {
|
||||
|
|
@ -101,7 +102,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
<Codicon name="close" size="0.625rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,23 +3,30 @@ import { ComposerPrimitive } from '@assistant-ui/react'
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export const COMPLETION_DRAWER_CLASS = [
|
||||
'absolute inset-x-0 bottom-[calc(100%-0.5rem)] z-50',
|
||||
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
|
||||
'w-60 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'border border-b-0',
|
||||
'border-[color-mix(in_srgb,var(--dt-ring)_45%,transparent)]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-popover)_96%,transparent)]',
|
||||
'px-1.5 pb-3 pt-1.5 text-popover-foreground',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.1]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.1)]',
|
||||
'data-[state=open]:-mb-2',
|
||||
'data-[state=open]:shadow-[0_-0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_-1rem_2.25rem_-1.75rem_color-mix(in_srgb,var(--dt-foreground)_34%,transparent),0_-0.3125rem_0.875rem_-0.6875rem_color-mix(in_srgb,var(--dt-foreground)_22%,transparent)]'
|
||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-md',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = [
|
||||
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
|
||||
'w-60 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-md',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
export const COMPLETION_DRAWER_ROW_CLASS = [
|
||||
'flex w-full min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1',
|
||||
'text-left text-xs transition-colors',
|
||||
'hover:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]',
|
||||
'data-[highlighted]:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
|
||||
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
|
||||
'hover:bg-(--ui-bg-tertiary)',
|
||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||
].join(' ')
|
||||
|
||||
export function ComposerCompletionDrawer({
|
||||
|
|
@ -48,9 +55,9 @@ export function ComposerCompletionDrawer({
|
|||
|
||||
export function CompletionDrawerEmpty({ children, title }: { children?: ReactNode; title: string }) {
|
||||
return (
|
||||
<div className="px-3 py-3 text-sm text-muted-foreground">
|
||||
<div className="px-3 py-3 text-xs text-(--ui-text-tertiary)">
|
||||
<p>{title}</p>
|
||||
{children && <p className="mt-1 text-xs text-muted-foreground/80">{children}</p>}
|
||||
{children && <p className="mt-1 text-xs text-(--ui-text-tertiary)">{children}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -10,7 +11,7 @@ import {
|
|||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from '@/lib/icons'
|
||||
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type IconComponent, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
|
|
@ -38,14 +39,14 @@ export function ContextMenu({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-accent data-[state=open]:text-foreground')}
|
||||
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground')}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<Codicon name="add" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
|
|
@ -107,7 +108,7 @@ export function ContextMenuItem({
|
|||
}: {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: LucideIcon
|
||||
icon: IconComponent
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ArrowUp, AudioLines, Layers3, Loader2, Mic, MicOff, Square } from '@/lib/icons'
|
||||
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
import type { ChatBarState, VoiceStatus } from './types'
|
||||
|
||||
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-full'
|
||||
export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground')
|
||||
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
|
||||
export const GHOST_ICON_BTN = cn(
|
||||
ICON_BTN,
|
||||
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)
|
||||
// Send/voice-conversation primary: solid foreground-on-background circle
|
||||
// (reads as black-on-white in light mode, white-on-black in dark mode) to
|
||||
// match the reference composer's high-contrast CTA. Keeps the pill itself
|
||||
|
|
@ -89,7 +93,7 @@ export function ComposerControls({
|
|||
<span className="block size-3 rounded-[0.1875rem] bg-current" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUp size={18} />
|
||||
<Codicon name="arrow-up" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -136,7 +140,7 @@ function ConversationPill({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{muted ? <MicOff size={16} /> : <Mic size={16} />}
|
||||
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
|
||||
</Button>
|
||||
{listening && (
|
||||
<Button
|
||||
|
|
@ -246,7 +250,7 @@ function DictationButton({
|
|||
) : status === 'transcribing' ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Mic size={16} />
|
||||
<Codicon name="mic" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
|
|
|
|||
2
apps/desktop/src/app/chat/composer/drop-affordance.ts
Normal file
2
apps/desktop/src/app/chat/composer/drop-affordance.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const COMPOSER_DROP_FADE_CLASS = 'transition-opacity duration-150 ease-out'
|
||||
export const COMPOSER_DROP_ACTIVE_CLASS = 'opacity-60'
|
||||
103
apps/desktop/src/app/chat/composer/focus.ts
Normal file
103
apps/desktop/src/app/chat/composer/focus.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Composer focus + external-insert bus.
|
||||
*
|
||||
* Mutations from outside the composer (sidebar attach, drag drop, terminal
|
||||
* Cmd+L, preview console, etc.) dispatch through here. Each composer subscribes
|
||||
* and routes the work back into its own ref/state.
|
||||
*
|
||||
* `dispatch` defers to a macrotask so synchronous click/keydown handlers
|
||||
* (react-arborist row focus, picker `node.select()`) finish first and don't
|
||||
* steal focus from the composer effect.
|
||||
*/
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
export type ComposerInsertMode = 'block' | 'inline'
|
||||
|
||||
interface FocusDetail {
|
||||
target: ComposerTarget
|
||||
}
|
||||
|
||||
interface InsertDetail {
|
||||
mode: ComposerInsertMode
|
||||
target: ComposerTarget
|
||||
text: string
|
||||
}
|
||||
|
||||
const FOCUS_EVENT = 'hermes:composer-focus'
|
||||
const INSERT_EVENT = 'hermes:composer-insert'
|
||||
|
||||
let activeTarget: ComposerTarget = 'main'
|
||||
|
||||
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target)
|
||||
|
||||
const dispatch = <T>(name: string, detail: T) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(() => window.dispatchEvent(new CustomEvent<T>(name, { detail })), 0)
|
||||
}
|
||||
|
||||
const subscribe = <T>(name: string, handler: (detail: T) => void) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
const listener = (event: Event) => {
|
||||
const detail = (event as CustomEvent<T>).detail
|
||||
|
||||
if (detail) {
|
||||
handler(detail)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(name, listener)
|
||||
|
||||
return () => window.removeEventListener(name, listener)
|
||||
}
|
||||
|
||||
export const markActiveComposer = (target: ComposerTarget) => {
|
||||
activeTarget = target
|
||||
}
|
||||
|
||||
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
|
||||
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
|
||||
|
||||
export const requestComposerInsert = (
|
||||
text: string,
|
||||
{ mode = 'block', target = 'active' }: { mode?: ComposerInsertMode; target?: ComposerTarget | 'active' } = {}
|
||||
) => {
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed })
|
||||
}
|
||||
|
||||
export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) =>
|
||||
subscribe<FocusDetail>(FOCUS_EVENT, ({ target }) => handler(target))
|
||||
|
||||
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
|
||||
subscribe<InsertDetail>(INSERT_EVENT, handler)
|
||||
|
||||
/**
|
||||
* Focus a composer input across React commit + browser focus restore.
|
||||
*
|
||||
* The triple-call survives:
|
||||
* - sync: contenteditable already mounted
|
||||
* - rAF: React just committed a `renderComposerContents` swap
|
||||
* - 0ms: browser focus reclaim from a click target inside an external panel
|
||||
*/
|
||||
export const focusComposerInput = (el: HTMLElement | null) => {
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const focus = () => el.focus({ preventScroll: true })
|
||||
|
||||
focus()
|
||||
window.requestAnimationFrame(focus)
|
||||
window.setTimeout(focus, 0)
|
||||
}
|
||||
|
|
@ -8,16 +8,16 @@ import {
|
|||
type DragEvent as ReactDragEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { formatRefValue, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { contextPath } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -25,28 +25,39 @@ import {
|
|||
$composerAttachments,
|
||||
$composerDraft,
|
||||
clearComposerAttachments,
|
||||
type ComposerAttachment
|
||||
type ComposerAttachment,
|
||||
reconcileComposerTerminalSelections
|
||||
} from '@/store/composer'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
removeQueuedPrompt,
|
||||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { ComposerControls } from './controls'
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
|
||||
import {
|
||||
type ComposerInsertMode,
|
||||
focusComposerInput,
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRequest
|
||||
} from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
placeCaretEnd,
|
||||
|
|
@ -54,7 +65,6 @@ import {
|
|||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
|
|
@ -104,7 +114,11 @@ export function ChatBar({
|
|||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
const queuedPrompts = activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []
|
||||
|
||||
const queuedPrompts = useMemo(
|
||||
() => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []),
|
||||
[activeQueueSessionKey, queuedPromptsBySession]
|
||||
)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
|
||||
|
|
@ -121,10 +135,11 @@ export function ChatBar({
|
|||
const [tight, setTight] = useState(false)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const dragDepthRef = useRef(0)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
const narrow = useMediaQuery('(max-width: 480px)')
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
|
||||
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
||||
const slash = useSlashCompletions({ gateway: gateway ?? null })
|
||||
|
|
@ -132,23 +147,81 @@ export function ChatBar({
|
|||
const stacked = expanded || narrow || tight
|
||||
const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const editingQueuedPrompt = queueEdit ? queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null : null
|
||||
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const placeholder = disabled ? 'Starting Hermes…' : 'Ask anything'
|
||||
const placeholder = disabled ? 'Starting Hermes...' : 'Send follow-up'
|
||||
|
||||
const focusInput = () => window.requestAnimationFrame(() => editorRef.current?.focus({ preventScroll: true }))
|
||||
const focusInput = useCallback(() => {
|
||||
focusComposerInput(editorRef.current)
|
||||
markActiveComposer('main')
|
||||
}, [])
|
||||
|
||||
const requestMainFocus = useCallback(() => {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}, [])
|
||||
|
||||
const appendExternalText = useCallback(
|
||||
(text: string, mode: ComposerInsertMode) => {
|
||||
const value = text.trim()
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
|
||||
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
|
||||
const next = `${base}${sep}${value}`
|
||||
|
||||
draftRef.current = next
|
||||
aui.composer().setText(next)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
renderComposerContents(editor, next)
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
setFocusRequestId(id => id + 1)
|
||||
},
|
||||
[aui]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
focusInput()
|
||||
}
|
||||
}, [disabled, focusKey])
|
||||
}, [disabled, focusInput, focusKey, focusRequestId])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const offFocus = onComposerFocusRequest(target => {
|
||||
if (target === 'main') {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}
|
||||
})
|
||||
|
||||
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
|
||||
if (target === 'main') {
|
||||
appendExternalText(text, mode)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText, disabled])
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
$composerDraft.set(draft)
|
||||
reconcileComposerTerminalSelections(draft)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
|
|
@ -232,114 +305,33 @@ export function ChatBar({
|
|||
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
focusInput()
|
||||
requestMainFocus()
|
||||
}
|
||||
|
||||
const insertInlineRefs = (refs: string[]) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!refs.length || !editor) {
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const inline = refs.join(' ')
|
||||
const selection = window.getSelection()
|
||||
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
||||
|
||||
const range =
|
||||
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
|
||||
? selection.getRangeAt(0)
|
||||
: null
|
||||
|
||||
editor.focus({ preventScroll: true })
|
||||
|
||||
if (range) {
|
||||
const beforeRange = range.cloneRange()
|
||||
beforeRange.selectNodeContents(editor)
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset)
|
||||
const beforeContainer = document.createElement('div')
|
||||
beforeContainer.appendChild(beforeRange.cloneContents())
|
||||
|
||||
const afterRange = range.cloneRange()
|
||||
afterRange.selectNodeContents(editor)
|
||||
afterRange.setStart(range.endContainer, range.endOffset)
|
||||
const afterContainer = document.createElement('div')
|
||||
afterContainer.appendChild(afterRange.cloneContents())
|
||||
|
||||
const beforeText = composerPlainText(beforeContainer)
|
||||
const afterText = composerPlainText(afterContainer)
|
||||
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
|
||||
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
|
||||
range.deleteContents()
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
if (needsBeforeSpace) {
|
||||
fragment.appendChild(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
refs.forEach((ref, index) => {
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
fragment.appendChild(match ? refChipElement(match[1], match[2]) : document.createTextNode(ref))
|
||||
|
||||
if (index < refs.length - 1) {
|
||||
fragment.appendChild(document.createTextNode(' '))
|
||||
}
|
||||
})
|
||||
|
||||
const trailingSpace = needsAfterSpace ? document.createTextNode(' ') : null
|
||||
|
||||
if (trailingSpace) {
|
||||
fragment.appendChild(trailingSpace)
|
||||
}
|
||||
|
||||
range.insertNode(fragment)
|
||||
|
||||
const nextRange = document.createRange()
|
||||
|
||||
if (trailingSpace) {
|
||||
nextRange.setStart(trailingSpace, trailingSpace.length)
|
||||
} else {
|
||||
nextRange.setStartAfter(fragment.lastChild || range.startContainer)
|
||||
}
|
||||
|
||||
nextRange.collapse(true)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(nextRange)
|
||||
} else {
|
||||
const current = composerPlainText(editor)
|
||||
renderComposerContents(editor, `${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `)
|
||||
placeCaretEnd(editor)
|
||||
if (nextDraft === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
requestMainFocus()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const droppedFileInlineRef = (candidate: DroppedFile) => {
|
||||
if (!candidate.path) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rel = contextPath(candidate.path, cwd || '')
|
||||
|
||||
if (candidate.line) {
|
||||
const { line, lineEnd } = candidate
|
||||
const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}`
|
||||
|
||||
return `@line:${formatRefValue(`${rel}:${range}`)}`
|
||||
}
|
||||
|
||||
const kind = candidate.isDirectory ? 'folder' : 'file'
|
||||
|
||||
return `@${kind}:${formatRefValue(rel)}`
|
||||
}
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
focusInput()
|
||||
requestMainFocus()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
|
|
@ -453,6 +445,7 @@ export function ChatBar({
|
|||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
requestMainFocus()
|
||||
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
|
||||
|
|
@ -498,7 +491,9 @@ export function ChatBar({
|
|||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
|
||||
if (!busy) void drainNextQueued()
|
||||
if (!busy) {
|
||||
void drainNextQueued()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -554,29 +549,13 @@ export function ChatBar({
|
|||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const dragHasAttachments = (transfer: DataTransfer | null) => {
|
||||
if (!transfer) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes(HERMES_PATHS_MIME)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes('Files')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Array.from(transfer.items || []).some(item => item.kind === 'file')
|
||||
}
|
||||
|
||||
const resetDragState = () => {
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
}
|
||||
|
||||
const handleDragEnter = (event: ReactDragEvent<HTMLFormElement>) => {
|
||||
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) {
|
||||
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -589,7 +568,7 @@ export function ChatBar({
|
|||
}
|
||||
|
||||
const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => {
|
||||
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) {
|
||||
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -625,7 +604,9 @@ export function ChatBar({
|
|||
}
|
||||
|
||||
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
|
||||
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
|
||||
const refs = candidates
|
||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
||||
.filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
|
|
@ -637,13 +618,13 @@ export function ChatBar({
|
|||
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
|
||||
if (attached) {
|
||||
triggerHaptic('selection')
|
||||
focusInput()
|
||||
requestMainFocus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer)) {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -653,12 +634,15 @@ export function ChatBar({
|
|||
}
|
||||
|
||||
const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer)) {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
const refs = candidates
|
||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
||||
.filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (!refs.length) {
|
||||
return
|
||||
|
|
@ -673,14 +657,14 @@ export function ChatBar({
|
|||
}
|
||||
}
|
||||
|
||||
const clearDraft = () => {
|
||||
const clearDraft = useCallback(() => {
|
||||
aui.composer().setText('')
|
||||
draftRef.current = ''
|
||||
|
||||
if (editorRef.current) {
|
||||
editorRef.current.replaceChildren()
|
||||
}
|
||||
}
|
||||
}, [aui])
|
||||
|
||||
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
|
||||
draftRef.current = text
|
||||
|
|
@ -696,7 +680,9 @@ export function ChatBar({
|
|||
}
|
||||
|
||||
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
|
||||
if (!activeQueueSessionKey || queueEdit) return
|
||||
if (!activeQueueSessionKey || queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
setQueueEdit({
|
||||
attachments: cloneAttachments($composerAttachments.get()),
|
||||
|
|
@ -710,13 +696,17 @@ export function ChatBar({
|
|||
}
|
||||
|
||||
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
|
||||
if (!queueEdit) return false
|
||||
if (!queueEdit) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (action === 'save') {
|
||||
const text = draftRef.current
|
||||
const next = cloneAttachments($composerAttachments.get())
|
||||
|
||||
if (!text.trim() && next.length === 0) return false
|
||||
if (!text.trim() && next.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text })
|
||||
triggerHaptic(saved ? 'success' : 'selection')
|
||||
|
|
@ -732,32 +722,45 @@ export function ChatBar({
|
|||
}
|
||||
|
||||
const queueCurrentDraft = useCallback(() => {
|
||||
if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) return false
|
||||
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) return false
|
||||
if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) {
|
||||
return false
|
||||
}
|
||||
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
triggerHaptic('selection')
|
||||
|
||||
return true
|
||||
}, [activeQueueSessionKey, attachments, draft])
|
||||
}, [activeQueueSessionKey, attachments, clearDraft, draft])
|
||||
|
||||
// All queue drain paths share one lock + send-then-remove sequence.
|
||||
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
|
||||
const runDrain = useCallback(
|
||||
async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise<boolean> => {
|
||||
if (drainingQueueRef.current || !activeQueueSessionKey) return false
|
||||
if (drainingQueueRef.current || !activeQueueSessionKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const entry = pickEntry(queuedPrompts)
|
||||
|
||||
if (!entry) return false
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
|
||||
drainingQueueRef.current = true
|
||||
|
||||
try {
|
||||
const accepted = await Promise.resolve(onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }))
|
||||
const accepted = await Promise.resolve(
|
||||
onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })
|
||||
)
|
||||
|
||||
if (accepted === false) return false
|
||||
if (accepted === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
removeQueuedPrompt(activeQueueSessionKey, entry.id)
|
||||
|
||||
|
|
@ -785,7 +788,9 @@ export function ChatBar({
|
|||
)
|
||||
|
||||
const interruptAndSendNextQueued = useCallback(async () => {
|
||||
if (queuedPrompts.length === 0) return false
|
||||
if (queuedPrompts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
await Promise.resolve(onCancel())
|
||||
|
||||
|
|
@ -797,15 +802,22 @@ export function ChatBar({
|
|||
const wasBusy = previousBusyRef.current
|
||||
previousBusyRef.current = busy
|
||||
|
||||
if (busy || !wasBusy || queuedPrompts.length === 0) return
|
||||
if (busy || !wasBusy || queuedPrompts.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
void drainNextQueued()
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
// Clean up queue edit when its target disappears (session swap or external delete).
|
||||
useEffect(() => {
|
||||
if (!queueEdit) return
|
||||
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) return
|
||||
if (!queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
|
||||
return
|
||||
}
|
||||
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
setQueueEdit(null)
|
||||
|
|
@ -815,9 +827,11 @@ export function ChatBar({
|
|||
if (queueEdit) {
|
||||
exitQueuedEdit('save')
|
||||
} else if (busy) {
|
||||
if (hasComposerPayload) queueCurrentDraft()
|
||||
else if (queuedPrompts.length > 0) void interruptAndSendNextQueued()
|
||||
else {
|
||||
if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else if (queuedPrompts.length > 0) {
|
||||
void interruptAndSendNextQueued()
|
||||
} else {
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
|
|
@ -966,6 +980,7 @@ export function ChatBar({
|
|||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onDragOver={handleInputDragOver}
|
||||
onDrop={handleInputDrop}
|
||||
onFocus={() => markActiveComposer('main')}
|
||||
onInput={handleEditorInput}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onKeyUp={handleEditorKeyUp}
|
||||
|
|
@ -1033,10 +1048,11 @@ export function ChatBar({
|
|||
<div
|
||||
className={cn(
|
||||
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)] group-focus-within/composer:shadow-composer-focus',
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-composer-ring)_calc(35%*var(--composer-ring-strength)),transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]',
|
||||
dragActive && 'border-midground/70 shadow-composer-focus ring-2 ring-midground/40'
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
ref={composerSurfaceRef}
|
||||
|
|
@ -1053,14 +1069,6 @@ export function ChatBar({
|
|||
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
||||
)}
|
||||
/>
|
||||
{dragActive && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-3 flex items-center justify-center bg-midground/10 text-sm font-semibold uppercase tracking-[0.18em] text-midground backdrop-blur-[1px]"
|
||||
>
|
||||
Drop files to attach
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
|
|
@ -1074,7 +1082,9 @@ export function ChatBar({
|
|||
<VoicePlaybackActivity />
|
||||
{queueEdit && editingQueuedPrompt && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
|
||||
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">Editing queued turn in composer</div>
|
||||
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
|
||||
Editing queued turn in composer
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[0.68rem]"
|
||||
|
|
|
|||
91
apps/desktop/src/app/chat/composer/inline-refs.ts
Normal file
91
apps/desktop/src/app/chat/composer/inline-refs.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { contextPath } from '@/lib/chat-runtime'
|
||||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
|
||||
|
||||
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
|
||||
if (!transfer) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes(pathsMime)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes('Files')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Array.from(transfer.items || []).some(item => item.kind === 'file')
|
||||
}
|
||||
|
||||
export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null | undefined) {
|
||||
if (!candidate.path) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rel = contextPath(candidate.path, cwd || '')
|
||||
|
||||
if (candidate.line) {
|
||||
const { line, lineEnd } = candidate
|
||||
const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}`
|
||||
|
||||
return `@line:${formatRefValue(`${rel}:${range}`)}`
|
||||
}
|
||||
|
||||
const kind = candidate.isDirectory ? 'folder' : 'file'
|
||||
|
||||
return `@${kind}:${formatRefValue(rel)}`
|
||||
}
|
||||
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) {
|
||||
if (!refs.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const refsHtml = refs
|
||||
.map(ref => {
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
const range =
|
||||
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
|
||||
? selection.getRangeAt(0)
|
||||
: null
|
||||
|
||||
editor.focus({ preventScroll: true })
|
||||
|
||||
if (range) {
|
||||
const beforeRange = range.cloneRange()
|
||||
beforeRange.selectNodeContents(editor)
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset)
|
||||
const beforeContainer = document.createElement('div')
|
||||
beforeContainer.appendChild(beforeRange.cloneContents())
|
||||
|
||||
const afterRange = range.cloneRange()
|
||||
afterRange.selectNodeContents(editor)
|
||||
afterRange.setStart(range.endContainer, range.endOffset)
|
||||
const afterContainer = document.createElement('div')
|
||||
afterContainer.appendChild(afterRange.cloneContents())
|
||||
|
||||
const beforeText = composerPlainText(beforeContainer)
|
||||
const afterText = composerPlainText(afterContainer)
|
||||
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
|
||||
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
|
||||
|
||||
document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`)
|
||||
} else {
|
||||
const current = composerPlainText(editor)
|
||||
placeCaretEnd(editor)
|
||||
document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `)
|
||||
}
|
||||
|
||||
return composerPlainText(editor)
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowUp, ChevronDown, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QueuedPromptEntry } from '@/store/composer-queue'
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
|||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown className={cn('shrink-0 transition-transform', collapsed && '-rotate-90')} size={14} />
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
|
||||
<span className="truncate">{entries.length} Queued</span>
|
||||
</button>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
export const REF_RE = /@(file|folder|url|image|tool|line):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,45 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
import {
|
||||
COMPLETION_DRAWER_BELOW_CLASS,
|
||||
COMPLETION_DRAWER_CLASS,
|
||||
COMPLETION_DRAWER_ROW_CLASS,
|
||||
CompletionDrawerEmpty
|
||||
} from './completion-drawer'
|
||||
|
||||
const AT_ICON_BY_TYPE: Record<string, string> = {
|
||||
diff: 'diff',
|
||||
file: 'book',
|
||||
folder: 'folder',
|
||||
git: 'git-branch',
|
||||
image: 'file-media',
|
||||
simple: 'symbol-misc',
|
||||
staged: 'diff-added',
|
||||
tool: 'tools',
|
||||
url: 'globe'
|
||||
}
|
||||
|
||||
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
|
||||
if (kind === '/') {
|
||||
return 'terminal'
|
||||
}
|
||||
|
||||
const meta = item.metadata as { rawText?: string } | undefined
|
||||
const raw = meta?.rawText || item.label
|
||||
|
||||
if (raw.startsWith('@diff')) {
|
||||
return AT_ICON_BY_TYPE.diff
|
||||
}
|
||||
|
||||
if (raw.startsWith('@staged')) {
|
||||
return AT_ICON_BY_TYPE.staged
|
||||
}
|
||||
|
||||
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
|
||||
}
|
||||
|
||||
interface ComposerTriggerPopoverProps {
|
||||
activeIndex: number
|
||||
|
|
@ -11,6 +48,7 @@ interface ComposerTriggerPopoverProps {
|
|||
loading: boolean
|
||||
onHover: (index: number) => void
|
||||
onPick: (item: Unstable_TriggerItem) => void
|
||||
placement?: 'bottom' | 'top'
|
||||
}
|
||||
|
||||
export function ComposerTriggerPopover({
|
||||
|
|
@ -19,11 +57,12 @@ export function ComposerTriggerPopover({
|
|||
kind,
|
||||
loading,
|
||||
onHover,
|
||||
onPick
|
||||
onPick,
|
||||
placement = 'top'
|
||||
}: ComposerTriggerPopoverProps) {
|
||||
return (
|
||||
<div
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
className={placement === 'bottom' ? COMPLETION_DRAWER_BELOW_CLASS : COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-completion-drawer"
|
||||
data-state="open"
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
|
|
@ -52,7 +91,7 @@ export function ComposerTriggerPopover({
|
|||
<button
|
||||
className={cn(
|
||||
COMPLETION_DRAWER_ROW_CLASS,
|
||||
index === activeIndex && 'bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
|
||||
index === activeIndex && 'bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
data-highlighted={index === activeIndex ? '' : undefined}
|
||||
key={item.id}
|
||||
|
|
@ -60,9 +99,14 @@ export function ComposerTriggerPopover({
|
|||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
|
||||
{display}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import { useCallback } from 'react'
|
|||
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer'
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
setComposerTerminalSelection,
|
||||
type ComposerAttachment,
|
||||
removeComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import type { ImageDetachResponse } from '../../types'
|
||||
|
|
@ -180,19 +186,38 @@ interface ComposerActionsOptions {
|
|||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
/** Add to the main composer and focus it. All sidebar/picker/drop attach paths funnel through here. */
|
||||
const attachToMain = (attachment: ComposerAttachment) => {
|
||||
addComposerAttachment(attachment)
|
||||
requestComposerFocus('main')
|
||||
}
|
||||
|
||||
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
|
||||
const addTextToDraft = useCallback((text: string) => {
|
||||
requestComposerInsert(text, { mode: 'block' })
|
||||
}, [])
|
||||
|
||||
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
|
||||
const trimmed = text.trim()
|
||||
const normalizedLabel = label.trim() || 'selection'
|
||||
const refText = `@terminal:${formatRefValue(normalizedLabel)}`
|
||||
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
setComposerTerminalSelection(normalizedLabel, trimmed)
|
||||
requestComposerInsert(refText, { mode: 'inline' })
|
||||
}, [])
|
||||
|
||||
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
|
||||
let kind: ComposerAttachment['kind'] = 'file'
|
||||
const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:')
|
||||
? 'folder'
|
||||
: refText.startsWith('@url:')
|
||||
? 'url'
|
||||
: 'file'
|
||||
|
||||
if (refText.startsWith('@folder:')) {
|
||||
kind = 'folder'
|
||||
}
|
||||
|
||||
if (refText.startsWith('@url:')) {
|
||||
kind = 'url'
|
||||
}
|
||||
|
||||
addComposerAttachment({
|
||||
attachToMain({
|
||||
id: attachmentId(kind, refText),
|
||||
kind,
|
||||
label: label || refText.replace(/^@(file|folder|url):/, ''),
|
||||
|
|
@ -216,7 +241,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
for (const path of paths) {
|
||||
const rel = contextPath(path, currentCwd)
|
||||
|
||||
addComposerAttachment({
|
||||
attachToMain({
|
||||
id: attachmentId(kind, rel),
|
||||
kind,
|
||||
label: pathLabel(path),
|
||||
|
|
@ -237,7 +262,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
|
||||
const rel = contextPath(filePath, currentCwd)
|
||||
|
||||
addComposerAttachment({
|
||||
attachToMain({
|
||||
id: attachmentId('file', rel),
|
||||
kind: 'file',
|
||||
label: pathLabel(filePath),
|
||||
|
|
@ -264,7 +289,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
path: filePath
|
||||
}
|
||||
|
||||
addComposerAttachment(baseAttachment)
|
||||
attachToMain(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
|
|
@ -361,7 +386,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
|
||||
const rel = contextPath(folderPath, currentCwd)
|
||||
|
||||
addComposerAttachment({
|
||||
attachToMain({
|
||||
id: attachmentId('folder', rel),
|
||||
kind: 'folder',
|
||||
label: pathLabel(folderPath),
|
||||
|
|
@ -482,7 +507,10 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
|
||||
return {
|
||||
addContextRefAttachment,
|
||||
addTerminalSelectionAttachment,
|
||||
addTextToDraft,
|
||||
attachContextFilePath,
|
||||
attachContextFolderPath,
|
||||
attachDroppedItems,
|
||||
attachImageBlob,
|
||||
attachImagePath,
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ import { Button } from '@/components/ui/button'
|
|||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$awaitingResponse,
|
||||
|
|
@ -92,32 +92,30 @@ function ChatHeader({
|
|||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
|
||||
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
|
||||
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
|
||||
pinned={selectedIsPinned}
|
||||
sessionId={selectedSessionId || activeSessionId || ''}
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
|
||||
pinned={selectedIsPinned}
|
||||
sessionId={selectedSessionId || activeSessionId || ''}
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-6 min-w-0 gap-1 rounded-md border border-transparent bg-(--ui-bg-quinary) px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-tertiary) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-bg-tertiary) [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
|
||||
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
)}
|
||||
<h2 className="max-w-[52vw] truncate text-[0.75rem] font-medium leading-none">{title}</h2>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
|
@ -271,7 +269,7 @@ export function ChatView({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-transparent',
|
||||
'relative flex h-full min-w-0 flex-col overflow-hidden bg-(--glass-chat-surface-background)',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -285,13 +283,17 @@ export function ChatView({
|
|||
|
||||
<NotificationStack />
|
||||
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-b-[1.0625rem] bg-transparent contain-[layout_paint]">
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--glass-chat-surface-background) contain-[layout_paint]">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
clampToComposer={showChatBar}
|
||||
cwd={currentCwd}
|
||||
gateway={gateway}
|
||||
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
|
||||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
onCancel={onCancel}
|
||||
sessionId={activeSessionId}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
{showChatBar && (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
|||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerDraft, setComposerDraft } from '@/store/composer'
|
||||
import { requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state'
|
||||
|
|
@ -186,10 +186,8 @@ export function PreviewConsolePanel({
|
|||
}
|
||||
|
||||
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
|
||||
const draft = $composerDraft.get()
|
||||
const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
|
||||
|
||||
setComposerDraft(next)
|
||||
requestComposerInsert(block, { mode: 'block', target: 'main' })
|
||||
consoleState.clearSelection()
|
||||
notify({
|
||||
kind: 'success',
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ function MarkdownPreview({ text }: { text: string }) {
|
|||
|
||||
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
|
||||
onClick={onToggle}
|
||||
|
|
@ -379,7 +379,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
|
||||
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
|
|
@ -512,7 +512,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
|
||||
if (isImage && state.dataUrl) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-auto bg-[color-mix(in_srgb,var(--dt-card)_42%,transparent)] p-4">
|
||||
<div className="flex h-full w-full items-center justify-center overflow-auto bg-transparent p-4">
|
||||
<img
|
||||
alt={target.label}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
|
||||
|
|
@ -528,7 +528,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
const showRendered = isMarkdown && !renderMarkdownAsSource
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-background">
|
||||
<div className="h-full overflow-auto bg-transparent">
|
||||
{state.truncated && (
|
||||
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
||||
Showing first 512 KB.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { Bug, RefreshCw, X } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Bug } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview'
|
||||
|
|
@ -301,13 +302,13 @@ export function PreviewPane({
|
|||
]
|
||||
: []),
|
||||
{
|
||||
icon: <RefreshCw className={cn(loading && 'animate-spin')} />,
|
||||
icon: <Codicon name="refresh" spinning={loading} />,
|
||||
id: `${TITLEBAR_GROUP_ID}-reload`,
|
||||
label: 'Reload preview',
|
||||
onSelect: reloadPreview
|
||||
},
|
||||
{
|
||||
icon: <X />,
|
||||
icon: <Codicon name="close" />,
|
||||
id: `${TITLEBAR_GROUP_ID}-close`,
|
||||
label: 'Close preview',
|
||||
onSelect: onClose
|
||||
|
|
@ -534,7 +535,7 @@ export function PreviewPane({
|
|||
}
|
||||
|
||||
const webview = document.createElement('webview') as PreviewWebview
|
||||
webview.className = 'flex h-full w-full flex-1 bg-background'
|
||||
webview.className = 'flex h-full w-full flex-1 bg-transparent'
|
||||
webview.setAttribute('partition', 'persist:hermes-preview')
|
||||
webview.setAttribute('src', target.url)
|
||||
webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes')
|
||||
|
|
@ -626,7 +627,7 @@ export function PreviewPane({
|
|||
}, [appendConsoleEntry, consoleState, isWebPreview, target.url])
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-background text-muted-foreground">
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent text-muted-foreground">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{!embedded && (
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
|
|
@ -645,12 +646,12 @@ export function PreviewPane({
|
|||
)}
|
||||
|
||||
<div
|
||||
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background"
|
||||
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-transparent"
|
||||
ref={previewContentRef}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex bg-background',
|
||||
'absolute inset-0 flex bg-transparent',
|
||||
(!isWebPreview || loadError) && 'pointer-events-none opacity-0'
|
||||
)}
|
||||
ref={hostRef}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { X } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$rightRailActiveTabId,
|
||||
|
|
@ -30,7 +30,7 @@ const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)`
|
|||
// against --chat-min-width so the chat surface never gets squeezed below it.
|
||||
// Subtracts the project browser width so preview yields rather than crushing
|
||||
// the chat when both right-side panes are open.
|
||||
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0px, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0px) - var(--chat-min-width))))`
|
||||
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0rem, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0rem) - var(--chat-min-width))))`
|
||||
|
||||
interface ChatPreviewRailProps {
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
|
|
@ -79,9 +79,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--glass-editor-surface-background) text-(--ui-text-tertiary)">
|
||||
<div
|
||||
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-(--ui-stroke-tertiary) bg-(--glass-sidebar-surface-background) [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map(tab => {
|
||||
|
|
@ -90,35 +90,36 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
|
||||
active
|
||||
? 'bg-background text-foreground'
|
||||
: 'border-r border-border/40 text-muted-foreground hover:bg-accent/30 hover:text-foreground'
|
||||
? 'bg-(--glass-editor-surface-background) text-foreground [--tab-bg:var(--glass-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--glass-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground hover:[--tab-bg:var(--chrome-action-hover)]'
|
||||
)}
|
||||
key={tab.id}
|
||||
>
|
||||
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-primary/70" />}
|
||||
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />}
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 flex-1 items-center truncate pl-3 pr-1.5 text-left outline-none"
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
title={tab.label}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
<button
|
||||
aria-label={`Close ${tab.label}`}
|
||||
className={cn(
|
||||
'mr-1.5 hidden size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/55 transition-colors hover:bg-accent hover:text-foreground focus-visible:grid group-hover/tab:grid',
|
||||
active && 'grid'
|
||||
)}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
title={`Close ${tab.label}`}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,28 @@
|
|||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
|
@ -14,19 +34,28 @@ import {
|
|||
} from '@/components/ui/sidebar'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { Brain, ChevronDown, Layers3, MessageCircle, Plus, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
$sidebarAgentsGrouped,
|
||||
$sidebarOpen,
|
||||
$sidebarPinsOpen,
|
||||
$sidebarRecentsOpen,
|
||||
pinSession,
|
||||
reorderPinnedSession,
|
||||
setSidebarAgentsGrouped,
|
||||
setSidebarPinsOpen,
|
||||
setSidebarRecentsOpen,
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '@/store/layout'
|
||||
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
|
||||
import {
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
$sessionsTotal,
|
||||
$workingSessionIds
|
||||
} from '@/store/session'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
|
|
@ -35,21 +64,79 @@ import type { SidebarNavItem } from '../../types'
|
|||
import { SidebarSessionRow } from './session-row'
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
label: 'New chat',
|
||||
icon: Plus,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
|
||||
{ id: 'messaging', label: 'Messaging', icon: MessageCircle, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
|
||||
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
|
||||
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
|
||||
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||
]
|
||||
|
||||
const WORKSPACE_PAGE = 5
|
||||
const WS_ID_PREFIX = 'workspace:'
|
||||
|
||||
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
|
||||
const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null)
|
||||
const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded))
|
||||
const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0
|
||||
|
||||
function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[]): T[] {
|
||||
if (!orderIds.length) {
|
||||
return items
|
||||
}
|
||||
|
||||
const byId = new Map(items.map(item => [getId(item), item]))
|
||||
const seen = new Set<string>()
|
||||
const out: T[] = []
|
||||
|
||||
for (const id of orderIds) {
|
||||
const item = byId.get(id)
|
||||
|
||||
if (item) {
|
||||
out.push(item)
|
||||
seen.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (!seen.has(getId(item))) {
|
||||
out.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim() || ''
|
||||
const id = path || '__no_workspace__'
|
||||
const label = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop() || path || 'No workspace'
|
||||
|
||||
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
|
||||
group.sessions.push(session)
|
||||
groups.set(id, group)
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
function useSortableBindings(id: string) {
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
||||
|
||||
return {
|
||||
dragging: isDragging,
|
||||
dragHandleProps: { ...attributes, ...listeners },
|
||||
ref: setNodeRef,
|
||||
reorderable: true as const,
|
||||
style: { transform: CSS.Transform.toString(transform), transition }
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
currentView: AppView
|
||||
onNavigate: (item: SidebarNavItem) => void
|
||||
onRefreshSessions: () => void
|
||||
onLoadMoreSessions: () => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
}
|
||||
|
|
@ -57,63 +144,136 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|||
export function ChatSidebar({
|
||||
currentView,
|
||||
onNavigate,
|
||||
onRefreshSessions,
|
||||
onLoadMoreSessions,
|
||||
onResumeSession,
|
||||
onDeleteSession
|
||||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
const recentsOpen = useStore($sidebarRecentsOpen)
|
||||
const agentsOpen = useStore($sidebarRecentsOpen)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
||||
const sessions = useStore($sessions)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const sessionsTotal = useStore($sessionsTotal)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
|
||||
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
|
||||
|
||||
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
||||
|
||||
const dndSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const sortedSessions = useMemo(
|
||||
() =>
|
||||
[...sessions].sort((a, b) => {
|
||||
const aTime = a.last_active || a.started_at || 0
|
||||
const bTime = b.last_active || b.started_at || 0
|
||||
|
||||
return bTime - aTime
|
||||
}),
|
||||
() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
|
||||
[sessions]
|
||||
)
|
||||
|
||||
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
|
||||
const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
|
||||
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
|
||||
const visiblePinnedIds = pinnedSessionIds.filter(id => sessionsById.has(id))
|
||||
const visiblePinnedIdSet = new Set(visiblePinnedIds)
|
||||
const visiblePinnedIds = useMemo(
|
||||
() => pinnedSessionIds.filter(id => sessionsById.has(id)),
|
||||
[pinnedSessionIds, sessionsById]
|
||||
)
|
||||
const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds])
|
||||
|
||||
const pinnedSessions = visiblePinnedIds
|
||||
.map(id => sessionsById.get(id))
|
||||
.filter((session): session is SessionInfo => Boolean(session))
|
||||
const pinnedSessions = useMemo(
|
||||
() => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean),
|
||||
[visiblePinnedIds, sessionsById]
|
||||
)
|
||||
|
||||
const recentSessions = sortedSessions.filter(session => !visiblePinnedIdSet.has(session.id))
|
||||
const unpinnedAgentSessions = useMemo(
|
||||
() => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)),
|
||||
[sortedSessions, visiblePinnedIdSet]
|
||||
)
|
||||
|
||||
const agentSessions = useMemo(
|
||||
() => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds),
|
||||
[unpinnedAgentSessions, agentOrderIds]
|
||||
)
|
||||
|
||||
const agentGroups = useMemo(
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, workspaceOrderIds]
|
||||
)
|
||||
|
||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||
const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length)
|
||||
const hasMoreSessions = knownSessionTotal > sortedSessions.length
|
||||
const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length)
|
||||
|
||||
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const newIndex = pinnedSessions.findIndex(s => s.id === String(over.id))
|
||||
|
||||
if (newIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
reorderPinnedSession(String(active.id), newIndex)
|
||||
}
|
||||
|
||||
const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeId = String(active.id)
|
||||
const overId = String(over.id)
|
||||
const activeWs = parseWsId(activeId)
|
||||
const overWs = parseWsId(overId)
|
||||
|
||||
if (activeWs && overWs) {
|
||||
const oldIdx = agentGroups.findIndex(g => g.id === activeWs)
|
||||
const newIdx = agentGroups.findIndex(g => g.id === overWs)
|
||||
|
||||
if (oldIdx < 0 || newIdx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (activeWs || overWs) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldIdx = agentSessions.findIndex(s => s.id === activeId)
|
||||
const newIdx = agentSessions.findIndex(s => s.id === overId)
|
||||
|
||||
if (oldIdx < 0 || newIdx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
|
||||
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none',
|
||||
sidebarOpen
|
||||
? 'border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
|
||||
? 'border-(--sidebar-edge-border) bg-(--glass-sidebar-surface-background) opacity-100'
|
||||
: 'pointer-events-none border-transparent bg-transparent opacity-0'
|
||||
)}
|
||||
collapsible="none"
|
||||
>
|
||||
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-(--sidebar-content-inline-padding)">
|
||||
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
|
||||
<SidebarPanelLabel className="pb-1 pt-1">Workspace</SidebarPanelLabel>
|
||||
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-2.5">
|
||||
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-px">
|
||||
{SIDEBAR_NAV.map(item => {
|
||||
const isInteractive = Boolean(item.action) || Boolean(item.route)
|
||||
|
||||
const active =
|
||||
(item.id === 'skills' && currentView === 'skills') ||
|
||||
(item.id === 'messaging' && currentView === 'messaging') ||
|
||||
|
|
@ -124,9 +284,9 @@ export function ChatSidebar({
|
|||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-sidebar-foreground/78 transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-(--chrome-action-hover) hover:text-foreground hover:transition-none',
|
||||
'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--chrome-action-hover) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-midground)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)] hover:border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))]!',
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
|
||||
)}
|
||||
|
|
@ -135,7 +295,14 @@ export function ChatSidebar({
|
|||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{sidebarOpen && <span className="max-[46.25rem]:hidden">{item.label}</span>}
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
{item.id === 'new-session' && (
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
|
|
@ -145,102 +312,103 @@ export function ChatSidebar({
|
|||
</SidebarGroup>
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<SidebarGroup className="shrink-0 p-0 pb-1">
|
||||
<SidebarSectionHeader label="Pinned" onToggle={() => setSidebarPinsOpen(!pinsOpen)} open={pinsOpen} />
|
||||
{pinsOpen && (
|
||||
<SidebarGroupContent className="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1">
|
||||
{pinnedSessions.length === 0 && (
|
||||
<div className="italic flex min-h-7 items-center gap-2 rounded-lg pl-2 text-xs text-muted-foreground/80">
|
||||
<span>Shift click to pin a chat</span>
|
||||
</div>
|
||||
)}
|
||||
{pinnedSessions.map(session => (
|
||||
<SidebarSessionRow
|
||||
isPinned
|
||||
isSelected={session.id === activeSidebarSessionId}
|
||||
isWorking={workingSessionIdSet.has(session.id)}
|
||||
key={session.id}
|
||||
onDelete={() => onDeleteSession(session.id)}
|
||||
onPin={() => unpinSession(session.id)}
|
||||
onResume={() => onResumeSession(session.id)}
|
||||
session={session}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label="Pinned"
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
|
||||
onTogglePin={unpinSession}
|
||||
open={pinsOpen}
|
||||
pinned
|
||||
rootClassName="shrink-0 p-0 pb-1"
|
||||
sessions={pinnedSessions}
|
||||
sortable={pinnedSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<SidebarGroup className="min-h-0 flex-1 p-0">
|
||||
<SidebarSectionHeader
|
||||
action={
|
||||
<Button
|
||||
aria-label={sessionsLoading ? 'Refreshing sessions' : 'Refresh sessions'}
|
||||
className="size-4 rounded-sm p-0 text-muted-foreground opacity-10 hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 disabled:opacity-35 [&_svg]:size-3!"
|
||||
disabled={sessionsLoading}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
onRefreshSessions()
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn(sessionsLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
}
|
||||
label="Recent chats"
|
||||
onToggle={() => setSidebarRecentsOpen(!recentsOpen)}
|
||||
open={recentsOpen}
|
||||
/>
|
||||
|
||||
{recentsOpen && (
|
||||
<SidebarGroupContent className="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
|
||||
{showSessionSkeletons && <SidebarSessionSkeletons />}
|
||||
{!showSessionSkeletons && recentSessions.length === 0 && <SidebarAllPinnedState />}
|
||||
{recentSessions.map(session => (
|
||||
<SidebarSessionRow
|
||||
isPinned={false}
|
||||
isSelected={session.id === activeSidebarSessionId}
|
||||
isWorking={workingSessionIdSet.has(session.id)}
|
||||
key={session.id}
|
||||
onDelete={() => onDeleteSession(session.id)}
|
||||
onPin={() => pinSession(session.id)}
|
||||
onResume={() => onResumeSession(session.id)}
|
||||
session={session}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
dndSensors={dndSensors}
|
||||
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
|
||||
footer={
|
||||
!agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
|
||||
<SidebarLoadMoreRow
|
||||
loading={sessionsLoading}
|
||||
onClick={onLoadMoreSessions}
|
||||
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
forceEmptyState={showSessionSkeletons}
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-bg-tertiary) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
}
|
||||
label="Agents"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handleAgentDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||
onTogglePin={pinSession}
|
||||
open={agentsOpen}
|
||||
pinned={false}
|
||||
rootClassName="min-h-0 flex-1 p-0"
|
||||
sessions={agentSessions}
|
||||
sortable={agentSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> {
|
||||
interface SidebarSectionHeaderProps {
|
||||
label: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
action?: React.ReactNode
|
||||
meta?: React.ReactNode
|
||||
}
|
||||
|
||||
function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
|
||||
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||
<button
|
||||
className="group/section-label flex w-fit items-center gap-2 bg-transparent text-left leading-none"
|
||||
className="group/section-label flex w-fit cursor-pointer items-center gap-1 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/70 opacity-0 transition group-hover/section-label:opacity-100',
|
||||
!open && '-rotate-90'
|
||||
)}
|
||||
{meta && <SidebarCount>{meta}</SidebarCount>}
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
{action}
|
||||
|
|
@ -262,7 +430,276 @@ function SidebarSessionSkeletons() {
|
|||
}
|
||||
|
||||
const SidebarAllPinnedState = () => (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-muted-foreground">
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
|
||||
Everything here is pinned. Unpin a chat to show it in recents.
|
||||
</div>
|
||||
)
|
||||
|
||||
function SidebarPinnedEmptyState() {
|
||||
return (
|
||||
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>Shift click to pin a chat</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarSessionGroup {
|
||||
id: string
|
||||
label: string
|
||||
path: null | string
|
||||
sessions: SessionInfo[]
|
||||
}
|
||||
|
||||
interface SidebarSessionsSectionProps {
|
||||
label: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
sessions: SessionInfo[]
|
||||
activeSessionId: null | string
|
||||
workingSessionIdSet: Set<string>
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
pinned: boolean
|
||||
rootClassName?: string
|
||||
contentClassName?: string
|
||||
emptyState: React.ReactNode
|
||||
forceEmptyState?: boolean
|
||||
headerAction?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
groups?: SidebarSessionGroup[]
|
||||
labelMeta?: React.ReactNode
|
||||
sortable?: boolean
|
||||
onReorder?: (event: DragEndEvent) => void
|
||||
dndSensors?: ReturnType<typeof useSensors>
|
||||
}
|
||||
|
||||
function SidebarSessionsSection({
|
||||
label,
|
||||
open,
|
||||
onToggle,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
workingSessionIdSet,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onTogglePin,
|
||||
pinned,
|
||||
rootClassName,
|
||||
contentClassName,
|
||||
emptyState,
|
||||
forceEmptyState = false,
|
||||
headerAction,
|
||||
footer,
|
||||
groups,
|
||||
labelMeta,
|
||||
sortable = false,
|
||||
onReorder,
|
||||
dndSensors
|
||||
}: SidebarSessionsSectionProps) {
|
||||
const showEmptyState = forceEmptyState || sessions.length === 0
|
||||
const dndActive = sortable && !!onReorder
|
||||
|
||||
const renderRow = (session: SessionInfo) => {
|
||||
const rowProps = {
|
||||
isPinned: pinned,
|
||||
isSelected: session.id === activeSessionId,
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onResume: () => onResumeSession(session.id),
|
||||
session
|
||||
}
|
||||
|
||||
return sortable ? (
|
||||
<SortableSidebarSessionRow key={session.id} {...rowProps} />
|
||||
) : (
|
||||
<SidebarSessionRow key={session.id} {...rowProps} />
|
||||
)
|
||||
}
|
||||
|
||||
const renderRows = (items: SessionInfo[]) => items.map(renderRow)
|
||||
|
||||
const renderSessionList = (items: SessionInfo[]) =>
|
||||
dndActive ? (
|
||||
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
|
||||
{renderRows(items)}
|
||||
</SortableContext>
|
||||
) : (
|
||||
renderRows(items)
|
||||
)
|
||||
|
||||
let inner: React.ReactNode
|
||||
|
||||
if (showEmptyState) {
|
||||
inner = emptyState
|
||||
} else if (groups?.length) {
|
||||
const groupNodes = groups.map(group =>
|
||||
dndActive ? (
|
||||
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
) : (
|
||||
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
)
|
||||
)
|
||||
|
||||
inner = dndActive ? (
|
||||
<SortableContext items={groups.map(g => wsId(g.id))} strategy={verticalListSortingStrategy}>
|
||||
{groupNodes}
|
||||
</SortableContext>
|
||||
) : (
|
||||
groupNodes
|
||||
)
|
||||
} else {
|
||||
inner = renderSessionList(sessions)
|
||||
}
|
||||
|
||||
const body =
|
||||
dndActive && !showEmptyState ? (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
|
||||
{inner}
|
||||
</DndContext>
|
||||
) : (
|
||||
inner
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarGroup className={rootClassName}>
|
||||
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
|
||||
{open && (
|
||||
<SidebarGroupContent className={contentClassName}>
|
||||
{body}
|
||||
{footer}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
reorderable?: boolean
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
function SidebarWorkspaceGroup({
|
||||
group,
|
||||
renderRows,
|
||||
reorderable = false,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
className,
|
||||
style,
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarWorkspaceGroupProps) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
|
||||
const visibleSessions = group.sessions.slice(0, visibleCount)
|
||||
const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length)
|
||||
const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount)
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<button
|
||||
className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
{reorderable && (
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
aria-label={`Reorder workspace ${group.label}`}
|
||||
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<Codicon
|
||||
className={cn(
|
||||
'text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/workspace:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
dragging && 'text-(--ui-text-secondary) opacity-100'
|
||||
)}
|
||||
name="grabber"
|
||||
size="0.75rem"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
{renderRows(visibleSessions)}
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
aria-label={`Show ${nextCount} more in ${group.label}`}
|
||||
className="ml-auto grid size-5 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
|
||||
title={`Show ${nextCount} more in ${group.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SortableWorkspaceProps {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
}
|
||||
|
||||
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
|
||||
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(wsId(props.group.id))} />
|
||||
}
|
||||
|
||||
function SidebarCount({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
|
||||
}
|
||||
|
||||
interface SortableSessionRowProps {
|
||||
session: SessionInfo
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
}
|
||||
|
||||
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
|
||||
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
|
||||
}
|
||||
|
||||
interface SidebarLoadMoreRowProps {
|
||||
loading: boolean
|
||||
onClick: () => void
|
||||
step: number
|
||||
}
|
||||
|
||||
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
|
||||
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-5 cursor-pointer items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { IconBookmark, IconBookmarkFilled, IconCircleX, IconFileDownload, IconPencil } from '@tabler/icons-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type * as React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -17,14 +17,13 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Pin } from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
|
||||
|
|
@ -56,54 +55,46 @@ export function SessionActionsMenu({
|
|||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
|
||||
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-40" sideOffset={sideOffset}>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!onPin}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}}
|
||||
>
|
||||
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
|
||||
<Pin className="size-3.5" strokeWidth={1.75} />
|
||||
<span>{pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</DropdownMenuItem>
|
||||
<CopyButton
|
||||
appearance="menu-item"
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!sessionId}
|
||||
errorMessage="Could not copy session ID"
|
||||
label="Copy ID"
|
||||
text={sessionId}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
}}
|
||||
>
|
||||
<IconFileDownload />
|
||||
<Codicon name="cloud-download" size="0.875rem" />
|
||||
<span>Export</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
}}
|
||||
>
|
||||
<IconPencil />
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-3" />
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4',
|
||||
'text-destructive focus:text-destructive'
|
||||
)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={!onDelete}
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
|
|
@ -111,7 +102,7 @@ export function SessionActionsMenu({
|
|||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<IconCircleX />
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { MoreVertical } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { SessionActionsMenu } from './session-actions-menu'
|
||||
|
||||
const SECOND = 1000
|
||||
const MINUTE = 60 * SECOND
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
session: SessionInfo
|
||||
isPinned: boolean
|
||||
|
|
@ -17,6 +22,28 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
|||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
reorderable?: boolean
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
function formatAge(seconds: number): string {
|
||||
const at = seconds * 1000
|
||||
const delta = Math.max(0, Date.now() - at)
|
||||
|
||||
if (delta < MINUTE) {
|
||||
return 'now'
|
||||
}
|
||||
|
||||
if (delta < HOUR) {
|
||||
return `${Math.floor(delta / MINUTE)}m`
|
||||
}
|
||||
|
||||
if (delta < DAY) {
|
||||
return `${Math.floor(delta / HOUR)}h`
|
||||
}
|
||||
|
||||
return `${Math.floor(delta / DAY)}d`
|
||||
}
|
||||
|
||||
export function SidebarSessionRow({
|
||||
|
|
@ -26,23 +53,35 @@ export function SidebarSessionRow({
|
|||
isWorking,
|
||||
onDelete,
|
||||
onPin,
|
||||
onResume
|
||||
onResume,
|
||||
reorderable = false,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
className,
|
||||
style,
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarSessionRowProps) {
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative grid min-h-7 cursor-pointer grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
|
||||
'after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-1 after:w-18 after:rounded-[inherit] after:bg-linear-to-r after:from-transparent after:via-[color-mix(in_srgb,var(--dt-sidebar-bg)_78%,transparent)] after:to-[color-mix(in_srgb,var(--dt-sidebar-bg)_96%,transparent)] after:opacity-0 after:transition-opacity after:duration-200 after:ease-out hover:after:opacity-100 focus-within:after:opacity-100',
|
||||
isSelected && 'bg-accent',
|
||||
isWorking && 'text-foreground'
|
||||
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-bg-quinary) hover:transition-none',
|
||||
isSelected && 'bg-(--ui-bg-tertiary)',
|
||||
isWorking && 'text-foreground',
|
||||
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
|
||||
className
|
||||
)}
|
||||
data-working={isWorking ? 'true' : undefined}
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{isWorking && <span aria-hidden="true" className="arc-border" />}
|
||||
<button
|
||||
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-1 pl-2 text-left"
|
||||
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
|
||||
onClick={event => {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
|
@ -57,28 +96,67 @@ export function SidebarSessionRow({
|
|||
}}
|
||||
type="button"
|
||||
>
|
||||
{isWorking && (
|
||||
{reorderable ? (
|
||||
<span
|
||||
aria-label="Session running"
|
||||
className="relative size-1.5 shrink-0 rounded-full bg-midground shadow-[0_0_0.625rem_color-mix(in_srgb,var(--dt-midground)_65%,transparent)] before:absolute before:inset-0 before:rounded-full before:bg-midground before:opacity-75 before:content-[''] before:animate-ping"
|
||||
role="status"
|
||||
/>
|
||||
{...dragHandleProps}
|
||||
aria-label={handleLabel}
|
||||
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<SidebarRowDot
|
||||
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
|
||||
isWorking={isWorking}
|
||||
/>
|
||||
<Codicon
|
||||
className={cn(
|
||||
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
dragging && 'text-(--ui-text-secondary) opacity-100'
|
||||
)}
|
||||
name="grabber"
|
||||
size="0.75rem"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
|
||||
<SidebarRowDot isWorking={isWorking} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
|
||||
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
<div className="relative z-2 grid w-6 place-items-center">
|
||||
<div className="relative z-2 grid w-[1.375rem] place-items-center">
|
||||
{!isWorking && (
|
||||
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{age}
|
||||
</span>
|
||||
)}
|
||||
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-6 rounded-md bg-transparent text-transparent transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
|
||||
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-bg-tertiary) hover:text-foreground focus-visible:bg-(--ui-bg-tertiary) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-bg-tertiary) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title="Session actions"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreVertical size={15} />
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? 'Session running' : undefined}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isWorking ? 'size-1.5 bg-(--ui-green)' : 'size-1 bg-(--ui-text-quaternary) opacity-80',
|
||||
className
|
||||
)}
|
||||
role={isWorking ? 'status' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ interface SectionSearchEntry {
|
|||
}
|
||||
|
||||
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ import {
|
|||
triggerCronJob,
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { AlertTriangle, Clock, Pause, Pencil, Play, Plus, RefreshCw, Search, Trash2, X, Zap } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
|
|
@ -295,12 +295,10 @@ function matchesQuery(job: CronJob, q: string): boolean {
|
|||
|
||||
interface CronViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function CronView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: CronViewProps) {
|
||||
const [jobs, setJobs] = useState<CronJob[] | null>(null)
|
||||
|
|
@ -329,24 +327,6 @@ export function CronView({
|
|||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarToolGroup('cron', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
|
||||
id: 'refresh-cron',
|
||||
label: refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs',
|
||||
onSelect: () => void refresh()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarToolGroup('cron', [])
|
||||
}, [refresh, refreshing, setTitlebarToolGroup])
|
||||
|
||||
const visibleJobs = useMemo(() => {
|
||||
if (!jobs) {
|
||||
return []
|
||||
|
|
@ -437,78 +417,66 @@ export function CronView({
|
|||
}
|
||||
|
||||
return (
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Cron</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Plus />
|
||||
New cron
|
||||
</Button>
|
||||
|
||||
<div className="ml-auto w-full max-w-sm min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 rounded-lg pl-8 pr-8 text-sm"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search cron jobs..."
|
||||
value={query}
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setQuery('')}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search cron jobs..."
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refresh()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
totalCount === 0
|
||||
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
|
||||
: 'Try a broader search query.'
|
||||
}
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
busy={busyJobId === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job })}
|
||||
onPauseResume={() => void handlePauseResume(job)}
|
||||
onTrigger={() => void handleTrigger(job)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
totalCount === 0
|
||||
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
|
||||
: 'Try a broader search query.'
|
||||
}
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
busy={busyJobId === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job })}
|
||||
onPauseResume={() => void handlePauseResume(job)}
|
||||
onTrigger={() => void handleTrigger(job)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<div className="hidden">
|
||||
{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}
|
||||
</div>
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
|
@ -536,7 +504,7 @@ export function CronView({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</PageSearchShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -656,7 +624,7 @@ function EmptyState({
|
|||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
{actionLabel && onAction && (
|
||||
<Button className="mt-2" onClick={onAction} size="sm">
|
||||
<Plus />
|
||||
<Codicon name="add" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { getSessionMessages, listSessions } from '../hermes'
|
|||
import { toChatMessages } from '../lib/chat-messages'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
$sessionsLimit,
|
||||
bumpSessionsLimit,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
FILE_BROWSER_MAX_WIDTH,
|
||||
FILE_BROWSER_MIN_WIDTH,
|
||||
|
|
@ -33,7 +35,8 @@ import {
|
|||
setCurrentProvider,
|
||||
setMessages,
|
||||
setSessions,
|
||||
setSessionsLoading
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
} from '../store/session'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
||||
|
||||
|
|
@ -46,10 +49,10 @@ import {
|
|||
PREVIEW_RAIL_PANE_WIDTH
|
||||
} from './chat/right-rail'
|
||||
import { ChatSidebar } from './chat/sidebar'
|
||||
import { FileBrowserPane } from './file-browser'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
|
||||
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
||||
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
||||
|
|
@ -182,10 +185,12 @@ export function DesktopController() {
|
|||
setSessionsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await listSessions(50)
|
||||
const limit = $sessionsLimit.get()
|
||||
const result = await listSessions(limit)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(result.sessions)
|
||||
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
|
||||
}
|
||||
} finally {
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
|
|
@ -194,6 +199,11 @@ export function DesktopController() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const loadMoreSessions = useCallback(() => {
|
||||
bumpSessionsLimit()
|
||||
void refreshSessions()
|
||||
}, [refreshSessions])
|
||||
|
||||
const toggleSelectedPin = useCallback(() => {
|
||||
const sessionId = $selectedStoredSessionId.get()
|
||||
|
||||
|
|
@ -210,10 +220,27 @@ export function DesktopController() {
|
|||
|
||||
const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
|
||||
|
||||
const { browseSessionCwd, changeSessionCwd, refreshProjectBranch } = useCwdActions({
|
||||
const updateActiveSessionRuntimeInfo = useCallback(
|
||||
(info: { branch?: string; cwd?: string }) => {
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
branch: info.branch ?? state.branch,
|
||||
cwd: info.cwd ?? state.cwd
|
||||
}))
|
||||
},
|
||||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const { changeSessionCwd, refreshProjectBranch } = useCwdActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
currentCwd,
|
||||
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
|
|
@ -315,6 +342,31 @@ export function DesktopController() {
|
|||
updateSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
|
||||
const editing =
|
||||
target?.isContentEditable ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
|
||||
if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey && event.code === 'KeyN') {
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [startFreshSessionDraft])
|
||||
|
||||
const composer = useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
|
|
@ -388,7 +440,6 @@ export function DesktopController() {
|
|||
|
||||
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
||||
agentsOpen,
|
||||
browseSessionCwd,
|
||||
commandCenterOpen,
|
||||
extraLeftItems: statusbarItemGroups.flat.left,
|
||||
extraRightItems: statusbarItemGroups.flat.right,
|
||||
|
|
@ -405,8 +456,8 @@ export function DesktopController() {
|
|||
<ChatSidebar
|
||||
currentView={currentView}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onNavigate={selectSidebarItem}
|
||||
onRefreshSessions={() => void refreshSessions()}
|
||||
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
)
|
||||
|
|
@ -497,8 +548,10 @@ export function DesktopController() {
|
|||
|
||||
return (
|
||||
<AppShell
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
leftStatusbarItems={leftStatusbarItems}
|
||||
leftTitlebarTools={titlebarToolGroups.flat.left}
|
||||
onOpenSearch={() => openCommandCenterSection('sessions')}
|
||||
onOpenSettings={openSettings}
|
||||
overlays={overlays}
|
||||
statusbarItems={statusbarItems}
|
||||
|
|
@ -521,7 +574,7 @@ export function DesktopController() {
|
|||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} />
|
||||
</Suspense>
|
||||
}
|
||||
path="skills"
|
||||
|
|
@ -529,10 +582,7 @@ export function DesktopController() {
|
|||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<MessagingView
|
||||
setStatusbarItemGroup={setStatusbarItemGroup}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
/>
|
||||
<MessagingView setStatusbarItemGroup={setStatusbarItemGroup} />
|
||||
</Suspense>
|
||||
}
|
||||
path="messaging"
|
||||
|
|
@ -540,10 +590,7 @@ export function DesktopController() {
|
|||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<ArtifactsView
|
||||
setStatusbarItemGroup={setStatusbarItemGroup}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
/>
|
||||
<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} />
|
||||
</Suspense>
|
||||
}
|
||||
path="artifacts"
|
||||
|
|
@ -551,7 +598,7 @@ export function DesktopController() {
|
|||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<CronView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
<CronView setStatusbarItemGroup={setStatusbarItemGroup} />
|
||||
</Suspense>
|
||||
}
|
||||
path="cron"
|
||||
|
|
@ -590,6 +637,7 @@ export function DesktopController() {
|
|||
</Pane>
|
||||
<Pane
|
||||
defaultOpen={false}
|
||||
disabled={!chatOpen}
|
||||
id="file-browser"
|
||||
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
||||
minWidth={FILE_BROWSER_MIN_WIDTH}
|
||||
|
|
@ -597,7 +645,12 @@ export function DesktopController() {
|
|||
side="right"
|
||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||
>
|
||||
<FileBrowserPane onActivateFile={composer.attachContextFilePath} onChangeCwd={changeSessionCwd} />
|
||||
<RightSidebarPane
|
||||
onActivateFile={composer.attachContextFilePath}
|
||||
onActivateFolder={composer.attachContextFolderPath}
|
||||
onAddTerminalSelection={composer.addTerminalSelectionAttachment}
|
||||
onChangeCwd={changeSessionCwd}
|
||||
/>
|
||||
</Pane>
|
||||
</AppShell>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,173 +0,0 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { FolderOpen, RefreshCw } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { ProjectTree } from './tree'
|
||||
import { useProjectTree } from './use-project-tree'
|
||||
|
||||
interface FileBrowserPaneProps {
|
||||
/** Activates a file row — drops the path into the composer as `@file:` ref. */
|
||||
onActivateFile: (path: string) => void
|
||||
onChangeCwd: (path: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPaneProps) {
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
|
||||
const cwdName = hasCwd
|
||||
? (currentCwd
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? currentCwd)
|
||||
: 'No folder selected'
|
||||
|
||||
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Change working directory',
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false
|
||||
})
|
||||
|
||||
if (selected?.[0]) {
|
||||
await onChangeCwd(selected[0])
|
||||
}
|
||||
}
|
||||
|
||||
const previewFile = async (path: string) => {
|
||||
try {
|
||||
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not preview ${path}`)
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="File browser"
|
||||
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] px-(--sidebar-content-inline-padding) pt-[calc(var(--titlebar-height)-0.625rem)] text-muted-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]"
|
||||
>
|
||||
<header className="group/project-header shrink-0 pb-1 pt-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FadeText
|
||||
className="flex flex-1 items-center px-2 pb-1 pt-1"
|
||||
title={hasCwd ? currentCwd : 'No folder selected'}
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</FadeText>
|
||||
<Button
|
||||
aria-label="Change working directory"
|
||||
className="pointer-events-none size-6 shrink-0 text-muted-foreground/75 opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
|
||||
onClick={() => void chooseFolder()}
|
||||
size="icon"
|
||||
title="Change working directory"
|
||||
variant="ghost"
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className="pointer-events-none size-6 shrink-0 text-muted-foreground/75 opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
|
||||
disabled={!hasCwd || rootLoading}
|
||||
onClick={() => void refreshRoot()}
|
||||
size="icon"
|
||||
title="Refresh tree"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', rootLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<FileTreeBody
|
||||
cwd={currentCwd}
|
||||
data={data}
|
||||
error={rootError}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
cwd: string
|
||||
data: ReturnType<typeof useProjectTree>['data']
|
||||
error: string | null
|
||||
loading: boolean
|
||||
onActivateFile: (path: string) => void
|
||||
onLoadChildren: (id: string) => void | Promise<void>
|
||||
onNodeOpenChange: (id: string, open: boolean) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
openState: ReturnType<typeof useProjectTree>['openState']
|
||||
}
|
||||
|
||||
function FileTreeBody({
|
||||
cwd,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
onActivateFile,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
openState
|
||||
}: FileTreeBodyProps) {
|
||||
if (!cwd) {
|
||||
return <EmptyState body="Set a working directory from the status bar to browse files." title="No project" />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyState body={`Could not read this folder (${error}).`} title="Unreadable" />
|
||||
}
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
return <EmptyState body="Reading project…" title="Loading" />
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState body="This folder is empty." title="Empty" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectTree
|
||||
data={data}
|
||||
onActivateFile={onActivateFile}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ body, title }: { body: string; title: string }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
|
||||
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
|
||||
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,18 +12,17 @@ import {
|
|||
type MessagingPlatformInfo,
|
||||
updateMessagingPlatform
|
||||
} from '@/hermes'
|
||||
import { AlertTriangle, ChevronDown, ExternalLink, RefreshCw, Save, Trash2 } from '@/lib/icons'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
interface MessagingViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
type EditMap = Record<string, Record<string, string>>
|
||||
|
|
@ -209,11 +208,11 @@ function fieldCopy(field: MessagingEnvVarInfo) {
|
|||
|
||||
export function MessagingView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: MessagingViewProps) {
|
||||
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
|
||||
const [edits, setEdits] = useState<EditMap>({})
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const platformIds = useMemo(() => platforms?.map(p => p.id) ?? [], [platforms])
|
||||
|
|
@ -263,24 +262,6 @@ export function MessagingView({
|
|||
}
|
||||
}, [refreshPlatforms])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarToolGroup('messaging', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
|
||||
id: 'refresh-messaging',
|
||||
label: refreshing ? 'Refreshing messaging' : 'Refresh messaging',
|
||||
onSelect: () => void refreshPlatforms()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarToolGroup('messaging', [])
|
||||
}, [refreshPlatforms, refreshing, setTitlebarToolGroup])
|
||||
|
||||
const selected = useMemo(() => {
|
||||
if (!platforms) {
|
||||
return null
|
||||
|
|
@ -289,7 +270,23 @@ export function MessagingView({
|
|||
return platforms.find(platform => platform.id === selectedId) || platforms[0] || null
|
||||
}, [platforms, selectedId])
|
||||
|
||||
const enabledCount = platforms?.filter(platform => platform.enabled).length || 0
|
||||
const visiblePlatforms = useMemo(() => {
|
||||
if (!platforms) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
if (!q) {
|
||||
return platforms
|
||||
}
|
||||
|
||||
return platforms.filter(platform =>
|
||||
[platform.id, platform.name, platform.description, platform.state]
|
||||
.filter(Boolean)
|
||||
.some(value => String(value).toLowerCase().includes(q))
|
||||
)
|
||||
}, [platforms, query])
|
||||
|
||||
async function handleToggle(platform: MessagingPlatformInfo, enabled: boolean) {
|
||||
setSaving(`enabled:${platform.id}`)
|
||||
|
|
@ -367,22 +364,20 @@ export function MessagingView({
|
|||
}
|
||||
|
||||
return (
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Messaging</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
{enabledCount === 0 ? 'No platforms enabled' : `${enabledCount} enabled`}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
|
||||
{!platforms ? (
|
||||
<PageLoader label="Loading messaging platforms..." />
|
||||
) : (
|
||||
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto border-b border-border/50 p-2 lg:border-b-0 lg:border-r">
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search messaging..."
|
||||
searchTrailingAction={null}
|
||||
searchValue={query}
|
||||
>
|
||||
{!platforms ? (
|
||||
<PageLoader label="Loading messaging platforms..." />
|
||||
) : (
|
||||
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r">
|
||||
<ul className="space-y-1">
|
||||
{platforms.map(platform => (
|
||||
{visiblePlatforms.map(platform => (
|
||||
<li key={platform.id}>
|
||||
<PlatformRow
|
||||
active={selected?.id === platform.id}
|
||||
|
|
@ -416,9 +411,8 @@ export function MessagingView({
|
|||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</PageSearchShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -434,15 +428,15 @@ function PlatformRow({
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">{platform.name}</span>
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-normal">{platform.name}</span>
|
||||
<StatusDot tone={stateTone(platform)} />
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -453,8 +447,8 @@ function PlatformAvatar({ platformId, platformName }: { platformId: string; plat
|
|||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-7 shrink-0 items-center justify-center rounded-md text-sm font-semibold',
|
||||
PLATFORM_TINTS[platformId] || 'bg-muted text-muted-foreground'
|
||||
'inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
PLATFORM_TINTS[platformId] || 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
|
|
@ -491,12 +485,12 @@ function PlatformDetail({
|
|||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-7 px-6 py-6">
|
||||
<header className="flex items-start gap-4">
|
||||
<div className="mx-auto max-w-2xl space-y-5 px-5 py-4">
|
||||
<header className="flex items-start gap-3">
|
||||
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{platform.name}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{platform.description}</p>
|
||||
<h3 className="text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{platform.description}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
|
||||
<SetupPill active={platform.configured}>
|
||||
|
|
@ -509,7 +503,7 @@ function PlatformDetail({
|
|||
</header>
|
||||
|
||||
{platform.error_message && (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-xs leading-5 text-destructive">
|
||||
<div className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{platform.error_message}</span>
|
||||
</div>
|
||||
|
|
@ -517,7 +511,7 @@ function PlatformDetail({
|
|||
|
||||
<section>
|
||||
<SectionTitle>Get your credentials</SectionTitle>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{introCopy(platform)}</p>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{introCopy(platform)}</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={platform.docs_url} rel="noreferrer" target="_blank">
|
||||
|
|
@ -543,7 +537,7 @@ function PlatformDetail({
|
|||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
This platform does not need a token here. Use the setup guide above, then enable it below.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -576,7 +570,7 @@ function PlatformDetail({
|
|||
type="button"
|
||||
>
|
||||
<span>Advanced ({hiddenCount})</span>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')} />
|
||||
<DisclosureCaret open={showAdvanced} size="0.875rem" />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-4">
|
||||
|
|
@ -597,9 +591,9 @@ function PlatformDetail({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-border/50 bg-background/95 px-6 py-3 backdrop-blur">
|
||||
<footer className="border-t border-(--ui-stroke-tertiary) bg-(--glass-chat-surface-background) px-5 py-2.5">
|
||||
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
|
||||
<label className="flex shrink-0 items-center gap-2 rounded-lg border border-border/50 bg-muted/25 px-3 py-1.5 text-sm">
|
||||
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
|
||||
<Switch
|
||||
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
|
||||
checked={platform.enabled}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { RefObject } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Loader2, Search, X } from '@/lib/icons'
|
||||
import { Loader2, Search } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlaySearchInputProps {
|
||||
|
|
@ -14,6 +15,7 @@ interface OverlaySearchInputProps {
|
|||
loading?: boolean
|
||||
onClear?: () => void
|
||||
inputRef?: RefObject<HTMLInputElement | null>
|
||||
trailingAction?: ReactNode
|
||||
}
|
||||
|
||||
export function OverlaySearchInput({
|
||||
|
|
@ -24,33 +26,52 @@ export function OverlaySearchInput({
|
|||
inputClassName,
|
||||
loading = false,
|
||||
onClear,
|
||||
inputRef
|
||||
inputRef,
|
||||
trailingAction
|
||||
}: OverlaySearchInputProps) {
|
||||
const clear = onClear ?? (() => onChange(''))
|
||||
const hasTrailing = Boolean(trailingAction)
|
||||
|
||||
return (
|
||||
<div className={cn('relative', containerClassName)}>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
className={cn('relative z-0 h-8 rounded-lg py-2 pl-8 pr-12 text-sm', inputClassName)}
|
||||
className={cn(
|
||||
'relative z-0 h-8 rounded-lg py-2 pl-8 text-[length:var(--conversation-text-font-size)]',
|
||||
hasTrailing || loading || value ? 'pr-16' : 'pr-8',
|
||||
inputClassName
|
||||
)}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
{loading ? (
|
||||
<Loader2 className="pointer-events-none absolute right-3 top-1/2 z-1 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground/70" />
|
||||
) : value ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1.5 top-1/2 z-1 -translate-y-1/2 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={clear}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="absolute right-1.5 top-1/2 z-1 flex -translate-y-1/2 items-center gap-0.5">
|
||||
{trailingAction}
|
||||
{loading ? (
|
||||
<Loader2 className="pointer-events-none size-3.5 animate-spin text-muted-foreground/70" />
|
||||
) : value ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSearchInput(props: OverlaySearchInputProps) {
|
||||
return (
|
||||
<OverlaySearchInput
|
||||
{...props}
|
||||
containerClassName={cn('mx-auto w-[min(36rem,calc(100%-2rem))] min-w-0', props.containerClassName)}
|
||||
inputClassName={cn('h-8 rounded-lg py-2 pl-8', props.inputClassName)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlaySplitLayoutProps {
|
||||
|
|
@ -20,7 +20,7 @@ interface OverlayMainProps {
|
|||
|
||||
interface OverlayNavItemProps {
|
||||
active: boolean
|
||||
icon: LucideIcon
|
||||
icon: IconComponent
|
||||
label: string
|
||||
onClick: () => void
|
||||
trailing?: ReactNode
|
||||
|
|
@ -30,7 +30,7 @@ export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutPr
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden rounded-[0.95rem] border border-[color-mix(in_srgb,var(--dt-border)_58%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_94%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_46%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_22%,transparent)] max-[760px]:grid-cols-1 dark:border-[color-mix(in_srgb,var(--dt-border)_36%,transparent)] dark:shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_10%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_45%,transparent)]',
|
||||
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden bg-transparent max-[47.5rem]:grid-cols-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -43,7 +43,7 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
|||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto border-r border-[color-mix(in_srgb,var(--dt-border)_48%,transparent)] bg-[color-mix(in_srgb,var(--dt-muted)_55%,var(--dt-card))] px-3.5 py-4',
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--glass-sidebar-surface-background) px-2.5 py-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -54,7 +54,7 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
|||
|
||||
export function OverlayMain({ children, className }: OverlayMainProps) {
|
||||
return (
|
||||
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-4', className)}>{children}</main>
|
||||
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-3', className)}>{children}</main>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -62,10 +62,10 @@ export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }:
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-8 w-full items-center justify-start gap-2 rounded-md border px-2.5 text-left text-sm font-medium transition-colors',
|
||||
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
|
||||
active
|
||||
? 'border-[color-mix(in_srgb,var(--dt-primary)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-primary)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
|
||||
: 'border-transparent bg-transparent text-foreground/78 hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground'
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { type ReactNode, useEffect } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlayViewProps {
|
||||
|
|
@ -46,7 +46,7 @@ export function OverlayView({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[2px] sm:p-8"
|
||||
className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[0.125rem] sm:p-6"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeOverlay()
|
||||
|
|
@ -56,7 +56,7 @@ export function OverlayView({
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-background/96 shadow-[0_1.5rem_4rem_-2rem_color-mix(in_srgb,#000_40%,transparent)]',
|
||||
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--glass-chat-surface-background) shadow-md',
|
||||
rootClassName
|
||||
)}
|
||||
>
|
||||
|
|
@ -69,12 +69,12 @@ export function OverlayView({
|
|||
|
||||
<Button
|
||||
aria-label={closeLabel}
|
||||
className="pointer-events-auto absolute right-3.75 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-lg text-muted-foreground hover:bg-accent/70 hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-md text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
onClick={closeOverlay}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={16} />
|
||||
<Codicon name="close" size="1rem" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
43
apps/desktop/src/app/page-search-shell.tsx
Normal file
43
apps/desktop/src/app/page-search-shell.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PageSearchInput } from './overlays/overlay-search-input'
|
||||
|
||||
interface PageSearchShellProps extends React.ComponentProps<'section'> {
|
||||
children: ReactNode
|
||||
filters?: ReactNode
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder: string
|
||||
searchTrailingAction?: ReactNode
|
||||
searchValue: string
|
||||
}
|
||||
|
||||
export function PageSearchShell({
|
||||
children,
|
||||
className,
|
||||
filters,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
searchTrailingAction,
|
||||
searchValue,
|
||||
...props
|
||||
}: PageSearchShellProps) {
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--glass-chat-surface-background)', className)}
|
||||
>
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5">
|
||||
<PageSearchInput
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
trailingAction={searchTrailingAction}
|
||||
value={searchValue}
|
||||
/>
|
||||
{filters ? <div className="flex flex-wrap items-center justify-center gap-1.5">{filters}</div> : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-(--glass-chat-surface-background)">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@ import {
|
|||
renameProfile,
|
||||
updateProfileSoul
|
||||
} from '@/hermes'
|
||||
import { AlertTriangle, Pencil, Plus, RefreshCw, Save, Terminal, Trash2, Users } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
|
|
@ -88,7 +89,7 @@ export function ProfilesView({
|
|||
setTitlebarToolGroup('profiles', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
|
||||
icon: <Codicon name="refresh" spinning={refreshing} />,
|
||||
id: 'refresh-profiles',
|
||||
label: refreshing ? 'Refreshing profiles' : 'Refresh profiles',
|
||||
onSelect: () => void refresh()
|
||||
|
|
@ -179,7 +180,7 @@ export function ProfilesView({
|
|||
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r">
|
||||
<div className="border-b border-border/40 p-2">
|
||||
<Button className="w-full" onClick={() => setCreateOpen(true)} size="sm">
|
||||
<Plus />
|
||||
<Codicon name="add" />
|
||||
New profile
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { ChevronDown, ChevronRight, FileText, FolderOpen, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { TreeNode } from './use-project-tree'
|
||||
|
||||
const ROW_HEIGHT = 28
|
||||
const INDENT = 14
|
||||
const ROW_HEIGHT = 22
|
||||
const INDENT = 10
|
||||
|
||||
interface ProjectTreeProps {
|
||||
data: TreeNode[]
|
||||
onActivateFile: (path: string) => void
|
||||
onActivateFolder: (path: string) => void
|
||||
onLoadChildren: (id: string) => void | Promise<void>
|
||||
onNodeOpenChange: (id: string, open: boolean) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
|
|
@ -22,6 +23,7 @@ interface ProjectTreeProps {
|
|||
export function ProjectTree({
|
||||
data,
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
|
|
@ -97,9 +99,26 @@ export function ProjectTree({
|
|||
rowHeight={ROW_HEIGHT}
|
||||
width={size.width}
|
||||
>
|
||||
{props => <ProjectTreeRow {...props} onAttachFile={onActivateFile} onPreviewFile={onPreviewFile} />}
|
||||
{props => (
|
||||
<ProjectTreeRow
|
||||
{...props}
|
||||
onAttachFile={onActivateFile}
|
||||
onAttachFolder={onActivateFolder}
|
||||
onPreviewFile={onPreviewFile}
|
||||
/>
|
||||
)}
|
||||
</Tree>
|
||||
) : null}
|
||||
) : (
|
||||
<TreeSizingState />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeSizingState() {
|
||||
return (
|
||||
<div className="flex h-full min-h-24 items-center justify-center px-3 text-[0.68rem] text-(--ui-text-tertiary)">
|
||||
Loading files...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -108,20 +127,24 @@ function ProjectTreeRow({
|
|||
dragHandle,
|
||||
node,
|
||||
onAttachFile,
|
||||
onAttachFolder,
|
||||
onPreviewFile,
|
||||
style
|
||||
}: NodeRendererProps<TreeNode> & { onAttachFile: (path: string) => void; onPreviewFile?: (path: string) => void }) {
|
||||
}: NodeRendererProps<TreeNode> & {
|
||||
onAttachFile: (path: string) => void
|
||||
onAttachFolder: (path: string) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
}) {
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = node.data.id.endsWith('::__loading__')
|
||||
const Caret = node.isOpen ? ChevronDown : ChevronRight
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-expanded={isFolder ? node.isOpen : undefined}
|
||||
aria-selected={node.isSelected}
|
||||
className={cn(
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-0.5 rounded-sm px-0 text-sm font-medium leading-snug text-foreground/90 transition-colors hover:bg-(--chrome-action-hover)',
|
||||
node.isSelected && 'bg-accent/65 text-foreground',
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||||
node.isSelected && 'bg-(--ui-bg-tertiary) text-foreground',
|
||||
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
|
||||
)}
|
||||
draggable={!isPlaceholder}
|
||||
|
|
@ -132,14 +155,16 @@ function ProjectTreeRow({
|
|||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
;(isFolder ? onAttachFolder : onAttachFile)(node.data.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isFolder) {
|
||||
node.toggle()
|
||||
} else {
|
||||
node.select()
|
||||
|
||||
if (event.shiftKey) {
|
||||
onAttachFile(node.data.id)
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDoubleClick={event => {
|
||||
|
|
@ -166,17 +191,18 @@ function ProjectTreeRow({
|
|||
style={style}
|
||||
>
|
||||
{isFolder && !isPlaceholder && (
|
||||
<span aria-hidden className="flex w-2.5 items-center justify-center">
|
||||
<Caret className="size-3 text-muted-foreground/70" />
|
||||
<span aria-hidden className="flex w-3 items-center justify-center">
|
||||
<Codicon className="text-(--ui-text-tertiary)" name={node.isOpen ? 'chevron-down' : 'chevron-right'} size="0.75rem" />
|
||||
</span>
|
||||
)}
|
||||
<span aria-hidden className="flex w-3 items-center justify-center text-muted-foreground/85">
|
||||
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
|
||||
{isPlaceholder ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
) : isFolder ? (
|
||||
<FolderOpen className="size-3.5" />
|
||||
<Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" />
|
||||
) : (
|
||||
<FileText className="size-3.5" />
|
||||
<Codicon name="file" size="0.875rem" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{node.data.name}</span>
|
||||
|
|
@ -227,7 +227,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
|||
openState: state.cwd === cwd ? state.openState : {},
|
||||
refreshRoot,
|
||||
rootError: state.cwd === cwd ? state.rootError : null,
|
||||
rootLoading: state.cwd === cwd ? state.rootLoading : false,
|
||||
rootLoading: state.cwd === cwd ? state.rootLoading : Boolean(cwd),
|
||||
setNodeOpen
|
||||
}),
|
||||
[
|
||||
290
apps/desktop/src/app/right-sidebar/index.tsx
Normal file
290
apps/desktop/src/app/right-sidebar/index.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentBranch, $currentCwd } from '@/store/session'
|
||||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { ProjectTree } from './files/tree'
|
||||
import { useProjectTree } from './files/use-project-tree'
|
||||
import { $rightSidebarTab, type RightSidebarTabId, setRightSidebarTab } from './store'
|
||||
import { TerminalTab } from './terminal'
|
||||
|
||||
interface RightSidebarPaneProps {
|
||||
onActivateFile: (path: string) => void
|
||||
onActivateFolder: (path: string) => void
|
||||
onAddTerminalSelection: (text: string, label?: string) => void
|
||||
onChangeCwd: (path: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
interface RightSidebarTab {
|
||||
icon: string
|
||||
id: RightSidebarTabId
|
||||
label: string
|
||||
}
|
||||
|
||||
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
|
||||
{ id: 'files', label: 'File system', icon: 'files' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: 'terminal' }
|
||||
]
|
||||
|
||||
export function RightSidebarPane({
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
onAddTerminalSelection,
|
||||
onChangeCwd
|
||||
}: RightSidebarPaneProps) {
|
||||
const activeTab = useStore($rightSidebarTab)
|
||||
const currentBranch = useStore($currentBranch).trim()
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
|
||||
const cwdName = hasCwd
|
||||
? (currentCwd
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? currentCwd)
|
||||
: 'No folder selected'
|
||||
|
||||
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
title: 'Change working directory'
|
||||
})
|
||||
|
||||
if (selected?.[0]) {
|
||||
await onChangeCwd(selected[0])
|
||||
}
|
||||
}
|
||||
|
||||
const previewFile = async (path: string) => {
|
||||
try {
|
||||
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not preview ${path}`)
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Right sidebar"
|
||||
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--glass-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]"
|
||||
>
|
||||
<RightSidebarChrome activeTab={activeTab} branch={currentBranch} />
|
||||
|
||||
{activeTab === 'terminal' ? (
|
||||
<TerminalTab cwd={currentCwd} onAddSelectionToChat={onAddTerminalSelection} />
|
||||
) : (
|
||||
<FilesystemTab
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
hasCwd={hasCwd}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
onRefresh={() => void refreshRoot()}
|
||||
openState={openState}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function RightSidebarChrome({ activeTab, branch }: { activeTab: RightSidebarTabId; branch: string }) {
|
||||
return (
|
||||
<header className="shrink-0 bg-transparent text-[0.75rem]">
|
||||
<div className="flex h-8 items-center gap-2 border-b border-(--ui-stroke-tertiary) px-3">
|
||||
{RIGHT_SIDEBAR_TABS.map(tab => (
|
||||
<button
|
||||
aria-label={tab.label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'grid size-6 shrink-0 place-items-center rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-sidebar-accent active:text-sidebar-accent-foreground',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground'
|
||||
)}
|
||||
data-active={tab.id === activeTab}
|
||||
key={tab.id}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
title={tab.label}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</button>
|
||||
))}
|
||||
{branch && (
|
||||
<span className="ml-auto flex min-w-0 items-center gap-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
|
||||
<span className="truncate">{branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
cwdName: string
|
||||
hasCwd: boolean
|
||||
onChangeFolder: () => Promise<void> | void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
function FilesystemTab({
|
||||
cwd,
|
||||
cwdName,
|
||||
data,
|
||||
error,
|
||||
hasCwd,
|
||||
loading,
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
onChangeFolder,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
onRefresh,
|
||||
openState
|
||||
}: FilesystemTabProps) {
|
||||
return (
|
||||
<div className="group/project-header flex min-h-0 flex-1 flex-col">
|
||||
<RightSidebarSectionHeader>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
title={hasCwd ? cwd : 'No folder selected'}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className="pointer-events-none size-6 shrink-0 rounded-md text-sidebar-foreground/70 opacity-0 transition-opacity hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon"
|
||||
title="Refresh tree"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
</RightSidebarSectionHeader>
|
||||
<FileTreeBody
|
||||
cwd={cwd}
|
||||
data={data}
|
||||
error={error}
|
||||
loading={loading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
|
||||
return <div className="flex h-7 shrink-0 items-center px-3">{children}</div>
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
cwd: string
|
||||
data: ReturnType<typeof useProjectTree>['data']
|
||||
error: string | null
|
||||
loading: boolean
|
||||
onActivateFile: (path: string) => void
|
||||
onActivateFolder: (path: string) => void
|
||||
onLoadChildren: (id: string) => void | Promise<void>
|
||||
onNodeOpenChange: (id: string, open: boolean) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
openState: ReturnType<typeof useProjectTree>['openState']
|
||||
}
|
||||
|
||||
function FileTreeBody({
|
||||
cwd,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
openState
|
||||
}: FileTreeBodyProps) {
|
||||
if (!cwd) {
|
||||
return <EmptyState body="Set a working directory from the status bar to browse files." title="No project" />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyState body={`Could not read this folder (${error}).`} title="Unreadable" />
|
||||
}
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
return <FileTreeLoadingState />
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState body="This folder is empty." title="Empty" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectTree
|
||||
data={data}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FileTreeLoadingState() {
|
||||
return (
|
||||
<div aria-label="Loading file tree" className="grid min-h-0 flex-1 place-items-center px-3" role="status">
|
||||
<Loader
|
||||
aria-hidden="true"
|
||||
className="size-8 text-(--ui-text-tertiary)"
|
||||
pathSteps={180}
|
||||
role="presentation"
|
||||
strokeScale={0.68}
|
||||
type="spiral-search"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ body, title }: { body: string; title: string }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
|
||||
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
|
||||
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
apps/desktop/src/app/right-sidebar/store.ts
Normal file
9
apps/desktop/src/app/right-sidebar/store.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
|
||||
|
||||
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
|
||||
|
||||
export function setRightSidebarTab(tab: RightSidebarTabId) {
|
||||
$rightSidebarTab.set(tab)
|
||||
}
|
||||
58
apps/desktop/src/app/right-sidebar/terminal/index.tsx
Normal file
58
apps/desktop/src/app/right-sidebar/terminal/index.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
|
||||
import { addSelectionShortcutLabel } from './selection'
|
||||
import { useTerminalSession } from './use-terminal-session'
|
||||
|
||||
interface TerminalTabProps {
|
||||
cwd: string
|
||||
onAddSelectionToChat: (text: string, label?: string) => void
|
||||
}
|
||||
|
||||
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
|
||||
cwd,
|
||||
onAddSelectionToChat
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-7 shrink-0 items-center px-3">
|
||||
<SidebarPanelLabel>{shellName}</SidebarPanelLabel>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 px-2 pb-2">
|
||||
{status === 'starting' && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||
<Loader className="size-8 text-(--ui-text-tertiary)" pathSteps={180} strokeScale={0.68} type="spiral-search" />
|
||||
</div>
|
||||
)}
|
||||
{selection.trim() && (
|
||||
<div className="absolute z-50 flex items-center gap-1" style={selectionStyle ?? { right: 12, top: 8 }}>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[0.68rem] shadow-md backdrop-blur-md"
|
||||
onClick={event => event.preventDefault()}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
addSelectionToChat()
|
||||
}}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
Add to chat
|
||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="h-full min-h-0 overflow-hidden px-1 py-1 text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-transparent! [&_.xterm-viewport]:bg-transparent!"
|
||||
ref={hostRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
apps/desktop/src/app/right-sidebar/terminal/selection.ts
Normal file
57
apps/desktop/src/app/right-sidebar/terminal/selection.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { Terminal } from '@xterm/xterm'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
export const TERMINAL_THEME = {
|
||||
background: '#00000000',
|
||||
cursor: '#6f6f6f',
|
||||
cursorAccent: '#f7f7f7',
|
||||
foreground: '#4d4d4d',
|
||||
selectionBackground: '#8c8c8c33'
|
||||
}
|
||||
|
||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||
|
||||
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
|
||||
|
||||
export function isAddSelectionShortcut(event: KeyboardEvent) {
|
||||
return isMacPlatform()
|
||||
? event.metaKey && !event.shiftKey && event.key.toLowerCase() === 'l'
|
||||
: event.ctrlKey && !event.shiftKey && event.key.toLowerCase() === 'l'
|
||||
}
|
||||
|
||||
function selectionLineCount(text: string) {
|
||||
return Math.max(1, text.trim().split(/\r?\n/).length)
|
||||
}
|
||||
|
||||
export function terminalSelectionLabel(term: Terminal, shellName: string, text: string) {
|
||||
const position = term.getSelectionPosition()
|
||||
|
||||
if (position) {
|
||||
return position.start.y === position.end.y
|
||||
? `${shellName}:${position.start.y}`
|
||||
: `${shellName}:${position.start.y}-${position.end.y}`
|
||||
}
|
||||
|
||||
const lines = selectionLineCount(text)
|
||||
|
||||
return `${shellName}:${lines} line${lines === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
export function terminalSelectionAnchor(host: HTMLDivElement): CSSProperties | null {
|
||||
const selectionRects = Array.from(host.querySelectorAll<HTMLElement>('.xterm-selection div'))
|
||||
.map(node => node.getBoundingClientRect())
|
||||
.filter(rect => rect.width > 0 && rect.height > 0)
|
||||
|
||||
const rect = selectionRects.at(-1)
|
||||
|
||||
if (!rect) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hostRect = host.getBoundingClientRect()
|
||||
const buttonWidth = 128
|
||||
const left = Math.min(Math.max(rect.left - hostRect.left, 8), Math.max(8, host.clientWidth - buttonWidth - 8))
|
||||
const top = Math.min(Math.max(rect.bottom - hostRect.top + 4, 8), Math.max(8, host.clientHeight - 34))
|
||||
|
||||
return { left, top }
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
import {
|
||||
isAddSelectionShortcut,
|
||||
TERMINAL_THEME,
|
||||
terminalSelectionAnchor,
|
||||
terminalSelectionLabel
|
||||
} from './selection'
|
||||
|
||||
type TerminalStatus = 'closed' | 'open' | 'starting'
|
||||
|
||||
interface UseTerminalSessionOptions {
|
||||
cwd: string
|
||||
onAddSelectionToChat: (text: string, label?: string) => void
|
||||
}
|
||||
|
||||
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
const sessionIdRef = useRef<string | null>(null)
|
||||
const shellNameRef = useRef('shell')
|
||||
const selectionLabelRef = useRef('')
|
||||
const selectionRef = useRef('')
|
||||
const onAddSelectionToChatRef = useRef(onAddSelectionToChat)
|
||||
const [status, setStatus] = useState<TerminalStatus>('starting')
|
||||
const [selection, setSelection] = useState('')
|
||||
const [selectionStyle, setSelectionStyle] = useState<CSSProperties | null>(null)
|
||||
const [shellName, setShellName] = useState('shell')
|
||||
|
||||
useEffect(() => {
|
||||
onAddSelectionToChatRef.current = onAddSelectionToChat
|
||||
}, [onAddSelectionToChat])
|
||||
|
||||
const addSelectionToChat = useCallback(() => {
|
||||
const selectedText = selectionRef.current || termRef.current?.getSelection() || ''
|
||||
|
||||
const label =
|
||||
selectionLabelRef.current || (termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
||||
|
||||
const trimmed = selectedText.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
onAddSelectionToChatRef.current(trimmed, label)
|
||||
termRef.current?.clearSelection()
|
||||
selectionRef.current = ''
|
||||
selectionLabelRef.current = ''
|
||||
setSelection('')
|
||||
setSelectionStyle(null)
|
||||
triggerHaptic('selection')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isAddSelectionShortcut(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
addSelectionToChat()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [addSelectionToChat, selection])
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
const terminalApi = window.hermesDesktop?.terminal
|
||||
|
||||
if (!host || !terminalApi) {
|
||||
setStatus('closed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let disposed = false
|
||||
const cleanup: Array<() => void> = []
|
||||
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.12,
|
||||
macOptionIsMeta: true,
|
||||
scrollback: 1000,
|
||||
theme: TERMINAL_THEME
|
||||
})
|
||||
|
||||
const fit = new FitAddon()
|
||||
|
||||
termRef.current = term
|
||||
term.loadAddon(fit)
|
||||
term.loadAddon(new Unicode11Addon())
|
||||
term.loadAddon(new WebLinksAddon())
|
||||
term.unicode.activeVersion = '11'
|
||||
term.open(host)
|
||||
|
||||
const fitAndResize = () => {
|
||||
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
fit.fit()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const id = sessionIdRef.current
|
||||
|
||||
if (id) {
|
||||
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(fitAndResize)
|
||||
resizeObserver.observe(host)
|
||||
cleanup.push(() => resizeObserver.disconnect())
|
||||
|
||||
const dataDisposable = term.onData(data => {
|
||||
const id = sessionIdRef.current
|
||||
|
||||
if (id) {
|
||||
void terminalApi.write(id, data)
|
||||
}
|
||||
})
|
||||
|
||||
cleanup.push(() => dataDisposable.dispose())
|
||||
|
||||
const selectionDisposable = term.onSelectionChange(() => {
|
||||
const next = term.getSelection()
|
||||
selectionRef.current = next
|
||||
selectionLabelRef.current = next.trim() ? terminalSelectionLabel(term, shellNameRef.current, next) : ''
|
||||
setSelection(next)
|
||||
setSelectionStyle(next.trim() ? terminalSelectionAnchor(host) : null)
|
||||
})
|
||||
|
||||
cleanup.push(() => selectionDisposable.dispose())
|
||||
|
||||
term.attachCustomKeyEventHandler(event => {
|
||||
if (event.type !== 'keydown') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isAddSelectionShortcut(event) && term.hasSelection()) {
|
||||
event.preventDefault()
|
||||
addSelectionToChat()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
fitAndResize()
|
||||
|
||||
void terminalApi
|
||||
.start({ cols: term.cols, cwd, rows: term.rows })
|
||||
.then(session => {
|
||||
if (disposed) {
|
||||
void terminalApi.dispose(session.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionIdRef.current = session.id
|
||||
shellNameRef.current = session.shell || 'shell'
|
||||
setShellName(session.shell || 'shell')
|
||||
|
||||
if (term.hasSelection()) {
|
||||
const currentSelection = term.getSelection()
|
||||
selectionRef.current = currentSelection
|
||||
selectionLabelRef.current = terminalSelectionLabel(term, shellNameRef.current, currentSelection)
|
||||
} else {
|
||||
selectionRef.current = ''
|
||||
selectionLabelRef.current = ''
|
||||
}
|
||||
|
||||
setStatus('open')
|
||||
cleanup.push(
|
||||
terminalApi.onData(session.id, data => term.write(data)),
|
||||
terminalApi.onExit(session.id, sessionExit => {
|
||||
const { code, signal } = sessionExit
|
||||
setStatus('closed')
|
||||
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
|
||||
})
|
||||
)
|
||||
window.requestAnimationFrame(fitAndResize)
|
||||
})
|
||||
.catch(error => {
|
||||
setStatus('closed')
|
||||
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
cleanup.forEach(run => run())
|
||||
|
||||
const id = sessionIdRef.current
|
||||
sessionIdRef.current = null
|
||||
|
||||
if (id) {
|
||||
void terminalApi.dispose(id)
|
||||
}
|
||||
|
||||
term.dispose()
|
||||
termRef.current = null
|
||||
shellNameRef.current = 'shell'
|
||||
selectionRef.current = ''
|
||||
selectionLabelRef.current = ''
|
||||
}
|
||||
}, [addSelectionToChat, cwd])
|
||||
|
||||
return {
|
||||
addSelectionToChat,
|
||||
hostRef,
|
||||
selection,
|
||||
selectionStyle,
|
||||
shellName,
|
||||
status
|
||||
}
|
||||
}
|
||||
|
|
@ -7,11 +7,16 @@ import type { SessionRuntimeInfo } from '@/types/hermes'
|
|||
interface CwdActionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
currentCwd: string
|
||||
onSessionRuntimeInfo?: (info: Pick<SessionRuntimeInfo, 'branch' | 'cwd'>) => void
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd, requestGateway }: CwdActionsOptions) {
|
||||
export function useCwdActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
onSessionRuntimeInfo,
|
||||
requestGateway
|
||||
}: CwdActionsOptions) {
|
||||
const refreshProjectBranch = useCallback(
|
||||
async (cwd: string) => {
|
||||
const target = cwd.trim()
|
||||
|
|
@ -44,23 +49,15 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
|
|||
return
|
||||
}
|
||||
|
||||
const persistGlobal = async () => {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
|
||||
...(activeSessionId && { session_id: activeSessionId }),
|
||||
key: 'terminal.cwd',
|
||||
value: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || info.value || trimmed)
|
||||
|
||||
if (!activeSessionId) {
|
||||
setCurrentBranch(info.branch || '')
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
try {
|
||||
await persistGlobal()
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
|
||||
key: 'project',
|
||||
cwd: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || trimmed)
|
||||
setCurrentBranch(info.branch || '')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
}
|
||||
|
|
@ -76,44 +73,27 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
|
|||
|
||||
setCurrentCwd(info.cwd || trimmed)
|
||||
setCurrentBranch(info.branch || '')
|
||||
onSessionRuntimeInfo?.({ branch: info.branch || '', cwd: info.cwd || trimmed })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// Older gateways without `session.cwd.set` fall back to a global write —
|
||||
// user has to restart the active session for it to take effect.
|
||||
if (!message.includes('unknown method')) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await persistGlobal()
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Working directory saved',
|
||||
message: 'Restart the desktop backend to apply cwd changes to this active session.'
|
||||
})
|
||||
} catch (fallbackErr) {
|
||||
notifyError(fallbackErr, 'Working directory change failed')
|
||||
}
|
||||
setCurrentCwd(trimmed)
|
||||
setCurrentBranch('')
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Working directory staged',
|
||||
message: 'Restart the desktop backend to apply cwd changes to this active session.'
|
||||
})
|
||||
}
|
||||
},
|
||||
[activeSessionId, requestGateway]
|
||||
[activeSessionId, onSessionRuntimeInfo, requestGateway]
|
||||
)
|
||||
|
||||
const browseSessionCwd = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Change working directory',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: true,
|
||||
multiple: false
|
||||
})
|
||||
|
||||
if (paths?.[0]) {
|
||||
await changeSessionCwd(paths[0])
|
||||
}
|
||||
}, [changeSessionCwd, currentCwd])
|
||||
|
||||
return { browseSessionCwd, changeSessionCwd, refreshProjectBranch }
|
||||
return { changeSessionCwd, refreshProjectBranch }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,9 @@ function parseMaybeRecord(value: unknown): Record<string, unknown> {
|
|||
|
||||
const firstString = (...candidates: unknown[]): string => {
|
||||
for (const v of candidates) {
|
||||
if (typeof v === 'string' && v) return v
|
||||
if (typeof v === 'string' && v) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
|
|
@ -111,7 +113,9 @@ function delegateTaskPayloads(
|
|||
phase: 'running' | 'complete',
|
||||
sourceEventType?: string
|
||||
): Record<string, unknown>[] {
|
||||
if (payload?.name !== 'delegate_task') return []
|
||||
if (payload?.name !== 'delegate_task') {
|
||||
return []
|
||||
}
|
||||
|
||||
const args = parseMaybeRecord(payload.args ?? payload.input)
|
||||
const result = parseMaybeRecord(payload.result)
|
||||
|
|
@ -120,6 +124,7 @@ function delegateTaskPayloads(
|
|||
const status = phase === 'complete' ? (payload.error ? 'failed' : 'completed') : 'running'
|
||||
const toolId = payload.tool_id || payload.tool_call_id || payload.id || 'delegate_task'
|
||||
const progressText = firstString(payload.preview, payload.message, payload.context)
|
||||
|
||||
const eventType =
|
||||
phase === 'complete'
|
||||
? 'subagent.complete'
|
||||
|
|
@ -517,6 +522,8 @@ export function useMessageStream({
|
|||
const runningChanged = typeof payload?.running === 'boolean'
|
||||
|
||||
if (apply) {
|
||||
const runtimeInfo: { branch?: string; cwd?: string } = {}
|
||||
|
||||
if (modelChanged) {
|
||||
setCurrentModel(payload!.model || '')
|
||||
}
|
||||
|
|
@ -527,10 +534,20 @@ export function useMessageStream({
|
|||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
setCurrentCwd(payload.cwd)
|
||||
runtimeInfo.cwd = payload.cwd
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
setCurrentBranch(payload.branch)
|
||||
runtimeInfo.branch = payload.branch
|
||||
}
|
||||
|
||||
if (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) {
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
branch: runtimeInfo.branch ?? state.branch,
|
||||
cwd: runtimeInfo.cwd ?? state.cwd
|
||||
}))
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
$composerAttachments,
|
||||
addComposerAttachment,
|
||||
clearComposerAttachments,
|
||||
terminalContextBlocksFromDraft,
|
||||
type ComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -206,11 +207,13 @@ export function usePromptActions({
|
|||
.map(a => a.refText)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
|
||||
const hasImage = attachments.some(a => a.kind === 'image')
|
||||
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
|
||||
|
||||
const text =
|
||||
[contextRefs, visibleText].filter(Boolean).join('\n\n') || (hasImage ? 'What do you see in this image?' : '')
|
||||
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
||||
(hasImage ? 'What do you see in this image?' : '')
|
||||
|
||||
if (!text || busyRef.current) {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { $pinnedSessionIds } from '@/store/layout'
|
|||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
$sessions,
|
||||
setActiveSessionId,
|
||||
|
|
@ -166,6 +167,7 @@ function upsertOptimisticSession(
|
|||
const now = Date.now() / 1000
|
||||
|
||||
const session: SessionInfo = {
|
||||
cwd: created.info?.cwd ?? null,
|
||||
ended_at: null,
|
||||
id,
|
||||
input_tokens: 0,
|
||||
|
|
@ -184,11 +186,21 @@ function upsertOptimisticSession(
|
|||
setSessions(prev => [session, ...prev.filter(s => s.id !== id)])
|
||||
}
|
||||
|
||||
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
|
||||
if (!info) {
|
||||
function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | null {
|
||||
if (!info) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionState: Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> = {}
|
||||
|
||||
if (info.credential_warning) {
|
||||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
|
|
@ -203,10 +215,12 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
|
|||
|
||||
if (info.cwd) {
|
||||
setCurrentCwd(info.cwd)
|
||||
sessionState.cwd = info.cwd
|
||||
}
|
||||
|
||||
if (info.branch !== undefined) {
|
||||
setCurrentBranch(info.branch || '')
|
||||
sessionState.branch = info.branch || ''
|
||||
}
|
||||
|
||||
if (typeof info.personality === 'string') {
|
||||
|
|
@ -228,6 +242,8 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
|
|||
if (info.usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...info.usage }))
|
||||
}
|
||||
|
||||
return sessionState
|
||||
}
|
||||
|
||||
export function useSessionActions({
|
||||
|
|
@ -269,6 +285,8 @@ export function useSessionActions({
|
|||
})
|
||||
setSessionStartedAt(null)
|
||||
setTurnStartedAt(null)
|
||||
setCurrentCwd('')
|
||||
setCurrentBranch('')
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
setFreshDraftReady(true)
|
||||
|
|
@ -284,7 +302,8 @@ export function useSessionActions({
|
|||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96 })
|
||||
const cwd = $currentCwd.get().trim()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
|
|
@ -310,7 +329,11 @@ export function useSessionActions({
|
|||
setActiveSessionId(created.session_id)
|
||||
setSelectedStoredSessionId(stored)
|
||||
setSessionStartedAt(Date.now())
|
||||
applyRuntimeInfo(created.info)
|
||||
const runtimeInfo = applyRuntimeInfo(created.info)
|
||||
|
||||
if (runtimeInfo) {
|
||||
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
|
||||
}
|
||||
|
||||
return created.session_id
|
||||
} finally {
|
||||
|
|
@ -325,7 +348,8 @@ export function useSessionActions({
|
|||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
])
|
||||
|
||||
const selectSidebarItem = useCallback(
|
||||
|
|
@ -376,6 +400,8 @@ export function useSessionActions({
|
|||
setActiveSessionId(cachedRuntimeId)
|
||||
activeSessionIdRef.current = cachedRuntimeId
|
||||
syncSessionStateToView(cachedRuntimeId, cachedState)
|
||||
setCurrentCwd(cachedState.cwd)
|
||||
setCurrentBranch(cachedState.branch)
|
||||
setSessionStartedAt(Date.now())
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
|
@ -467,10 +493,15 @@ export function useSessionActions({
|
|||
|
||||
setActiveSessionId(resumed.session_id)
|
||||
activeSessionIdRef.current = resumed.session_id
|
||||
const runtimeInfo = applyRuntimeInfo(resumed.info)
|
||||
|
||||
patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd)
|
||||
|
||||
updateSessionState(
|
||||
resumed.session_id,
|
||||
state => ({
|
||||
...state,
|
||||
...(runtimeInfo ?? {}),
|
||||
messages: messagesForView,
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
|
|
@ -479,7 +510,6 @@ export function useSessionActions({
|
|||
)
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
applyRuntimeInfo(resumed.info)
|
||||
} catch (err) {
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
|
|
@ -570,8 +600,11 @@ export function useSessionActions({
|
|||
|
||||
clearNotifications()
|
||||
|
||||
const cwd = $currentCwd.get().trim()
|
||||
|
||||
const branched = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
...(cwd && { cwd }),
|
||||
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
||||
title: 'Branch'
|
||||
})
|
||||
|
|
@ -600,7 +633,13 @@ export function useSessionActions({
|
|||
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
applyRuntimeInfo(branched.info)
|
||||
const runtimeInfo = applyRuntimeInfo(branched.info)
|
||||
|
||||
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
|
||||
|
||||
if (runtimeInfo) {
|
||||
updateSessionState(branched.session_id, state => ({ ...state, ...runtimeInfo }), routedSessionId)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -58,16 +58,16 @@ export function AppearanceSettings() {
|
|||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="space-y-7">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<SectionHeading icon={Palette} title="Appearance" />
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
|
||||
chat surface styling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 shadow-sm">
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Color Mode</div>
|
||||
|
|
@ -84,8 +84,8 @@ export function AppearanceSettings() {
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-xl border border-border/45 bg-background/55 p-3 text-left transition hover:border-primary/35 hover:bg-accent/45',
|
||||
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
|
||||
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={id}
|
||||
onClick={() => {
|
||||
|
|
@ -104,15 +104,15 @@ export function AppearanceSettings() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-medium">{label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{description}</div>
|
||||
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{label}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{description}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 shadow-sm">
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Tool Call Display</div>
|
||||
|
|
@ -142,8 +142,8 @@ export function AppearanceSettings() {
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-xl border border-border/45 bg-background/55 p-3 text-left transition hover:border-primary/35 hover:bg-accent/45',
|
||||
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
|
||||
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
|
|
@ -153,21 +153,21 @@ export function AppearanceSettings() {
|
|||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
|
||||
{active && (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{option.description}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 shadow-sm">
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Theme</div>
|
||||
|
|
@ -184,8 +184,8 @@ export function AppearanceSettings() {
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-2xl border border-border/45 bg-background/50 p-2.5 text-left transition hover:border-primary/35 hover:bg-accent/35',
|
||||
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
|
|
@ -197,8 +197,8 @@ export function AppearanceSettings() {
|
|||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{theme.label}</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{theme.label}</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
Brain,
|
||||
Lock,
|
||||
type LucideIcon,
|
||||
type IconComponent,
|
||||
MessageCircle,
|
||||
Mic,
|
||||
Monitor,
|
||||
|
|
@ -302,7 +302,7 @@ export interface ModeOption {
|
|||
id: ThemeMode
|
||||
label: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
icon: IconComponent
|
||||
}
|
||||
|
||||
export const MODE_OPTIONS: ModeOption[] = [
|
||||
|
|
|
|||
|
|
@ -45,22 +45,22 @@ function ModeCard({
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-2xl border p-4 text-left transition',
|
||||
'rounded-xl border p-3 text-left transition',
|
||||
active
|
||||
? 'border-primary bg-primary/10 ring-2 ring-primary/15'
|
||||
: 'border-border bg-background/60 hover:bg-muted/40',
|
||||
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span>{title}</span>
|
||||
{active ? <Check className="ml-auto size-4 text-primary" /> : null}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{description}</p>
|
||||
<p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{description}</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -191,20 +191,20 @@ export function GatewaySettings() {
|
|||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<div className="mb-5">
|
||||
<div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
Gateway Connection
|
||||
{state.envOverride ? <Pill tone="primary">env override</Pill> : null}
|
||||
</div>
|
||||
<p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground">
|
||||
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
|
||||
an already-running Hermes backend on another machine or behind a trusted proxy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{state.envOverride ? (
|
||||
<div className="mb-5 flex items-start gap-2 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive">
|
||||
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">Environment variables are controlling this desktop session.</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, X, Zap } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
|
@ -169,7 +170,7 @@ function EnvVarRow({
|
|||
{saving === varKey ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
|
||||
<X />
|
||||
<Codicon name="close" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ import type { ReactNode } from 'react'
|
|||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SettingsContent({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<section className="min-h-0 overflow-hidden">
|
||||
<div className="h-full min-h-0 overflow-y-auto px-8 py-6 pb-24">
|
||||
<div className="mx-auto w-full max-w-5xl">{children}</div>
|
||||
<div className="h-full min-h-0 overflow-y-auto px-5 py-4 pb-20">
|
||||
<div className="mx-auto w-full max-w-4xl">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -19,7 +19,7 @@ export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary';
|
|||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.66rem]',
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem]',
|
||||
tone === 'primary' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
|
|
@ -28,9 +28,9 @@ export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary';
|
|||
)
|
||||
}
|
||||
|
||||
export function SectionHeading({ icon: Icon, title, meta }: { icon: LucideIcon; title: string; meta?: string }) {
|
||||
export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2 pt-3.5 text-sm font-medium">
|
||||
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span>{title}</span>
|
||||
{meta && <Pill>{meta}</Pill>}
|
||||
|
|
@ -44,7 +44,7 @@ export function NavLink({
|
|||
active,
|
||||
onClick
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
icon: IconComponent
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
|
|
@ -52,8 +52,8 @@ export function NavLink({
|
|||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'flex min-h-8 w-full justify-start gap-2 rounded-lg px-2.5 text-left text-sm transition',
|
||||
active ? 'bg-muted text-foreground' : 'text-foreground/80 hover:bg-muted/70'
|
||||
'flex min-h-7 w-full justify-start gap-2 rounded-md px-2 text-left text-[length:var(--conversation-text-font-size)] transition',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
|
|
@ -84,13 +84,13 @@ export function ListRow({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-4 py-3.5 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center',
|
||||
'grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center',
|
||||
wide && 'sm:grid-cols-1 sm:items-start'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
{description && <div className="mt-1 text-xs leading-5 text-muted-foreground">{description}</div>}
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
|
||||
{description && <div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{description}</div>}
|
||||
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
|
||||
{below}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
|
||||
|
|
@ -28,7 +28,7 @@ export interface ProviderGroup {
|
|||
export interface DesktopConfigSection {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
icon: IconComponent
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react'
|
|||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import {
|
||||
|
|
@ -21,9 +20,11 @@ import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
|||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
commandCenterOpen?: boolean
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
onOpenSearch: () => void
|
||||
overlays?: ReactNode
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
|
|
@ -46,9 +47,11 @@ const viewportIsFullscreen = () =>
|
|||
|
||||
export function AppShell({
|
||||
children,
|
||||
commandCenterOpen = false,
|
||||
leftStatusbarItems,
|
||||
leftTitlebarTools,
|
||||
onOpenSettings,
|
||||
onOpenSearch,
|
||||
overlays,
|
||||
statusbarItems,
|
||||
titlebarTools
|
||||
|
|
@ -70,9 +73,9 @@ export function AppShell({
|
|||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
|
||||
// The static system cluster (file-browser, haptics, settings) is hardcoded
|
||||
// in TitlebarControls. Pane-supplied tools (preview's group) render in a
|
||||
// separate cluster anchored further left.
|
||||
// The static system cluster (haptics, profiles, settings, right-sidebar) is
|
||||
// hardcoded in TitlebarControls. Pane-supplied tools (preview's group) render
|
||||
// in a separate cluster anchored further left.
|
||||
//
|
||||
// Width math has to include the `gap-x-1` (0.25rem) between buttons:
|
||||
// N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room
|
||||
|
|
@ -105,7 +108,7 @@ export function AppShell({
|
|||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen min-h-0 bg-background"
|
||||
className="h-screen min-h-0 flex-col bg-background"
|
||||
onOpenChange={setSidebarOpen}
|
||||
open={sidebarOpen}
|
||||
style={
|
||||
|
|
@ -127,10 +130,15 @@ export function AppShell({
|
|||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
<TitlebarControls
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onOpenSettings={onOpenSettings}
|
||||
tools={titlebarTools}
|
||||
/>
|
||||
|
||||
<Backdrop />
|
||||
<main className="relative z-3 flex h-screen w-full flex-col overflow-hidden pr-0.75 pt-0.75 transition-none">
|
||||
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-(--glass-chat-surface-background) transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
|
|
|
|||
|
|
@ -3,16 +3,14 @@ import { useMemo } from 'react'
|
|||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { Activity, AlertCircle, Clock, Command, Cpu, FolderOpen, GitBranch, Hash, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { Activity, AlertCircle, Clock, Command, Cpu, Hash, Loader2, Sparkles } from '@/lib/icons'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks } from '@/store/activity'
|
||||
import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$busy,
|
||||
$currentBranch,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentUsage,
|
||||
|
|
@ -30,7 +28,6 @@ import type { StatusbarItem } from '../statusbar-controls'
|
|||
|
||||
interface StatusbarItemsOptions {
|
||||
agentsOpen: boolean
|
||||
browseSessionCwd: () => Promise<void>
|
||||
commandCenterOpen: boolean
|
||||
extraLeftItems: readonly StatusbarItem[]
|
||||
extraRightItems: readonly StatusbarItem[]
|
||||
|
|
@ -45,7 +42,6 @@ interface StatusbarItemsOptions {
|
|||
|
||||
export function useStatusbarItems({
|
||||
agentsOpen,
|
||||
browseSessionCwd,
|
||||
commandCenterOpen,
|
||||
extraLeftItems,
|
||||
extraRightItems,
|
||||
|
|
@ -58,8 +54,6 @@ export function useStatusbarItems({
|
|||
toggleCommandCenter
|
||||
}: StatusbarItemsOptions) {
|
||||
const busy = useStore($busy)
|
||||
const currentBranch = useStore($currentBranch)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
|
|
@ -79,10 +73,10 @@ export function useStatusbarItems({
|
|||
const gatewayMenuContent = useMemo(
|
||||
() => (
|
||||
<GatewayMenuPanel
|
||||
logLines={gatewayLogLines}
|
||||
onOpenSystem={() => openCommandCenterSection('system')}
|
||||
gatewayState={gatewayState}
|
||||
inferenceStatus={inferenceStatus}
|
||||
logLines={gatewayLogLines}
|
||||
onOpenSystem={() => openCommandCenterSection('system')}
|
||||
statusSnapshot={statusSnapshot}
|
||||
/>
|
||||
),
|
||||
|
|
@ -108,6 +102,7 @@ export function useStatusbarItems({
|
|||
const gatewayConnecting = gatewayState === 'connecting'
|
||||
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
|
||||
const gatewayDegraded = gatewayOpen || gatewayConnecting
|
||||
|
||||
const gatewayDetail = gatewayOpen
|
||||
? inferenceStatus?.ready
|
||||
? 'ready'
|
||||
|
|
@ -117,6 +112,7 @@ export function useStatusbarItems({
|
|||
: gatewayConnecting
|
||||
? 'connecting'
|
||||
: 'offline'
|
||||
|
||||
const gatewayClassName = inferenceReady
|
||||
? undefined
|
||||
: gatewayDegraded
|
||||
|
|
@ -277,31 +273,12 @@ export function useStatusbarItems({
|
|||
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
icon: <FolderOpen className="size-3" />,
|
||||
id: 'cwd',
|
||||
label: currentCwd ? compactPath(currentCwd) : 'No project cwd',
|
||||
onSelect: () => void browseSessionCwd(),
|
||||
title: currentCwd ? `Change working directory · ${currentCwd}` : 'Choose working directory',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
hidden: !currentBranch,
|
||||
icon: <GitBranch className="size-3" />,
|
||||
id: 'branch',
|
||||
label: currentBranch,
|
||||
title: currentBranch ? `Current branch: ${currentBranch}` : undefined,
|
||||
variant: 'text'
|
||||
},
|
||||
versionItem
|
||||
],
|
||||
[
|
||||
browseSessionCwd,
|
||||
busy,
|
||||
contextBar,
|
||||
contextUsage,
|
||||
currentBranch,
|
||||
currentCwd,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
sessionStartedAt,
|
||||
|
|
|
|||
|
|
@ -2,20 +2,17 @@ import type * as React from 'react'
|
|||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SidebarPanelLabelProps extends React.ComponentProps<'span'> {
|
||||
dotClassName?: string
|
||||
}
|
||||
type SidebarPanelLabelProps = React.ComponentProps<'span'>
|
||||
|
||||
export function SidebarPanelLabel({ children, className, dotClassName, ...props }: SidebarPanelLabelProps) {
|
||||
export function SidebarPanelLabel({ children, className, ...props }: SidebarPanelLabelProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-sidebar-foreground/72',
|
||||
'flex min-w-0 items-center gap-1.5 text-[0.6875rem] font-medium text-sidebar-foreground/55',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden="true" className={cn('dither inline-block size-2 shrink-0 rounded-[1px]', dotClassName)} />
|
||||
<span className="min-w-0 truncate leading-none">{children}</span>
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
|||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'flex h-7 shrink-0 items-stretch justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-1 py-0 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
||||
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--glass-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -89,7 +89,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
|
|
@ -150,7 +150,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-full items-center gap-1 px-1.5 text-[0.68rem] text-muted-foreground/90',
|
||||
'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
|
|
@ -163,7 +163,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
return (
|
||||
<a
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
href={item.href}
|
||||
|
|
@ -179,7 +179,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -11,7 +12,7 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { FolderOpen, NotebookTabs, Settings, Users, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
|
|
@ -40,10 +41,18 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
|
|||
interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
leftTools?: readonly TitlebarTool[]
|
||||
tools?: readonly TitlebarTool[]
|
||||
commandCenterOpen?: boolean
|
||||
onOpenSettings: () => void
|
||||
onOpenSearch: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
export function TitlebarControls({
|
||||
leftTools = [],
|
||||
tools = [],
|
||||
commandCenterOpen = false,
|
||||
onOpenSettings,
|
||||
onOpenSearch
|
||||
}: TitlebarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
|
|
@ -63,7 +72,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
|
||||
const leftToolbarTools: TitlebarTool[] = [
|
||||
{
|
||||
icon: <NotebookTabs />,
|
||||
icon: <Codicon name="layout-sidebar-left" />,
|
||||
id: 'sidebar',
|
||||
label: sidebarOpen ? 'Hide sidebar' : 'Show sidebar',
|
||||
onSelect: () => {
|
||||
|
|
@ -71,21 +80,33 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
toggleSidebarOpen()
|
||||
}
|
||||
},
|
||||
{
|
||||
active: commandCenterOpen,
|
||||
icon: <Codicon name="search" />,
|
||||
id: 'search',
|
||||
label: 'Search',
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
onOpenSearch()
|
||||
},
|
||||
title: 'Search sessions, views, and actions'
|
||||
},
|
||||
...leftTools
|
||||
]
|
||||
|
||||
const rightSidebarTool: TitlebarTool = {
|
||||
active: fileBrowserOpen,
|
||||
icon: <Codicon name="layout-sidebar-right" />,
|
||||
id: 'right-sidebar',
|
||||
label: fileBrowserOpen ? 'Hide right sidebar' : 'Show right sidebar',
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
}
|
||||
|
||||
// Static system tools — always pinned to the screen's right edge.
|
||||
const systemTools: TitlebarTool[] = [
|
||||
{
|
||||
active: fileBrowserOpen,
|
||||
icon: <FolderOpen />,
|
||||
id: 'file-browser',
|
||||
label: fileBrowserOpen ? 'Hide file browser' : 'Show file browser',
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
{
|
||||
active: hapticsMuted,
|
||||
icon: hapticsMuted ? <VolumeX /> : <Volume2 />,
|
||||
|
|
@ -94,7 +115,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
icon: <Settings />,
|
||||
icon: <Codicon name="settings-gear" />,
|
||||
id: 'settings',
|
||||
label: 'Open settings',
|
||||
onSelect: () => {
|
||||
|
|
@ -113,7 +134,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
<>
|
||||
<div
|
||||
aria-label="Window controls"
|
||||
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-[2px] flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-0.5 flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{leftToolbarTools
|
||||
.filter(tool => !tool.hidden)
|
||||
|
|
@ -150,6 +171,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
))}
|
||||
<ProfilesMenuButton navigate={navigate} />
|
||||
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
|
||||
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
@ -169,7 +191,7 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
|
|||
title="Profiles"
|
||||
type="button"
|
||||
>
|
||||
<Users />
|
||||
<Codicon name="account" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64" sideOffset={8}>
|
||||
|
|
@ -186,7 +208,7 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
|
|||
navigate(PROFILES_ROUTE)
|
||||
}}
|
||||
>
|
||||
<Users className="size-4" />
|
||||
<Codicon name="account" size="1rem" />
|
||||
<span>Manage profiles</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -198,6 +220,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
|||
const className = cn(
|
||||
titlebarButtonClass,
|
||||
'grid place-items-center bg-transparent select-none [&_svg]:size-4',
|
||||
tool.active && 'bg-(--ui-bg-tertiary)! text-foreground!',
|
||||
tool.className
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
|
|||
export const TITLEBAR_EDGE_INSET = 14
|
||||
|
||||
export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-bg-tertiary) hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderBaseClass =
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 bg-background/70 px-[max(0.75rem,var(--titlebar-content-inset,0px))] backdrop-blur-sm'
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--glass-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
|
||||
|
||||
export const titlebarHeaderShadowClass =
|
||||
"shadow-header after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']"
|
||||
"after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--glass-chat-surface-background) after:to-transparent after:content-['']"
|
||||
|
||||
export function titlebarControlsPosition(
|
||||
windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined,
|
||||
|
|
|
|||
|
|
@ -3,20 +3,19 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { Brain, RefreshCw, Search, Wrench, X } from '@/lib/icons'
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Brain, Wrench } from '@/lib/icons'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
const SKILLS_MODES = ['skills', 'toolsets'] as const
|
||||
type SkillsMode = (typeof SKILLS_MODES)[number]
|
||||
|
|
@ -64,12 +63,10 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[]
|
|||
|
||||
interface SkillsViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function SkillsView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: SkillsViewProps) {
|
||||
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
|
||||
|
|
@ -99,24 +96,6 @@ export function SkillsView({
|
|||
void refreshCapabilities()
|
||||
}, [refreshCapabilities])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarToolGroup('skills', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
|
||||
id: 'refresh-skills',
|
||||
label: refreshing ? 'Refreshing skills' : 'Refresh skills',
|
||||
onSelect: () => void refreshCapabilities()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarToolGroup('skills', [])
|
||||
}, [refreshCapabilities, refreshing, setTitlebarToolGroup])
|
||||
|
||||
const categories = useMemo(() => {
|
||||
if (!skills) {
|
||||
return []
|
||||
|
|
@ -153,7 +132,6 @@ export function SkillsView({
|
|||
}, [visibleSkills])
|
||||
|
||||
const totalSkills = skills?.length || 0
|
||||
const enabledSkills = skills?.filter(skill => skill.enabled).length || 0
|
||||
const enabledToolsets = toolsets?.filter(toolset => toolset.enabled).length || 0
|
||||
|
||||
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
|
||||
|
|
@ -175,17 +153,11 @@ export function SkillsView({
|
|||
}
|
||||
|
||||
return (
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Skills</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
{enabledSkills}/{totalSkills} enabled
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5">
|
||||
<ModeButton active={mode === 'skills'} icon={Brain} onClick={() => setMode('skills')} text="Skills" />
|
||||
<ModeButton
|
||||
active={mode === 'toolsets'}
|
||||
|
|
@ -193,33 +165,9 @@ export function SkillsView({
|
|||
onClick={() => setMode('toolsets')}
|
||||
text="Toolsets"
|
||||
/>
|
||||
<div className="ml-auto w-full max-w-sm min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 rounded-lg pl-8 pr-8 text-sm"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
|
||||
value={query}
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setQuery('')}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'skills' && categories.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
<CategoryButton
|
||||
active={activeCategory === null}
|
||||
count={totalSkills}
|
||||
|
|
@ -237,7 +185,26 @@ export function SkillsView({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshCapabilities()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? 'Refreshing skills' : 'Refresh skills'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
|
||||
{!skills || !toolsets ? (
|
||||
<PageLoader label="Loading capabilities..." />
|
||||
|
|
@ -252,10 +219,10 @@ export function SkillsView({
|
|||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{list.map(skill => (
|
||||
<div
|
||||
className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
className="grid gap-3 px-0 py-2.5 transition-colors hover:bg-(--ui-bg-quinary) sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
key={skill.name}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
|
|
@ -286,13 +253,13 @@ export function SkillsView({
|
|||
<div className="text-xs text-muted-foreground">
|
||||
{enabledToolsets}/{toolsets.length} toolsets enabled
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2.5" key={toolset.name}>
|
||||
<div className="px-0 py-2.5 transition-colors hover:bg-(--ui-bg-quinary)" key={toolset.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">{label}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
@ -309,7 +276,7 @@ export function SkillsView({
|
|||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tools.map(name => (
|
||||
<span
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.65rem] text-muted-foreground"
|
||||
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
|
||||
key={name}
|
||||
>
|
||||
{name}
|
||||
|
|
@ -325,8 +292,7 @@ export function SkillsView({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</PageSearchShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -337,7 +303,7 @@ function ModeButton({
|
|||
text
|
||||
}: {
|
||||
active: boolean
|
||||
icon: LucideIcon
|
||||
icon: IconComponent
|
||||
onClick: () => void
|
||||
text: string
|
||||
}) {
|
||||
|
|
@ -345,7 +311,9 @@ function ModeButton({
|
|||
<Button
|
||||
className={cn(
|
||||
'h-8 gap-1.5 rounded-md px-2.5 text-xs',
|
||||
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
active
|
||||
? 'bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
|
|
@ -372,8 +340,10 @@ function CategoryButton({
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center gap-1 bg-transparent px-1.5 text-[0.68rem] transition-colors',
|
||||
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
'inline-flex h-7 items-center gap-1 rounded-md bg-transparent px-1.5 text-[0.68rem] transition-colors',
|
||||
active
|
||||
? 'bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
|
|
@ -393,7 +363,9 @@ function StatusPill({ active, children }: { active: boolean; children: string })
|
|||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem]',
|
||||
active ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
active
|
||||
? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)'
|
||||
: 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
|
||||
export interface ContextSuggestion {
|
||||
text: string
|
||||
|
|
@ -62,7 +63,7 @@ export type SidebarNavId =
|
|||
export interface SidebarNavItem {
|
||||
id: SidebarNavId
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
route?: string
|
||||
action?: 'new-session'
|
||||
}
|
||||
|
|
@ -70,6 +71,8 @@ export interface SidebarNavItem {
|
|||
export interface ClientSessionState {
|
||||
storedSessionId: string | null
|
||||
messages: ChatMessage[]
|
||||
branch: string
|
||||
cwd: string
|
||||
busy: boolean
|
||||
awaitingResponse: boolean
|
||||
streamId: string | null
|
||||
|
|
|
|||
|
|
@ -1,114 +1,152 @@
|
|||
/**
|
||||
* Leva-driven palette fine-tuning, dev-mode only.
|
||||
*
|
||||
* Two folders (`Theme / Light` and `Theme / Dark`) expose color pickers
|
||||
* for the most-tweaked surface tokens of the *active* skin. Edits write
|
||||
* CSS variables directly — they're live-only and do not persist or feed
|
||||
* back into the theme resolver.
|
||||
* Two folders (`Theme / Light` and `Theme / Dark`) expose the seed colors
|
||||
* and mix percentages that drive the glass token derivation. Edits are live
|
||||
* only; use them to tune values before copying them back into presets/CSS.
|
||||
*/
|
||||
|
||||
import { button, useControls } from 'leva'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { getBaseColors, useTheme } from '@/themes/context'
|
||||
import type { DesktopThemeColors } from '@/themes/types'
|
||||
|
||||
/** Curated subset of tokens that materially change the app's look. */
|
||||
const FIELDS: Array<[keyof DesktopThemeColors, string]> = [
|
||||
['background', 'background'],
|
||||
['foreground', 'foreground'],
|
||||
['card', 'card'],
|
||||
['muted', 'muted'],
|
||||
['mutedForeground', 'muted text'],
|
||||
['primary', 'primary'],
|
||||
['primaryForeground', 'primary text'],
|
||||
['secondary', 'secondary'],
|
||||
['accent', 'accent'],
|
||||
['border', 'border'],
|
||||
['ring', 'ring'],
|
||||
['midground', 'midground'],
|
||||
['composerRing', 'composer ring'],
|
||||
['sidebarBackground', 'sidebar bg'],
|
||||
['userBubble', 'user bubble']
|
||||
]
|
||||
|
||||
const CSS_VARS: Record<keyof DesktopThemeColors, string> = {
|
||||
background: '--dt-background',
|
||||
foreground: '--dt-foreground',
|
||||
card: '--dt-card',
|
||||
cardForeground: '--dt-card-foreground',
|
||||
muted: '--dt-muted',
|
||||
mutedForeground: '--dt-muted-foreground',
|
||||
popover: '--dt-popover',
|
||||
popoverForeground: '--dt-popover-foreground',
|
||||
primary: '--dt-primary',
|
||||
primaryForeground: '--dt-primary-foreground',
|
||||
secondary: '--dt-secondary',
|
||||
secondaryForeground: '--dt-secondary-foreground',
|
||||
accent: '--dt-accent',
|
||||
accentForeground: '--dt-accent-foreground',
|
||||
border: '--dt-border',
|
||||
input: '--dt-input',
|
||||
ring: '--dt-ring',
|
||||
midground: '--dt-midground',
|
||||
midgroundForeground: '--dt-midground-foreground',
|
||||
composerRing: '--dt-composer-ring',
|
||||
destructive: '--dt-destructive',
|
||||
destructiveForeground: '--dt-destructive-foreground',
|
||||
sidebarBackground: '--dt-sidebar-bg',
|
||||
sidebarBorder: '--dt-sidebar-border',
|
||||
userBubble: '--dt-user-bubble',
|
||||
userBubbleBorder: '--dt-user-bubble-border'
|
||||
interface ThemeTuningValues {
|
||||
accentFill: number
|
||||
accentSoft: string
|
||||
backgroundSeed: string
|
||||
bubbleMix: number
|
||||
bubbleSeed: string
|
||||
cardMix: number
|
||||
cardSeed: string
|
||||
chromeMix: number
|
||||
elevatedMix: number
|
||||
elevatedSeed: string
|
||||
foreground: string
|
||||
midground: string
|
||||
primary: string
|
||||
primaryFill: number
|
||||
primaryStroke: number
|
||||
quaternaryFill: number
|
||||
quaternaryStroke: number
|
||||
quinaryFill: number
|
||||
secondary: string
|
||||
secondaryFill: number
|
||||
secondaryStroke: number
|
||||
sidebarMix: number
|
||||
sidebarSeed: string
|
||||
tertiaryFill: number
|
||||
tertiaryStroke: number
|
||||
warm: string
|
||||
}
|
||||
|
||||
const HEX_RE = /^#[0-9a-f]{6}$/i
|
||||
|
||||
// Leva's color picker only renders concrete `#rrggbb` values; non-hex seeds
|
||||
// (e.g. color-mix(...)) fall back to a dark grey so the swatch is clickable.
|
||||
const swatch = (value: string | undefined) =>
|
||||
typeof value === 'string' && HEX_RE.test(value.trim()) ? value : '#444444'
|
||||
|
||||
const setVar = (key: keyof DesktopThemeColors, value: string) =>
|
||||
document.documentElement.style.setProperty(CSS_VARS[key], value)
|
||||
const pct = (value: number) => `${value}%`
|
||||
|
||||
const defaultsFor = (mode: 'light' | 'dark') => ({
|
||||
bubbleMix: mode === 'dark' ? 48 : 30,
|
||||
cardMix: mode === 'dark' ? 38 : 22,
|
||||
chromeMix: mode === 'dark' ? 36 : 44,
|
||||
elevatedMix: mode === 'dark' ? 46 : 28,
|
||||
primaryFill: 16,
|
||||
primaryStroke: 24,
|
||||
quaternaryFill: 5,
|
||||
quaternaryStroke: 6,
|
||||
quinaryFill: 3,
|
||||
secondaryFill: 11,
|
||||
secondaryStroke: 16,
|
||||
sidebarMix: mode === 'dark' ? 42 : 36,
|
||||
tertiaryFill: 8,
|
||||
tertiaryStroke: 10
|
||||
})
|
||||
|
||||
const setCss = (name: string, value: string) => document.documentElement.style.setProperty(name, value)
|
||||
|
||||
function applyTuning(values: ThemeTuningValues) {
|
||||
setCss('--theme-foreground', values.foreground)
|
||||
setCss('--theme-primary', values.primary)
|
||||
setCss('--theme-secondary', values.secondary)
|
||||
setCss('--theme-accent-soft', values.accentSoft)
|
||||
setCss('--theme-midground', values.midground)
|
||||
setCss('--theme-warm', values.warm)
|
||||
setCss('--theme-background-seed', values.backgroundSeed)
|
||||
setCss('--theme-sidebar-seed', values.sidebarSeed)
|
||||
setCss('--theme-card-seed', values.cardSeed)
|
||||
setCss('--theme-elevated-seed', values.elevatedSeed)
|
||||
setCss('--theme-bubble-seed', values.bubbleSeed)
|
||||
setCss('--theme-mix-chrome', pct(values.chromeMix))
|
||||
setCss('--theme-mix-sidebar', pct(values.sidebarMix))
|
||||
setCss('--theme-mix-card', pct(values.cardMix))
|
||||
setCss('--theme-mix-elevated', pct(values.elevatedMix))
|
||||
setCss('--theme-mix-bubble', pct(values.bubbleMix))
|
||||
setCss('--theme-fill-primary-accent-mix', pct(values.primaryFill))
|
||||
setCss('--theme-fill-secondary-accent-mix', pct(values.secondaryFill))
|
||||
setCss('--theme-fill-tertiary-accent-mix', pct(values.tertiaryFill))
|
||||
setCss('--theme-fill-quaternary-accent-mix', pct(values.quaternaryFill))
|
||||
setCss('--theme-fill-quinary-accent-mix', pct(values.quinaryFill))
|
||||
setCss('--theme-stroke-primary-accent-mix', pct(values.primaryStroke))
|
||||
setCss('--theme-stroke-secondary-accent-mix', pct(values.secondaryStroke))
|
||||
setCss('--theme-stroke-tertiary-accent-mix', pct(values.tertiaryStroke))
|
||||
setCss('--theme-stroke-quaternary-accent-mix', pct(values.quaternaryStroke))
|
||||
}
|
||||
|
||||
function buildSchema(skinName: string, mode: 'light' | 'dark') {
|
||||
const base = getBaseColors(skinName, mode)
|
||||
const entries: Record<string, unknown> = {}
|
||||
const mix = defaultsFor(mode)
|
||||
|
||||
for (const [key, label] of FIELDS) {
|
||||
entries[key] = {
|
||||
value: swatch(base[key]),
|
||||
label,
|
||||
transient: false,
|
||||
onChange: (value: string, _path: string, ctx: { initial: boolean }) => {
|
||||
if (!ctx.initial) {
|
||||
setVar(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
const schema = {
|
||||
foreground: { value: swatch(base.foreground), label: 'text base' },
|
||||
primary: { value: swatch(base.primary), label: 'primary' },
|
||||
secondary: { value: swatch(base.secondary), label: 'secondary' },
|
||||
accentSoft: { value: swatch(base.accent), label: 'accent soft' },
|
||||
midground: { value: swatch(base.midground ?? base.ring), label: 'midground' },
|
||||
warm: { value: swatch(base.primary), label: 'warm glow' },
|
||||
backgroundSeed: { value: swatch(base.background), label: 'chrome seed' },
|
||||
sidebarSeed: { value: swatch(base.sidebarBackground ?? base.background), label: 'sidebar seed' },
|
||||
cardSeed: { value: swatch(base.card), label: 'card seed' },
|
||||
elevatedSeed: { value: swatch(base.popover), label: 'elevated seed' },
|
||||
bubbleSeed: { value: swatch(base.userBubble ?? base.popover), label: 'bubble seed' },
|
||||
chromeMix: { value: mix.chromeMix, min: 0, max: 100, step: 1, label: 'chrome mix %' },
|
||||
sidebarMix: { value: mix.sidebarMix, min: 0, max: 100, step: 1, label: 'sidebar mix %' },
|
||||
cardMix: { value: mix.cardMix, min: 0, max: 100, step: 1, label: 'card mix %' },
|
||||
elevatedMix: { value: mix.elevatedMix, min: 0, max: 100, step: 1, label: 'elevated mix %' },
|
||||
bubbleMix: { value: mix.bubbleMix, min: 0, max: 100, step: 1, label: 'bubble mix %' },
|
||||
primaryFill: { value: mix.primaryFill, min: 0, max: 40, step: 1, label: 'fill primary %' },
|
||||
secondaryFill: { value: mix.secondaryFill, min: 0, max: 40, step: 1, label: 'fill secondary %' },
|
||||
tertiaryFill: { value: mix.tertiaryFill, min: 0, max: 40, step: 1, label: 'fill tertiary %' },
|
||||
quaternaryFill: { value: mix.quaternaryFill, min: 0, max: 40, step: 1, label: 'fill quaternary %' },
|
||||
quinaryFill: { value: mix.quinaryFill, min: 0, max: 40, step: 1, label: 'fill quinary %' },
|
||||
primaryStroke: { value: mix.primaryStroke, min: 0, max: 50, step: 1, label: 'stroke primary %' },
|
||||
secondaryStroke: { value: mix.secondaryStroke, min: 0, max: 50, step: 1, label: 'stroke secondary %' },
|
||||
tertiaryStroke: { value: mix.tertiaryStroke, min: 0, max: 50, step: 1, label: 'stroke tertiary %' },
|
||||
quaternaryStroke: { value: mix.quaternaryStroke, min: 0, max: 50, step: 1, label: 'stroke quaternary %' }
|
||||
}
|
||||
|
||||
entries['reset live edits'] = button(() => {
|
||||
for (const [key] of FIELDS) {
|
||||
const v = base[key]
|
||||
return {
|
||||
...schema,
|
||||
'apply defaults': button(() => applyTuning(valuesFromSchema(schema)))
|
||||
} as Parameters<typeof useControls>[1]
|
||||
}
|
||||
|
||||
if (typeof v === 'string') {
|
||||
setVar(key, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return entries as Parameters<typeof useControls>[1]
|
||||
function valuesFromSchema(schema: Record<string, { value: number | string }>): ThemeTuningValues {
|
||||
return Object.fromEntries(Object.entries(schema).map(([key, field]) => [key, field.value])) as unknown as ThemeTuningValues
|
||||
}
|
||||
|
||||
/** Renders nothing — Leva's UI is a portal driven by `useControls`. */
|
||||
export function ThemeControls() {
|
||||
const { themeName } = useTheme()
|
||||
const { resolvedMode, themeName } = useTheme()
|
||||
const light = useMemo(() => buildSchema(themeName, 'light'), [themeName])
|
||||
const dark = useMemo(() => buildSchema(themeName, 'dark'), [themeName])
|
||||
const lightValues = useControls('Theme / Light', light, { collapsed: resolvedMode !== 'light' }, [themeName])
|
||||
const darkValues = useControls('Theme / Dark', dark, { collapsed: resolvedMode !== 'dark' }, [themeName])
|
||||
|
||||
useControls('Theme / Light', light, { collapsed: true }, [themeName])
|
||||
useControls('Theme / Dark', dark, { collapsed: true }, [themeName])
|
||||
useEffect(() => {
|
||||
applyTuning((resolvedMode === 'light' ? lightValues : darkValues) as ThemeTuningValues)
|
||||
}, [darkValues, lightValues, resolvedMode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
|
|||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line'] as const
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal'] as const
|
||||
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||||
|
||||
/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24).
|
||||
|
|
@ -37,7 +37,8 @@ const ICON_PATHS: Record<HermesRefType, string[]> = {
|
|||
'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3'
|
||||
],
|
||||
tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
|
||||
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16']
|
||||
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
|
||||
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0']
|
||||
}
|
||||
|
||||
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
|
||||
|
|
@ -112,7 +113,7 @@ export const DIRECTIVE_CHIP_CLASS =
|
|||
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
|
||||
|
||||
const HERMES_DIRECTIVE_RE = new RegExp(
|
||||
'@(file|folder|url|image|tool|line):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
|
||||
'@(file|folder|url|image|tool|line|terminal):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
|
||||
'g'
|
||||
)
|
||||
|
||||
|
|
@ -248,6 +249,10 @@ function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
|
|||
}
|
||||
|
||||
function shortLabel(type: HermesRefType, id: string): string {
|
||||
if (type === 'terminal') {
|
||||
return id || 'terminal'
|
||||
}
|
||||
|
||||
if (type === 'url') {
|
||||
try {
|
||||
const parsed = new URL(id)
|
||||
|
|
|
|||
|
|
@ -12,10 +12,8 @@ import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
|
|||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
|
||||
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
|
||||
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
|
||||
import {
|
||||
filePathFromMediaPath,
|
||||
|
|
@ -42,28 +40,6 @@ import { cn } from '@/lib/utils'
|
|||
// LLM convention). The default false-setting only accepts `$$...$$`.
|
||||
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
||||
|
||||
function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
const normalizedCode = (code ?? '').replace(/^\n+/, '').trimEnd()
|
||||
|
||||
if (!normalizedCode.trim() || isLikelyProseCodeBlock(language, normalizedCode)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||
|
||||
return (
|
||||
<div className="aui-code-header m-0 flex items-stretch justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 pr-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2.5 py-1.5 pl-3 font-mono uppercase tracking-[0.16em]">
|
||||
<span className="text-midground/85">{label || 'code'}</span>
|
||||
</span>
|
||||
<CopyButton appearance="inline" iconClassName="size-3" label="Copy code" text={normalizedCode}>
|
||||
Copy
|
||||
</CopyButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
|
||||
const blob = await fetch(dataUrl).then(response => response.blob())
|
||||
|
||||
|
|
@ -145,7 +121,7 @@ function MediaAttachment({ path }: { path: string }) {
|
|||
|
||||
if (kind === 'audio' && src) {
|
||||
return (
|
||||
<span className="my-3 block max-w-md rounded-xl border border-border/70 bg-card/70 p-3">
|
||||
<span className="my-3 block max-w-md rounded-xl border border-border bg-muted/35 p-3">
|
||||
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
|
||||
<audio className="block w-full" controls onError={() => setFailed(true)} preload="metadata" src={src} />
|
||||
{failed && <OpenMediaButton kind="audio" path={path} />}
|
||||
|
|
@ -155,7 +131,7 @@ function MediaAttachment({ path }: { path: string }) {
|
|||
|
||||
if (kind === 'video' && src) {
|
||||
return (
|
||||
<span className="my-3 block max-w-2xl rounded-xl border border-border/70 bg-card/70 p-3">
|
||||
<span className="my-3 block max-w-2xl rounded-xl border border-border bg-muted/35 p-3">
|
||||
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
|
||||
<video
|
||||
className="block max-h-112 w-full rounded-lg bg-black"
|
||||
|
|
@ -250,6 +226,15 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
|
|||
)
|
||||
}
|
||||
|
||||
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
|
||||
// table-driven so adding/tweaking levels is one row.
|
||||
const HEADING_SIZES: Record<'h1' | 'h2' | 'h3' | 'h4', string> = {
|
||||
h1: 'text-[1rem] tracking-tight',
|
||||
h2: 'text-[0.9375rem] tracking-tight',
|
||||
h3: 'text-[0.875rem]',
|
||||
h4: 'text-[0.8125rem]'
|
||||
}
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
const isStreaming = useAuiState(s => s.message.status?.type === 'running')
|
||||
|
||||
|
|
@ -257,40 +242,40 @@ const MarkdownTextImpl = () => {
|
|||
() =>
|
||||
({
|
||||
h1: ({ className, ...props }: ComponentProps<'h1'>) => (
|
||||
<h1 className={cn('text-xl font-semibold tracking-tight', className)} {...props} />
|
||||
<h1 className={cn('my-1 font-semibold', HEADING_SIZES.h1, className)} {...props} />
|
||||
),
|
||||
h2: ({ className, ...props }: ComponentProps<'h2'>) => (
|
||||
<h2 className={cn('text-lg font-semibold tracking-tight', className)} {...props} />
|
||||
<h2 className={cn('my-1 font-semibold', HEADING_SIZES.h2, className)} {...props} />
|
||||
),
|
||||
h3: ({ className, ...props }: ComponentProps<'h3'>) => (
|
||||
<h3 className={cn('text-base font-semibold', className)} {...props} />
|
||||
<h3 className={cn('my-1 font-semibold', HEADING_SIZES.h3, className)} {...props} />
|
||||
),
|
||||
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
|
||||
<h4 className={cn('text-sm font-semibold', className)} {...props} />
|
||||
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
|
||||
),
|
||||
p: ({ className, ...props }: ComponentProps<'p'>) => (
|
||||
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
|
||||
<p className={cn('my-1 wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
|
||||
),
|
||||
a: MarkdownLink,
|
||||
hr: ({ className, ...props }: ComponentProps<'hr'>) => (
|
||||
<hr className={cn('border-border/70', className)} {...props} />
|
||||
<hr className={cn('border-border', className)} {...props} />
|
||||
),
|
||||
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
|
||||
<blockquote
|
||||
className={cn('border-l-2 border-midground/40 pl-3 text-muted-foreground italic', className)}
|
||||
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ className, ...props }: ComponentProps<'ul'>) => <ul className={cn(className)} {...props} />,
|
||||
ol: ({ className, ...props }: ComponentProps<'ol'>) => <ol className={cn(className)} {...props} />,
|
||||
ul: ({ className, ...props }: ComponentProps<'ul'>) => <ul className={cn('my-1 gap-0', className)} {...props} />,
|
||||
ol: ({ className, ...props }: ComponentProps<'ol'>) => <ol className={cn('my-1 gap-0', className)} {...props} />,
|
||||
li: ({ className, ...props }: ComponentProps<'li'>) => (
|
||||
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
|
||||
),
|
||||
table: ({ className, ...props }: ComponentProps<'table'>) => (
|
||||
<div className="aui-md-table my-3 max-w-full overflow-x-auto rounded-md border border-border">
|
||||
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border">
|
||||
<table
|
||||
className={cn(
|
||||
'm-0 w-full border-collapse text-sm [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
|
||||
'm-0 w-full border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -298,23 +283,22 @@ const MarkdownTextImpl = () => {
|
|||
</div>
|
||||
),
|
||||
thead: ({ className, ...props }: ComponentProps<'thead'>) => (
|
||||
<thead className={cn('m-0 bg-muted/50 text-foreground', className)} {...props} />
|
||||
<thead className={cn('m-0 bg-muted/35 text-muted-foreground', className)} {...props} />
|
||||
),
|
||||
th: ({ className, ...props }: ComponentProps<'th'>) => (
|
||||
<th
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-left align-middle text-xs font-semibold uppercase tracking-[0.16em] text-midground/75',
|
||||
'px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ className, ...props }: ComponentProps<'td'>) => (
|
||||
<td className={cn('px-3 py-2 align-top text-sm leading-snug', className)} {...props} />
|
||||
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
|
||||
),
|
||||
img: MarkdownImage,
|
||||
SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} />,
|
||||
CodeHeader
|
||||
SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} />
|
||||
}) as StreamdownTextComponents,
|
||||
[isStreaming]
|
||||
)
|
||||
|
|
@ -324,13 +308,13 @@ const MarkdownTextImpl = () => {
|
|||
caret="block"
|
||||
components={components}
|
||||
containerClassName={cn(
|
||||
'aui-md prose w-full max-w-none overflow-hidden text-base leading-(--dt-line-height) text-foreground',
|
||||
'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
|
||||
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
|
||||
'prose-headings:text-foreground prose-strong:text-foreground',
|
||||
'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
|
||||
'prose-li:marker:text-midground/55',
|
||||
'prose-code:rounded prose-code:border-0 prose-code:bg-muted/80 prose-code:px-0.5 prose-code:py-px prose-code:font-mono prose-code:text-[0.86em] prose-code:text-muted-foreground prose-code:before:content-none prose-code:after:content-none',
|
||||
'[&>*:last-child]:mb-0'
|
||||
'prose-li:marker:text-muted-foreground/70',
|
||||
'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
|
||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-1'
|
||||
)}
|
||||
lineNumbers={false}
|
||||
mode="streaming"
|
||||
|
|
@ -345,7 +329,6 @@ const MarkdownTextImpl = () => {
|
|||
parseIncompleteMarkdown
|
||||
plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}
|
||||
preprocess={preprocessMarkdown}
|
||||
shikiTheme={['github-light-default', 'github-dark-default']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import {
|
||||
ActionBarPrimitive,
|
||||
AuiIf,
|
||||
|
|
@ -7,15 +8,51 @@ import {
|
|||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
type ToolCallMessagePartProps,
|
||||
useAui,
|
||||
useAuiEvent,
|
||||
useAuiState
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type DragEvent as ReactDragEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
|
||||
import {
|
||||
focusComposerInput,
|
||||
markActiveComposer,
|
||||
type ComposerInsertMode,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRequest
|
||||
} from '@/app/chat/composer/focus'
|
||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
||||
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from '@/app/chat/composer/rich-editor'
|
||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
||||
|
|
@ -27,6 +64,7 @@ import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/c
|
|||
import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder'
|
||||
import { Intro, type IntroProps } from '@/components/chat/intro'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -36,20 +74,10 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
GitBranchIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
Volume2Icon,
|
||||
VolumeXIcon,
|
||||
XIcon
|
||||
} from '@/lib/icons'
|
||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -112,13 +140,27 @@ function pinElementToBottom(el: HTMLElement) {
|
|||
|
||||
export const Thread: FC<{
|
||||
clampToComposer?: boolean
|
||||
cwd?: string | null
|
||||
gateway?: HermesGateway | null
|
||||
intro?: IntroProps
|
||||
loading?: ThreadLoadingState
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
onCancel?: () => Promise<void> | void
|
||||
sessionId?: string | null
|
||||
sessionKey?: string | null
|
||||
}> = ({ clampToComposer = false, intro, loading, onBranchInNewChat, sessionKey }) => {
|
||||
}> = ({ clampToComposer = false, cwd = null, gateway = null, intro, loading, onBranchInNewChat, onCancel, sessionId = null, sessionKey }) => {
|
||||
const introHero = useAuiState(s => Boolean(intro) && s.thread.isEmpty)
|
||||
|
||||
const messageComponents = useMemo(
|
||||
() => ({
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} />
|
||||
}),
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, sessionId]
|
||||
)
|
||||
|
||||
return (
|
||||
<GeneratedImageProvider>
|
||||
<ThreadPrimitive.Root className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
||||
|
|
@ -132,10 +174,10 @@ export const Thread: FC<{
|
|||
<ThreadScrollSync sessionKey={sessionKey} />
|
||||
<StickToBottom.Content
|
||||
className={cn(
|
||||
'scroll-auto mx-auto min-h-full w-full max-w-[calc(var(--composer-width)-2rem)] min-w-0 gap-3 px-4 sm:px-6 lg:px-8',
|
||||
'scroll-auto mx-auto min-h-full w-full max-w-(--composer-width) min-w-0 gap-(--conversation-turn-gap) px-6',
|
||||
introHero
|
||||
? 'grid grid-rows-[minmax(0,1fr)_auto] py-[calc(var(--vsq)*12)]'
|
||||
: 'flex flex-col pt-[calc(var(--vsq)*19)]'
|
||||
? 'grid grid-rows-[minmax(0,1fr)_auto] py-8'
|
||||
: 'flex flex-col pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
)}
|
||||
data-slot="aui_thread-content"
|
||||
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
|
|
@ -150,15 +192,11 @@ export const Thread: FC<{
|
|||
</div>
|
||||
) : null}
|
||||
</AuiIf>
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
||||
SystemMessage,
|
||||
UserEditComposer,
|
||||
UserMessage
|
||||
}}
|
||||
/>
|
||||
<GroupedThreadMessages components={messageComponents} />
|
||||
{loading === 'response' && <ResponseLoadingIndicator />}
|
||||
{clampToComposer && (
|
||||
<div aria-hidden="true" className="shrink-0" style={{ height: 'var(--thread-last-message-clearance)' }} />
|
||||
)}
|
||||
</StickToBottom.Content>
|
||||
</StickToBottom>
|
||||
</ThreadPrimitive.ViewportProvider>
|
||||
|
|
@ -168,6 +206,69 @@ export const Thread: FC<{
|
|||
)
|
||||
}
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
function GroupedThreadMessages({ components }: { components: ThreadMessageComponents }) {
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
|
||||
)
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const messages = messageSignature
|
||||
? messageSignature.split('\n').map(row => {
|
||||
const [index, id, role] = row.split(':')
|
||||
|
||||
return { id, index: Number(index), role }
|
||||
})
|
||||
: []
|
||||
|
||||
const result: Array<{ id: string; indices: number[]; role: string }> = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
if (message.role !== 'user') {
|
||||
result.push({ id: message.id, indices: [message.index], role: message.role })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indices = [message.index]
|
||||
let j = i + 1
|
||||
|
||||
while (j < messages.length && messages[j].role !== 'user') {
|
||||
indices.push(messages[j].index)
|
||||
j++
|
||||
}
|
||||
|
||||
result.push({ id: message.id, indices, role: 'turn' })
|
||||
i = j - 1
|
||||
}
|
||||
|
||||
return result
|
||||
}, [messageSignature])
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map(group =>
|
||||
group.role === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
key={group.id}
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.indices[0]} key={group.id} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => {
|
||||
const { scrollRef, isAtBottom, state } = useStickToBottomContext()
|
||||
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
|
||||
|
|
@ -386,7 +487,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
'wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-base leading-(--dt-line-height) text-foreground',
|
||||
'wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
|
||||
interruptedOnly && 'text-[0.8rem] leading-5 text-muted-foreground/82'
|
||||
)}
|
||||
data-slot="aui_assistant-message-content"
|
||||
|
|
@ -438,7 +539,7 @@ const ResponseLoadingIndicator: FC = () => {
|
|||
|
||||
return (
|
||||
<StatusRow data-slot="aui_response-loading" label="Hermes is loading a response">
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
<span aria-hidden="true" className="inline-block size-1.5 rounded-full bg-(--ui-orange) animate-pulse" />
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
|
|
@ -457,7 +558,7 @@ const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="mt-1.5">
|
||||
<ImageGenerationPlaceholder />
|
||||
</div>
|
||||
)
|
||||
|
|
@ -527,28 +628,29 @@ const ThinkingDisclosure: FC<{
|
|||
}, [isPreview])
|
||||
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground" data-slot="aui_thinking-disclosure" ref={enterRef}>
|
||||
<div className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)" data-slot="aui_thinking-disclosure" ref={enterRef}>
|
||||
<DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
|
||||
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)',
|
||||
pending && 'shimmer text-foreground/55'
|
||||
)}
|
||||
>
|
||||
Thinking
|
||||
</span>
|
||||
{pending && (
|
||||
<ActivityTimerText className="text-[0.625rem] tabular-nums text-muted-foreground/55" seconds={elapsed} />
|
||||
<ActivityTimerText className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)" seconds={elapsed} />
|
||||
)}
|
||||
</span>
|
||||
</DisclosureRow>
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
// Keep the reasoning body tucked close to the "Thinking" row so
|
||||
// it aligns with tool-group disclosure rhythm.
|
||||
'mt-0.5 w-full min-w-0 max-w-full overflow-hidden pr-2 pl-3 wrap-anywhere pb-1',
|
||||
// Body sits flush with the "Thinking" header — no left indent —
|
||||
// and inherits the disclosure-level opacity fade defined in
|
||||
// styles.css (~0.67 at rest, 1 on hover/focus).
|
||||
'mt-0.5 w-full min-w-0 max-w-full overflow-hidden wrap-anywhere pb-1',
|
||||
isPreview && 'thinking-preview max-h-40'
|
||||
)}
|
||||
ref={scrollRef}
|
||||
|
|
@ -660,10 +762,10 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
|||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<div className="relative flex w-full shrink-0 justify-end">
|
||||
<ActionBarPrimitive.Root
|
||||
className={cn(
|
||||
'relative flex flex-row items-center gap-2 py-2.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
|
||||
'relative flex flex-row items-center justify-end gap-2 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
|
||||
menuOpen && 'pointer-events-auto opacity-100 [&_button]:opacity-100'
|
||||
)}
|
||||
data-slot="aui_msg-actions"
|
||||
|
|
@ -672,13 +774,13 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
|||
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label="Copy" text={messageText} />
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh">
|
||||
<RefreshCwIcon />
|
||||
<Codicon name="refresh" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipIconButton tooltip="More actions">
|
||||
<MoreHorizontalIcon />
|
||||
<Codicon name="ellipsis" />
|
||||
</TooltipIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
|
||||
|
|
@ -744,19 +846,19 @@ const MessageTimestamp: FC = () => {
|
|||
}
|
||||
|
||||
const AssistantFooter: FC<MessageActionProps> = props => (
|
||||
<div className="flex min-h-6 flex-col items-start gap-1 pl-(--message-text-indent)">
|
||||
<div className="flex min-h-6 flex-col items-end gap-1 pr-(--message-text-indent) pl-(--message-text-indent)">
|
||||
<BranchPickerPrimitive.Root
|
||||
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35">
|
||||
<ChevronLeftIcon className="size-3.5" />
|
||||
<Codicon name="chevron-left" size="0.875rem" />
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="tabular-nums">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35">
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
<Codicon name="chevron-right" size="0.875rem" />
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
<AssistantActionBar {...props} />
|
||||
|
|
@ -773,9 +875,44 @@ function messageAttachmentRefs(value: unknown): string[] {
|
|||
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
const UserMessage: FC = () => {
|
||||
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="group/user-message sticky top-0 z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--glass-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Shared "user bubble" base. Both the read-only message and the inline
|
||||
// edit composer render the same bubble surface (rounded glass card,
|
||||
// shadow-composer); they only differ in border weight, cursor, and
|
||||
// padding-right (the read-only view reserves room for the restore icon).
|
||||
const USER_BUBBLE_BASE_CLASS =
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left shadow-composer'
|
||||
|
||||
const UserMessage: FC<{
|
||||
onCancel?: () => Promise<void> | void
|
||||
}> = ({ onCancel }) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const threadRunning = useAuiState(s => s.thread.isRunning)
|
||||
|
||||
const latestUserId = useAuiState(s => {
|
||||
for (let i = s.thread.messages.length - 1; i >= 0; i--) {
|
||||
const message = s.thread.messages[i] as { id?: string; role?: string }
|
||||
|
||||
if (message.role === 'user') {
|
||||
return message.id ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const attachmentRefs = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
|
||||
|
|
@ -784,47 +921,95 @@ const UserMessage: FC = () => {
|
|||
})
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
||||
const showRestore = !isLatestUser && !threadRunning
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex min-w-0 max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end overflow-hidden"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 text-base leading-(--dt-line-height) text-foreground/95">
|
||||
{attachmentRefs.length > 0 && (
|
||||
<div className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
<MessagePrimitive.Root asChild>
|
||||
<StickyHumanMessageContainer>
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions" hideWhenRunning>
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label="Edit message"
|
||||
className={cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'cursor-pointer border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors hover:border-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title="Edit message"
|
||||
type="button"
|
||||
>
|
||||
{attachmentRefs.length > 0 && (
|
||||
<span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</span>
|
||||
)}
|
||||
{hasBody && (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-1.5 bottom-1.5 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
<button
|
||||
aria-label="Stop"
|
||||
className="stop-button pointer-events-auto grid size-6 place-items-center rounded-full bg-(--ui-text-primary) text-(--ui-bg-editor) shadow-sm hover:opacity-90"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title="Stop"
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="debug-stop" size="0.75rem" />
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="restore-button flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
||||
title="Editable checkpoint"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasBody && (
|
||||
<div className="wrap-anywhere whitespace-pre-line">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<UserActionBar messageText={messageText} />
|
||||
<BranchPickerPrimitive.Root
|
||||
className="checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
|
||||
<BranchPickerPrimitive.Previous
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden"
|
||||
title="Restore previous checkpoint"
|
||||
>
|
||||
Restore checkpoint
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="checkpoint-divider opacity-55">
|
||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden"
|
||||
title="Restore next checkpoint"
|
||||
>
|
||||
Go forward
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
</StickyHumanMessageContainer>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const UserActionBar: FC<{ messageText: string }> = ({ messageText }) => (
|
||||
<div className="relative shrink-0">
|
||||
<ActionBarPrimitive.Root
|
||||
className="relative flex flex-row items-center gap-2 py-2.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100"
|
||||
data-slot="aui_msg-actions"
|
||||
hideWhenRunning
|
||||
>
|
||||
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label="Copy" text={messageText} />
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('selection')} tooltip="Edit">
|
||||
<PencilIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Edit>
|
||||
</ActionBarPrimitive.Root>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
|
||||
|
||||
const SystemMessage: FC = () => {
|
||||
|
|
@ -861,29 +1046,449 @@ const SystemMessage: FC = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const UserEditComposer: FC = () => (
|
||||
<ComposerPrimitive.Root
|
||||
className="flex min-w-[min(18rem,72vw)] max-w-[min(72%,34rem)] flex-col gap-1.5 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_88%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_98%,transparent)] px-3 py-2 shadow-sm"
|
||||
data-slot="aui_edit-composer-root"
|
||||
>
|
||||
<ComposerPrimitive.Input
|
||||
autoFocus
|
||||
className="min-h-8 w-full resize-none bg-transparent text-base leading-(--dt-line-height) text-foreground/95 outline-none"
|
||||
rows={1}
|
||||
submitMode="enter"
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
/>
|
||||
<div className="flex justify-end gap-1">
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<TooltipIconButton tooltip="Cancel edit">
|
||||
<XIcon />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.Cancel>
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Send edit">
|
||||
<CheckIcon />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.Send>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
)
|
||||
interface UserEditComposerProps {
|
||||
cwd: string | null
|
||||
gateway: HermesGateway | null
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
|
||||
const aui = useAui()
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const dragDepthRef = useRef(0)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const expanded = draft.includes('\n') || draft.length > 96
|
||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||
const slash = useSlashCompletions({ gateway })
|
||||
|
||||
const focusEditor = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
focusComposerInput(editor)
|
||||
|
||||
if (editor) {
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
markActiveComposer('edit')
|
||||
}, [])
|
||||
|
||||
const requestEditFocus = useCallback(() => {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}, [])
|
||||
|
||||
const appendExternalText = useCallback(
|
||||
(text: string, mode: ComposerInsertMode) => {
|
||||
const value = text.trim()
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
|
||||
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
|
||||
const next = `${base}${sep}${value}`
|
||||
|
||||
draftRef.current = next
|
||||
aui.composer().setText(next)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
renderComposerContents(editor, next)
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
setFocusRequestId(id => id + 1)
|
||||
},
|
||||
[aui]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor && (editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))) {
|
||||
renderComposerContents(editor, draft)
|
||||
|
||||
if (document.activeElement === editor) {
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
useEffect(() => {
|
||||
focusEditor()
|
||||
}, [focusEditor, focusRequestId])
|
||||
|
||||
useEffect(() => {
|
||||
const offFocus = onComposerFocusRequest(target => {
|
||||
if (target === 'edit') {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}
|
||||
})
|
||||
|
||||
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
|
||||
if (target === 'edit') {
|
||||
appendExternalText(text, mode)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText])
|
||||
|
||||
const syncDraftFromEditor = useCallback(
|
||||
(editor: HTMLDivElement) => {
|
||||
const nextDraft = composerPlainText(editor)
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
return nextDraft
|
||||
},
|
||||
[aui]
|
||||
)
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
if (detected) {
|
||||
const rect = editor.getBoundingClientRect()
|
||||
const spaceAbove = rect.top
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
|
||||
setTriggerPlacement(spaceAbove < 220 && spaceBelow > spaceAbove ? 'bottom' : 'top')
|
||||
}
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
|
||||
const closeTrigger = useCallback(() => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
setTriggerItems([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTriggerItems(triggerAdapter.search(trigger.query))
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
useEffect(() => {
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
|
||||
const replaceTriggerWithChip = useCallback(
|
||||
(item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || !trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
requestEditFocus()
|
||||
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
|
||||
const sel = window.getSelection()
|
||||
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
|
||||
const node = range?.startContainer
|
||||
const offset = range?.startOffset ?? 0
|
||||
|
||||
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
||||
const current = composerPlainText(editor)
|
||||
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
||||
placeCaretEnd(editor)
|
||||
|
||||
return finish()
|
||||
}
|
||||
|
||||
const replaceRange = document.createRange()
|
||||
replaceRange.setStart(node, offset - trigger.tokenLength)
|
||||
replaceRange.setEnd(node, offset)
|
||||
replaceRange.deleteContents()
|
||||
|
||||
if (directive) {
|
||||
const chip = refChipElement(directive[1], directive[2])
|
||||
const space = document.createTextNode(' ')
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.append(chip, space)
|
||||
replaceRange.insertNode(fragment)
|
||||
|
||||
const caret = document.createRange()
|
||||
caret.setStart(space, 1)
|
||||
caret.collapse(true)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(caret)
|
||||
|
||||
return finish()
|
||||
}
|
||||
|
||||
document.execCommand('insertText', false, text)
|
||||
finish()
|
||||
},
|
||||
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
|
||||
)
|
||||
|
||||
const insertDroppedRefs = useCallback(
|
||||
(candidates: ReturnType<typeof extractDroppedFiles>) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const refs = candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
|
||||
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
||||
|
||||
if (nextDraft === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
requestEditFocus()
|
||||
|
||||
return true
|
||||
},
|
||||
[aui, cwd, requestEditFocus]
|
||||
)
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
dragDepthRef.current += 1
|
||||
|
||||
if (!dragActive) {
|
||||
setDragActive(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||
|
||||
if (!candidates.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDragState()
|
||||
|
||||
if (insertDroppedRefs(candidates)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const editor = event.currentTarget
|
||||
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
|
||||
syncDraftFromEditor(editor)
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const pastedText = event.clipboardData.getData('text')
|
||||
|
||||
if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
document.execCommand('insertText', false, pastedText)
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
}
|
||||
|
||||
const submitEdit = (editor: HTMLDivElement) => {
|
||||
syncDraftFromEditor(editor)
|
||||
aui.composer().send()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
replaceTriggerWithChip(item)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
aui.composer().cancel()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submitEdit(event.currentTarget)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root
|
||||
className="contents"
|
||||
data-slot="aui_edit-composer-root"
|
||||
>
|
||||
<StickyHumanMessageContainer>
|
||||
<div
|
||||
className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--glass-chat-surface-background)"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{trigger && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
kind={trigger.kind}
|
||||
loading={triggerLoading}
|
||||
onHover={setTriggerActive}
|
||||
onPick={replaceTriggerWithChip}
|
||||
placement={triggerPlacement}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'ui-prompt-input__container relative border-(--ui-stroke-secondary) data-[expanded=true]:min-h-20',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-expanded={expanded ? 'true' : undefined}
|
||||
>
|
||||
<div
|
||||
aria-label="Edit message"
|
||||
autoFocus
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
)}
|
||||
contentEditable
|
||||
data-placeholder="Edit message"
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onFocus={() => markActiveComposer('edit')}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={() => window.setTimeout(refreshTrigger, 0)}
|
||||
onMouseUp={refreshTrigger}
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||
</div>
|
||||
</div>
|
||||
</StickyHumanMessageContainer>
|
||||
</ComposerPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { normalizeExternalUrl } from '@/lib/external-link'
|
||||
import { Command, FileText, Globe, LinkIcon, Search, Sparkles, Wrench } from '@/lib/icons'
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
|
||||
|
||||
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
|
||||
|
|
@ -31,7 +29,7 @@ export interface ToolView {
|
|||
detail: string
|
||||
detailLabel: string
|
||||
durationLabel?: string
|
||||
icon: LucideIcon
|
||||
icon?: string
|
||||
imageUrl?: string
|
||||
inlineDiff: string
|
||||
previewTarget?: string
|
||||
|
|
@ -46,7 +44,7 @@ export interface ToolView {
|
|||
|
||||
interface ToolMeta {
|
||||
done: string
|
||||
icon: LucideIcon
|
||||
icon?: string
|
||||
pending: string
|
||||
tone: ToolTone
|
||||
}
|
||||
|
|
@ -63,39 +61,39 @@ export interface MessageRunningStateSlice {
|
|||
}
|
||||
|
||||
const TOOL_META: Record<string, ToolMeta> = {
|
||||
browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: Globe, tone: 'browser' },
|
||||
browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: Globe, tone: 'browser' },
|
||||
browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: Globe, tone: 'browser' },
|
||||
browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' },
|
||||
browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' },
|
||||
browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: 'globe', tone: 'browser' },
|
||||
browser_snapshot: {
|
||||
done: 'Captured page snapshot',
|
||||
pending: 'Capturing page snapshot',
|
||||
icon: Globe,
|
||||
icon: 'globe',
|
||||
tone: 'browser'
|
||||
},
|
||||
browser_take_screenshot: {
|
||||
done: 'Captured screenshot',
|
||||
pending: 'Capturing screenshot',
|
||||
icon: Sparkles,
|
||||
icon: 'file-media',
|
||||
tone: 'browser'
|
||||
},
|
||||
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: Globe, tone: 'browser' },
|
||||
edit_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' },
|
||||
execute_code: { done: 'Ran code', pending: 'Running code', icon: Command, tone: 'terminal' },
|
||||
image_generate: { done: 'Generated image', pending: 'Generating image', icon: Sparkles, tone: 'image' },
|
||||
list_files: { done: 'Listed files', pending: 'Listing files', icon: FileText, tone: 'file' },
|
||||
read_file: { done: 'Read file', pending: 'Reading file', icon: FileText, tone: 'file' },
|
||||
search_files: { done: 'Searched files', pending: 'Searching files', icon: FileText, tone: 'file' },
|
||||
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
|
||||
edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
|
||||
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
|
||||
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
|
||||
list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' },
|
||||
read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' },
|
||||
search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' },
|
||||
session_search_recall: {
|
||||
done: 'Searched session history',
|
||||
pending: 'Searching session history',
|
||||
icon: Search,
|
||||
icon: 'search',
|
||||
tone: 'agent'
|
||||
},
|
||||
terminal: { done: 'Ran command', pending: 'Running command', icon: Command, tone: 'terminal' },
|
||||
todo: { done: 'Updated todos', pending: 'Updating todos', icon: Wrench, tone: 'agent' },
|
||||
web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: LinkIcon, tone: 'web' },
|
||||
web_search: { done: 'Searched web', pending: 'Searching web', icon: Search, tone: 'web' },
|
||||
write_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' }
|
||||
terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' },
|
||||
todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' },
|
||||
web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' },
|
||||
web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' },
|
||||
write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }
|
||||
}
|
||||
|
||||
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
|
||||
|
|
@ -117,9 +115,9 @@ function titleForTool(name: string): string {
|
|||
)
|
||||
}
|
||||
|
||||
const PREFIX_META: { icon: LucideIcon; prefix: string; tone: ToolTone; verb: string }[] = [
|
||||
{ prefix: 'browser_', verb: 'Browser', icon: Globe, tone: 'browser' },
|
||||
{ prefix: 'web_', verb: 'Web', icon: Search, tone: 'web' }
|
||||
const PREFIX_META: { icon?: string; prefix: string; tone: ToolTone; verb: string }[] = [
|
||||
{ prefix: 'browser_', verb: 'Browser', icon: 'globe', tone: 'browser' },
|
||||
{ prefix: 'web_', verb: 'Web', icon: 'globe', tone: 'web' }
|
||||
]
|
||||
|
||||
function toolMeta(name: string): ToolMeta {
|
||||
|
|
@ -137,7 +135,7 @@ function toolMeta(name: string): ToolMeta {
|
|||
icon: prefix.icon,
|
||||
tone: prefix.tone
|
||||
}
|
||||
: { done: action, pending: `Running ${action.toLowerCase()}`, icon: Wrench, tone: 'default' }
|
||||
: { done: action, pending: `Running ${action.toLowerCase()}`, tone: 'default' }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import { useShallow } from 'zustand/shallow'
|
|||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { CompactMarkdown } from '@/components/chat/compact-markdown'
|
||||
import { DiffLines } from '@/components/chat/diff-lines'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
||||
|
|
@ -25,11 +27,9 @@ import {
|
|||
groupCopyText as buildGroupCopyText,
|
||||
buildToolView,
|
||||
cleanVisibleText,
|
||||
compactPreview,
|
||||
groupFailedStepCount,
|
||||
groupPreviewTargets,
|
||||
groupStatus,
|
||||
groupTailSubtitle,
|
||||
groupTitle,
|
||||
groupTotalDurationLabel,
|
||||
inlineDiffFromResult,
|
||||
|
|
@ -54,27 +54,50 @@ const SPECIAL_TOOL_NAMES = new Set(['todo', 'image_generate', 'clarify'])
|
|||
// the group already shows.
|
||||
const ToolEmbedContext = createContext(false)
|
||||
|
||||
const STATUS_DOT_CLASS: Record<ToolStatus, string> = {
|
||||
error: 'bg-destructive',
|
||||
running: 'bg-muted-foreground/55 animate-pulse',
|
||||
success: 'bg-emerald-500',
|
||||
warning: 'bg-amber-500'
|
||||
}
|
||||
// Shared header chrome for tool rows. Both the single-tool DisclosureRow
|
||||
// and the multi-tool group header pass through these constants so a
|
||||
// "Patch" row and a "Tool actions · 2 steps" row are visually identical.
|
||||
const TOOL_HEADER_TITLE_CLASS =
|
||||
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)'
|
||||
|
||||
const STATUS_LABEL: Record<ToolStatus, string> = {
|
||||
error: 'Error',
|
||||
running: 'Running',
|
||||
success: 'Done',
|
||||
warning: 'Recovered'
|
||||
}
|
||||
const TOOL_HEADER_DURATION_CLASS =
|
||||
'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)'
|
||||
|
||||
function statusDot(status: ToolStatus): ReactNode {
|
||||
return (
|
||||
<span
|
||||
aria-label={STATUS_LABEL[status]}
|
||||
className={cn('size-1.5 shrink-0 rounded-full', STATUS_DOT_CLASS[status])}
|
||||
/>
|
||||
)
|
||||
const TOOL_HEADER_SUBTITLE_CLASS =
|
||||
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)'
|
||||
|
||||
const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center self-center'
|
||||
|
||||
// Glass-style section label that sits above any pre/JSON/output block.
|
||||
// Lowercase tracking + tiny size so it reads as a quiet field label rather
|
||||
// than a chrome heading. Used for "COMMAND OUTPUT", "INPUT", "OUTPUT", etc.
|
||||
const TOOL_SECTION_LABEL_CLASS =
|
||||
'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)'
|
||||
|
||||
// Inset scroll surface for any detail body. The expanded tool row owns the
|
||||
// border; the payload itself is just clipped raw text.
|
||||
const TOOL_SECTION_SURFACE_CLASS =
|
||||
'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)'
|
||||
|
||||
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
|
||||
|
||||
function rawTechnicalTrace(args: unknown, result: unknown): string {
|
||||
const parts = [args, result]
|
||||
.filter(value => value !== undefined && value !== null)
|
||||
.map(value => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function statusGlyph(status: ToolStatus): ReactNode {
|
||||
|
|
@ -82,7 +105,7 @@ function statusGlyph(status: ToolStatus): ReactNode {
|
|||
return (
|
||||
<BrailleSpinner
|
||||
ariaLabel="Running"
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
|
||||
spinner="breathe"
|
||||
/>
|
||||
)
|
||||
|
|
@ -99,6 +122,27 @@ function statusGlyph(status: ToolStatus): ReactNode {
|
|||
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
}
|
||||
|
||||
// Leading glyph for any tool-row header. Status (running/error/warning)
|
||||
// takes precedence; otherwise falls back to the tool's codicon. Returns
|
||||
// null when neither applies so callers can render unconditionally.
|
||||
function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) {
|
||||
const node = status ? (
|
||||
statusGlyph(status)
|
||||
) : icon ? (
|
||||
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
||||
) : null
|
||||
|
||||
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
|
||||
}
|
||||
|
||||
// Which status (if any) should pre-empt the tool's icon in the leading
|
||||
// slot. Success is silent — the row reads as "done" without a checkmark.
|
||||
function leadingStatus(isPending: boolean, status: ToolStatus): ToolStatus | undefined {
|
||||
if (isPending) return 'running'
|
||||
|
||||
return status === 'success' ? undefined : status
|
||||
}
|
||||
|
||||
function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
|
||||
return (
|
||||
<ol className="m-0 grid list-none gap-2.5 p-0">
|
||||
|
|
@ -110,16 +154,16 @@ function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
|
|||
<li className="grid min-w-0 gap-0.5" key={key}>
|
||||
{hit.url ? (
|
||||
<PrettyLink
|
||||
className="block max-w-full text-[0.78rem] leading-snug"
|
||||
className={cn(TOOL_HEADER_TITLE_CLASS, 'block max-w-full')}
|
||||
fallbackLabel={trimmedTitle || urlSlugTitleLabel(hit.url)}
|
||||
href={hit.url}
|
||||
label={trimmedTitle || undefined}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[0.78rem] font-medium leading-snug text-foreground/85">{trimmedTitle}</span>
|
||||
<span className={TOOL_HEADER_TITLE_CLASS}>{trimmedTitle}</span>
|
||||
)}
|
||||
{hit.snippet && (
|
||||
<p className="m-0 line-clamp-3 text-[0.7rem] leading-snug text-muted-foreground/85">{hit.snippet}</p>
|
||||
<p className={cn(TOOL_HEADER_SUBTITLE_CLASS, 'm-0 line-clamp-3')}>{hit.snippet}</p>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
|
|
@ -156,7 +200,6 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
// handles its own enter animation, so embedded children skip it.
|
||||
const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`)
|
||||
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
|
||||
const preview = compactPreview(part.args) || compactPreview(part.result)
|
||||
const liveDiffs = useStore($toolInlineDiffs)
|
||||
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
|
||||
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result)
|
||||
|
|
@ -224,97 +267,71 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
toolViewMode === 'technical'
|
||||
)
|
||||
|
||||
const isTerminalLike = part.toolName === 'terminal' || part.toolName === 'execute_code'
|
||||
const subtitleText = view.subtitle ? (toolViewMode === 'technical' ? preview || view.subtitle : view.subtitle) : ''
|
||||
const subtitleIsSingleLine = !subtitleText.includes('\n')
|
||||
const showStatusGlyph = isPending || view.status === 'error' || view.status === 'warning'
|
||||
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
|
||||
|
||||
const trailing =
|
||||
isPending && !embedded ? (
|
||||
<ActivityTimerText className="text-[0.625rem] tabular-nums text-muted-foreground/55" seconds={elapsed} />
|
||||
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
|
||||
) : !isPending && copyAction.text ? (
|
||||
<CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} />
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-0 max-w-full overflow-hidden text-sm text-muted-foreground"
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
|
||||
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
|
||||
)}
|
||||
data-slot="tool-block"
|
||||
ref={enterRef}
|
||||
>
|
||||
<DisclosureRow
|
||||
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
|
||||
open={open}
|
||||
trailing={trailing}
|
||||
>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
{showStatusGlyph && (
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">
|
||||
{statusGlyph(isPending ? 'running' : view.status)}
|
||||
</span>
|
||||
)}
|
||||
<FadeText
|
||||
className={cn(
|
||||
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
|
||||
isPending && 'shimmer text-foreground/55',
|
||||
view.status === 'error' && 'text-destructive',
|
||||
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
|
||||
)}
|
||||
>
|
||||
{view.title}
|
||||
</FadeText>
|
||||
{!isPending && view.countLabel && (
|
||||
<span className="shrink-0 text-[0.68rem] tabular-nums text-foreground/70">{view.countLabel}</span>
|
||||
)}
|
||||
{!isPending && view.durationLabel && (
|
||||
<span className="shrink-0 text-[0.625rem] tabular-nums text-midground/60 tracking-[0.04em]">
|
||||
{view.durationLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{subtitleText &&
|
||||
(subtitleIsSingleLine ? (
|
||||
<div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}>
|
||||
<DisclosureRow
|
||||
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
|
||||
open={open}
|
||||
trailing={trailing}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ToolGlyph icon={view.icon} status={leadingStatus(isPending, view.status)} />
|
||||
<FadeText
|
||||
className={cn(
|
||||
'text-[0.7rem] leading-[1.05rem] text-muted-foreground/70',
|
||||
isTerminalLike && 'font-mono text-[0.68rem]'
|
||||
TOOL_HEADER_TITLE_CLASS,
|
||||
isPending && 'shimmer text-(--ui-text-tertiary)',
|
||||
view.status === 'error' && 'text-destructive',
|
||||
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
|
||||
)}
|
||||
>
|
||||
{subtitleText}
|
||||
{view.title}
|
||||
</FadeText>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'line-clamp-2 block whitespace-pre-wrap text-[0.7rem] leading-[1.05rem] text-muted-foreground/70',
|
||||
isTerminalLike && 'font-mono text-[0.68rem]'
|
||||
)}
|
||||
>
|
||||
{subtitleText}
|
||||
</span>
|
||||
))}
|
||||
</DisclosureRow>
|
||||
{!isPending && view.countLabel && (
|
||||
<span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>
|
||||
)}
|
||||
{!isPending && view.durationLabel && (
|
||||
<span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
{open && (
|
||||
<div className={cn('mt-2 grid w-full min-w-0 max-w-full gap-2 overflow-hidden pb-2 pr-2 pl-3')}>
|
||||
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
|
||||
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
|
||||
<PreviewAttachment source="tool-result" target={view.previewTarget} />
|
||||
)}
|
||||
{view.imageUrl && (
|
||||
<div className="max-w-72 overflow-hidden rounded-lg border border-border/70">
|
||||
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
|
||||
<ZoomableImage alt="Tool output" className="h-auto w-full object-cover" src={view.imageUrl} />
|
||||
</div>
|
||||
)}
|
||||
{hasSearchHits && view.searchHits && (
|
||||
<div className="max-w-full text-xs leading-relaxed text-muted-foreground/90">
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{searchResultsLabel && (
|
||||
<p className="mb-1 text-[0.66rem] font-medium uppercase tracking-[0.06em] text-muted-foreground/65">
|
||||
{searchResultsLabel}
|
||||
</p>
|
||||
<p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>
|
||||
)}
|
||||
<SearchResultsList hits={view.searchHits} />
|
||||
</div>
|
||||
)}
|
||||
{showDetail &&
|
||||
toolViewMode !== 'technical' &&
|
||||
(view.status === 'error' ? (
|
||||
detailSections.summary || detailSections.body ? (
|
||||
<div className="max-w-full text-xs leading-relaxed text-destructive">
|
||||
|
|
@ -334,53 +351,38 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<div className="max-w-full text-xs leading-relaxed text-muted-foreground/90">
|
||||
{view.detailLabel && (
|
||||
<p className="mb-1 text-[0.66rem] font-medium uppercase tracking-[0.06em] text-muted-foreground/65">
|
||||
{view.detailLabel}
|
||||
</p>
|
||||
)}
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
||||
{renderDetailAsCode ? (
|
||||
<pre className="max-h-56 max-w-full overflow-auto whitespace-pre-wrap wrap-anywhere border-l-2 border-border/50 pl-3 font-mono text-[0.7rem] leading-[1.55] text-foreground/85">
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.detail}
|
||||
</pre>
|
||||
) : (
|
||||
<CompactMarkdown text={view.detail} />
|
||||
<CompactMarkdown
|
||||
className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')}
|
||||
text={view.detail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showRawSearchDrilldown && (
|
||||
<details className="max-w-full rounded-md border border-border/60 bg-background/55 px-2 py-1.5">
|
||||
<summary className="cursor-pointer text-[0.65rem] font-medium uppercase tracking-[0.06em] text-muted-foreground/70">
|
||||
<details className="max-w-full">
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'cursor-pointer mb-0')}>
|
||||
Raw response
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-56 max-w-full overflow-auto whitespace-pre-wrap wrap-anywhere font-mono text-[0.66rem] leading-normal text-muted-foreground/90">
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rawResult}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{toolViewMode === 'technical' && (
|
||||
<div className="grid gap-2">
|
||||
<JsonSection label="Input" value={view.rawArgs} />
|
||||
{part.result !== undefined && <JsonSection label="Output" value={view.rawResult} />}
|
||||
</div>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{rawTechnicalTrace(part.args, part.result)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{view.inlineDiff && <InlineDiff text={view.inlineDiff} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JsonSection({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-[0.65rem] font-medium tracking-[0.08em] text-muted-foreground/75 uppercase">
|
||||
{label}
|
||||
</div>
|
||||
<pre className="max-h-56 max-w-full overflow-auto rounded-md border border-border/70 bg-background/65 p-2 font-mono text-[0.65rem] leading-relaxed text-muted-foreground/90">
|
||||
{value}
|
||||
</pre>
|
||||
{view.inlineDiff && <DiffLines text={view.inlineDiff} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -454,10 +456,8 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
|
|||
? '1 step failed'
|
||||
: `${failedStepCount} steps failed`
|
||||
|
||||
const tailSummary = useMemo(() => groupTailSubtitle(visibleParts), [visibleParts])
|
||||
const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts])
|
||||
const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts])
|
||||
const showGroupStatusGlyph = displayStatus !== 'success'
|
||||
|
||||
return (
|
||||
<ToolEmbedContext.Provider value={isGroup}>
|
||||
|
|
@ -473,13 +473,11 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
|
|||
) : undefined
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
{showGroupStatusGlyph && (
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">{statusGlyph(displayStatus)}</span>
|
||||
)}
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ToolGlyph status={displayStatus === 'success' ? undefined : displayStatus} />
|
||||
<FadeText
|
||||
className={cn(
|
||||
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
|
||||
TOOL_HEADER_TITLE_CLASS,
|
||||
displayStatus === 'error' && 'text-destructive',
|
||||
displayStatus === 'warning' && 'text-amber-700 dark:text-amber-300'
|
||||
)}
|
||||
|
|
@ -487,20 +485,13 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
|
|||
{groupTitle(visibleParts)}
|
||||
</FadeText>
|
||||
{totalDurationLabel && (
|
||||
<span className="shrink-0 text-[0.625rem] tabular-nums text-muted-foreground/55">
|
||||
{totalDurationLabel}
|
||||
</span>
|
||||
<span className={TOOL_HEADER_DURATION_CLASS}>{totalDurationLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
{tailSummary && (
|
||||
<FadeText className="text-[0.7rem] leading-[1.05rem] text-muted-foreground/70">
|
||||
{tailSummary.replace(/\n+/g, ' · ')}
|
||||
</FadeText>
|
||||
)}
|
||||
{statusSummary && (
|
||||
<FadeText
|
||||
className={cn(
|
||||
'text-[0.68rem] leading-[1.05rem]',
|
||||
TOOL_HEADER_SUBTITLE_CLASS,
|
||||
displayStatus === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
|
||||
)}
|
||||
>
|
||||
|
|
@ -539,30 +530,3 @@ export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: To
|
|||
return <ToolEntry part={part} />
|
||||
}
|
||||
|
||||
function InlineDiff({ text }: { text: string }) {
|
||||
return (
|
||||
<pre className="mt-2 max-h-96 max-w-full min-w-0 overflow-auto rounded-lg border border-border/60 bg-background/70 px-3 py-2 font-mono text-[0.6875rem] leading-relaxed">
|
||||
{text.split('\n').map((line, index) => {
|
||||
const added = line.startsWith('+') && !line.startsWith('+++')
|
||||
const removed = line.startsWith('-') && !line.startsWith('---')
|
||||
const hunk = line.startsWith('@@')
|
||||
const fileHeader = line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'block min-w-max whitespace-pre',
|
||||
added && 'text-emerald-700 dark:text-emerald-300',
|
||||
removed && 'text-rose-700 dark:text-rose-300',
|
||||
hunk && 'text-sky-700 dark:text-sky-300',
|
||||
!added && !removed && !hunk && fileHeader && 'text-muted-foreground/80'
|
||||
)}
|
||||
key={`${index}-${line}`}
|
||||
>
|
||||
{line || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
82
apps/desktop/src/components/chat/code-card.tsx
Normal file
82
apps/desktop/src/components/chat/code-card.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { Codicon, type CodiconProps } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Rounded-card shell for fenced code (and any equivalent: diffs, raw payloads,
|
||||
* etc.) sized for the conversation column. Mirrors the expanded tool-row
|
||||
* pattern so code blocks read as the same family of artifact.
|
||||
*/
|
||||
function CodeCard({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-hidden rounded-[0.625rem] border border-border text-[length:var(--conversation-tool-font-size)] text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
data-slot="code-card"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeCardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between gap-2 border-b border-border px-2 py-1.5', className)}
|
||||
data-slot="code-card-header"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeCardTitle({ className, children, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1.5 truncate text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-foreground/80',
|
||||
className
|
||||
)}
|
||||
data-slot="code-card-title"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeCardIcon({ className, ...props }: CodiconProps) {
|
||||
return (
|
||||
<Codicon
|
||||
className={cn('shrink-0 text-[0.875rem] leading-none text-muted-foreground', className)}
|
||||
data-slot="code-card-icon"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeCardSubtitle({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn('font-normal text-muted-foreground', className)}
|
||||
data-slot="code-card-subtitle"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
|
||||
className
|
||||
)}
|
||||
data-slot="code-card-body"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { CodeCard, CodeCardBody, CodeCardHeader, CodeCardIcon, CodeCardSubtitle, CodeCardTitle }
|
||||
51
apps/desktop/src/components/chat/diff-lines.tsx
Normal file
51
apps/desktop/src/components/chat/diff-lines.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Per-line classed renderer for unified diffs. Lives outside `CodeCard` so
|
||||
* tool-result panels (already nested inside a tool card) don't double-shell;
|
||||
* for markdown ` ```diff ` fences the standard `CodeCard` + Shiki path runs
|
||||
* instead and gives equivalent coloring.
|
||||
*/
|
||||
interface DiffLineKind {
|
||||
className?: string
|
||||
match: (line: string) => boolean
|
||||
}
|
||||
|
||||
const DIFF_LINE_KINDS: DiffLineKind[] = [
|
||||
{ className: 'text-emerald-700 dark:text-emerald-300', match: line => line.startsWith('+') && !line.startsWith('+++') },
|
||||
{ className: 'text-rose-700 dark:text-rose-300', match: line => line.startsWith('-') && !line.startsWith('---') },
|
||||
{ className: 'text-sky-700 dark:text-sky-300', match: line => line.startsWith('@@') },
|
||||
{
|
||||
className: 'text-muted-foreground/70',
|
||||
match: line => line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
|
||||
}
|
||||
]
|
||||
|
||||
function classifyLine(line: string): string | undefined {
|
||||
return DIFF_LINE_KINDS.find(kind => kind.match(line))?.className
|
||||
}
|
||||
|
||||
interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> {
|
||||
text: string
|
||||
}
|
||||
|
||||
export function DiffLines({ className, text, ...props }: DiffLinesProps) {
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
'mt-2 max-h-96 max-w-full min-w-0 overflow-auto rounded-md border border-border/60 bg-muted/35 px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
data-slot="diff-lines"
|
||||
{...props}
|
||||
>
|
||||
{text.split('\n').map((line, index) => (
|
||||
<span className={cn('block min-w-max whitespace-pre', classifyLine(line))} key={`${index}-${line}`}>
|
||||
{line || ' '}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { ChevronRight } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared header row for any collapsible block (thinking, tool group, single
|
||||
// tool). Each parent supplies its own outer wrapper (with the data-slot CSS
|
||||
// uses to escape the message padding) and its own expanded body.
|
||||
//
|
||||
// Cursor-style affordance:
|
||||
// Affordance:
|
||||
// - No leading chevron; a caret appears to the RIGHT of the text on hover
|
||||
// (and stays visible when the row is open).
|
||||
// - The hover background is a tight content-shaped pill — sized to the
|
||||
|
|
@ -26,13 +26,13 @@ export function DisclosureRow({
|
|||
trailing?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="group/disclosure-row relative flex w-full max-w-full min-w-0 text-muted-foreground">
|
||||
<div className="group/disclosure-row relative flex w-full max-w-full min-w-0 text-(--ui-text-tertiary)">
|
||||
<button
|
||||
aria-expanded={onToggle ? open : undefined}
|
||||
className={cn(
|
||||
// max-w-fit so the click target hugs the title text width — no
|
||||
// background fill, just the cursor + the affordance caret.
|
||||
'flex min-w-0 max-w-fit items-start gap-2 text-left transition-colors',
|
||||
'flex min-w-0 max-w-fit items-start gap-1.5 text-left transition-colors',
|
||||
onToggle
|
||||
? 'cursor-pointer hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
|
||||
: 'cursor-default'
|
||||
|
|
@ -41,31 +41,23 @@ export function DisclosureRow({
|
|||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">{children}</span>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">{children}</span>
|
||||
{onToggle && (
|
||||
// Wrapper height matches the title row's line-height so the caret
|
||||
// is vertically centred with the title (not with the full stack
|
||||
// when a subtitle wraps below).
|
||||
// Wrapper height matches the title row's actual line-height so the
|
||||
// caret centres with the title, not the whole subtitle stack.
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-[1.1rem] shrink-0 items-center justify-center transition-opacity duration-150',
|
||||
'flex h-(--conversation-line-height) shrink-0 items-center justify-center transition-opacity duration-150',
|
||||
open
|
||||
? 'opacity-80'
|
||||
: 'opacity-0 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80'
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn('size-3.5 transition-transform duration-150', open && 'rotate-90')}
|
||||
// currentColor + a chunkier stroke so the caret reads as a
|
||||
// confident hover affordance instead of a hairline.
|
||||
color="currentColor"
|
||||
strokeWidth={2.75}
|
||||
/>
|
||||
<DisclosureCaret open={open} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{trailing && <span className="absolute right-1 top-0.5 flex h-[1.1rem] items-center">{trailing}</span>}
|
||||
{trailing && <span className="absolute right-1 top-0 flex h-(--conversation-line-height) items-center">{trailing}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { type CSSProperties, useState } from 'react'
|
||||
|
||||
import introCopyJsonl from './intro-copy.jsonl?raw'
|
||||
|
||||
|
|
@ -161,9 +161,17 @@ export function Intro({ personality, seed }: IntroProps) {
|
|||
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
|
||||
data-slot="aui_intro"
|
||||
>
|
||||
<div className="w-full min-w-0 max-w-xl">
|
||||
<p className="mb-3 font-['Collapse'] text-[clamp(3.25rem,4.6dvw,4.875rem)] font-bold uppercase leading-[0.95] tracking-wider text-midground mix-blend-plus-lighter dark:text-foreground/90">
|
||||
Hermes Agent
|
||||
<div className="w-full min-w-0">
|
||||
<p
|
||||
className="fit-text mx-auto mb-3 w-4/5 font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{ '--fit-text-line-height': '0.9', '--fit-text-max': '8rem', '--fit-text-min': '2.75rem' } as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>HERMES AGENT</span>
|
||||
</span>
|
||||
<span aria-hidden="true">HERMES AGENT</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center leading-normal tracking-tight">{copy.body}</p>
|
||||
|
|
|
|||
|
|
@ -4,72 +4,89 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
|
|||
import type { FC } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
|
||||
import { isLikelyProseCodeBlock } from '@/lib/markdown-code'
|
||||
import {
|
||||
CodeCard,
|
||||
CodeCardBody,
|
||||
CodeCardHeader,
|
||||
CodeCardIcon,
|
||||
CodeCardSubtitle,
|
||||
CodeCardTitle
|
||||
} from '@/components/chat/code-card'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
|
||||
/**
|
||||
* assistant-ui's recommended `SyntaxHighlighter` slot.
|
||||
* Streamdown's code adapter renders header + body as inline siblings, so we
|
||||
* own the wrapping `<CodeCard>` here and neutralize the upstream
|
||||
* `data-streamdown="code-block"` chrome from styles.css. Anything that wants
|
||||
* a card-shaped code surface should compose `CodeCard*` directly.
|
||||
*
|
||||
* Uses the full `react-shiki` bundle so all `bundledLanguages` work
|
||||
* (rust, go, swift, kotlin, sql, etc.) — the `/web` subpath only ships
|
||||
* common web languages and silently falls back to plain text otherwise.
|
||||
*
|
||||
* Theme switching is automatic via the CSS `color-scheme` on `:root`
|
||||
* (set from the desktop theme provider).
|
||||
*
|
||||
* `showLanguage` is disabled because we render our own `CodeHeader`;
|
||||
* leaving it on causes the language to appear twice.
|
||||
* `react-shiki` full bundle so all `bundledLanguages` work; theme switches
|
||||
* follow the document `color-scheme` via `defaultColor="light-dark()"`.
|
||||
*/
|
||||
interface HermesSyntaxHighlighterProps extends SyntaxHighlighterProps {
|
||||
defer?: boolean
|
||||
}
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
|
||||
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
||||
components: { Pre, Code: _UnusedCode },
|
||||
components: { Pre },
|
||||
language,
|
||||
code,
|
||||
defer = false
|
||||
}) => {
|
||||
const preClassName =
|
||||
'aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed'
|
||||
|
||||
// Streamdown may hand us fence contents with edge newlines. Strip blank
|
||||
// fence padding without touching indentation on the first real line.
|
||||
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
|
||||
|
||||
// Avoid rendering an empty code card while Streamdown is still deciding
|
||||
// whether a transient/incomplete fence is real markdown.
|
||||
// Streaming may hand us empty/incomplete fences — render nothing rather
|
||||
// than a transient empty card.
|
||||
if (!trimmed.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLikelyProseCodeBlock(language, trimmed)) {
|
||||
return <div className="whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div>
|
||||
return <div className="aui-prose-fence whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div>
|
||||
}
|
||||
|
||||
if (defer) {
|
||||
return (
|
||||
<Pre className={preClassName}>
|
||||
<code className="block whitespace-pre">{trimmed}</code>
|
||||
</Pre>
|
||||
)
|
||||
}
|
||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||
|
||||
return (
|
||||
<Pre className={preClassName}>
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={120}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={{
|
||||
light: 'github-light-default',
|
||||
dark: 'github-dark-default'
|
||||
}}
|
||||
>
|
||||
{trimmed}
|
||||
</ShikiHighlighter>
|
||||
</Pre>
|
||||
<CodeCard>
|
||||
<CodeCardHeader>
|
||||
<CodeCardTitle>
|
||||
<CodeCardIcon name={codiconForLanguage(label)} />
|
||||
Code
|
||||
{label && <CodeCardSubtitle> · {label}</CodeCardSubtitle>}
|
||||
</CodeCardTitle>
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="-my-1 -mr-1 h-5 px-1 opacity-55 hover:opacity-100"
|
||||
iconClassName="size-2.5"
|
||||
label="Copy code"
|
||||
showLabel={false}
|
||||
text={trimmed}
|
||||
/>
|
||||
</CodeCardHeader>
|
||||
<CodeCardBody>
|
||||
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
|
||||
{defer ? (
|
||||
<code className="block whitespace-pre">{trimmed}</code>
|
||||
) : (
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={120}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{trimmed}
|
||||
</ShikiHighlighter>
|
||||
)}
|
||||
</Pre>
|
||||
</CodeCardBody>
|
||||
</CodeCard>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,8 +144,8 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
|||
const showPicker = flow.status === 'idle' || flow.status === 'success'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-background/80 p-6 backdrop-blur-xl">
|
||||
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
|
||||
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--glass-chat-surface-background) p-6">
|
||||
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--glass-chat-bubble-background) shadow-sm">
|
||||
<Header />
|
||||
<div className="grid gap-5 p-6">
|
||||
{reason ? <ReasonNotice reason={reason} /> : null}
|
||||
|
|
@ -209,14 +209,14 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
|||
|
||||
function Header() {
|
||||
return (
|
||||
<div className="border-b border-border bg-muted/30 px-6 py-5">
|
||||
<div className="border-b border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) px-6 py-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
|
||||
<Sparkles className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Welcome to Hermes</h2>
|
||||
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Welcome to Hermes</h2>
|
||||
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
Connect a model provider to start chatting. Most options take one click.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { useStore } from '@nanostores/react'
|
|||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type LucideIcon, X } from '@/lib/icons'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$notifications,
|
||||
|
|
@ -16,7 +17,7 @@ import {
|
|||
|
||||
type ToneVariant = 'default' | 'destructive' | 'warning' | 'success'
|
||||
|
||||
const tone: Record<NotificationKind, { icon: LucideIcon; iconClass: string; variant: ToneVariant }> = {
|
||||
const tone: Record<NotificationKind, { icon: IconComponent; iconClass: string; variant: ToneVariant }> = {
|
||||
error: { icon: AlertCircle, iconClass: 'text-destructive', variant: 'destructive' },
|
||||
warning: { icon: AlertTriangle, iconClass: 'text-primary', variant: 'warning' },
|
||||
info: { icon: Info, iconClass: 'text-muted-foreground', variant: 'default' },
|
||||
|
|
@ -122,7 +123,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
|||
onClick={() => dismissNotification(notification.id)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
<Codicon name="close" size="0.875rem" />
|
||||
</button>
|
||||
</Alert>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ interface CollectedPane {
|
|||
defaultOpen: boolean
|
||||
disabled: boolean
|
||||
id: string
|
||||
resizable: boolean
|
||||
side: PaneSide
|
||||
width: string
|
||||
}
|
||||
|
|
@ -110,6 +111,7 @@ function collectPanes(children: ReactNode) {
|
|||
defaultOpen: props.defaultOpen ?? true,
|
||||
disabled: props.disabled ?? false,
|
||||
id: props.id,
|
||||
resizable: props.resizable ?? false,
|
||||
side: props.side,
|
||||
width: widthToCss(props.width, DEFAULT_WIDTH)
|
||||
}
|
||||
|
|
@ -128,7 +130,7 @@ function trackForPane(pane: CollectedPane, states: Record<string, { open: boolea
|
|||
return { open: false, track: '0px' }
|
||||
}
|
||||
|
||||
const override = states[pane.id]?.widthOverride
|
||||
const override = pane.resizable ? states[pane.id]?.widthOverride : undefined
|
||||
|
||||
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
|
||||
}
|
||||
|
|
@ -286,14 +288,14 @@ export function Pane({
|
|||
aria-label={`Resize ${id}`}
|
||||
aria-orientation="vertical"
|
||||
className={cn(
|
||||
'group absolute bottom-0 top-0 z-10 w-3 cursor-col-resize [-webkit-app-region:no-drag]',
|
||||
'group absolute bottom-0 top-0 z-20 w-1 cursor-col-resize [-webkit-app-region:no-drag]',
|
||||
slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2'
|
||||
)}
|
||||
onPointerDown={startResize}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-18 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
|
||||
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--glass-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { CheckIcon } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
|
|
@ -18,7 +18,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
|||
className="flex items-center justify-center text-current"
|
||||
data-slot="checkbox-indicator"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
<Codicon name="check" size="0.875rem" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
|
|
|
|||
20
apps/desktop/src/components/ui/codicon.tsx
Normal file
20
apps/desktop/src/components/ui/codicon.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface CodiconProps extends React.HTMLAttributes<HTMLElement> {
|
||||
name: string
|
||||
size?: number | string
|
||||
spinning?: boolean
|
||||
}
|
||||
|
||||
export function Codicon({ className, name, size, spinning, style, ...props }: CodiconProps) {
|
||||
return (
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className={cn('codicon', `codicon-${name}`, spinning && 'codicon-modifier-spin', className)}
|
||||
style={{ fontSize: size, ...style }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { XIcon } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
|
|
@ -24,7 +24,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
|
|||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-[120] pointer-events-auto bg-black/50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-[120] pointer-events-auto bg-black/22 backdrop-blur-[0.125rem] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-overlay"
|
||||
|
|
@ -46,7 +46,7 @@ function DialogContent({
|
|||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-6 shadow-lg duration-200 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',
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--glass-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 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',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
|
|
@ -55,10 +55,10 @@ function DialogContent({
|
|||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-3 top-3 rounded-md p-1.5 text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none"
|
||||
className="absolute right-2.5 top-2.5 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none"
|
||||
data-slot="dialog-close-button"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<Codicon name="close" size="1rem" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
|
|
@ -70,7 +70,7 @@ function DialogContent({
|
|||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col gap-1.5 text-center sm:text-left', className)}
|
||||
className={cn('flex flex-col gap-1 text-center sm:text-left', className)}
|
||||
data-slot="dialog-header"
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -90,7 +90,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn('text-base font-semibold tracking-tight text-foreground', className)}
|
||||
className={cn('text-[0.9375rem] font-semibold tracking-tight text-foreground', className)}
|
||||
data-slot="dialog-title"
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -100,7 +100,7 @@ function DialogTitle({ className, ...props }: React.ComponentProps<typeof Dialog
|
|||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
className={cn('text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)', className)}
|
||||
data-slot="dialog-description"
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
20
apps/desktop/src/components/ui/disclosure-caret.tsx
Normal file
20
apps/desktop/src/components/ui/disclosure-caret.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Codicon, type CodiconProps } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DisclosureCaretProps extends Omit<CodiconProps, 'name'> {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
// Chrome caret for collapsible sections: points right when closed (▶),
|
||||
// rotates to point down (▼) when open. Override `className` to layer
|
||||
// hover/opacity styling; twMerge resolves transition conflicts.
|
||||
export function DisclosureCaret({ className, open, size = '0.75rem', ...props }: DisclosureCaretProps) {
|
||||
return (
|
||||
<Codicon
|
||||
className={cn('transition-transform duration-150', open && 'rotate-90', className)}
|
||||
name="chevron-right"
|
||||
size={size}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
|
|
@ -25,7 +25,7 @@ function DropdownMenuContent({
|
|||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
className={cn(
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
|
|
@ -52,7 +52,7 @@ function DropdownMenuItem({
|
|||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-bg-tertiary) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
|
|
@ -73,7 +73,7 @@ function DropdownMenuCheckboxItem({
|
|||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-bg-tertiary) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
|
|
@ -81,7 +81,7 @@ function DropdownMenuCheckboxItem({
|
|||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<Codicon name="check" size="1rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
|
|
@ -101,7 +101,7 @@ function DropdownMenuRadioItem({
|
|||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-bg-tertiary) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
|
|
@ -109,7 +109,7 @@ function DropdownMenuRadioItem({
|
|||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
<Codicon name="primitive-dot" size="0.5rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
|
|
@ -126,7 +126,7 @@ function DropdownMenuLabel({
|
|||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
className={cn('px-2 py-1 text-xs font-medium text-(--ui-text-tertiary) data-[inset]:pl-7', className)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-label"
|
||||
{...props}
|
||||
|
|
@ -137,7 +137,7 @@ function DropdownMenuLabel({
|
|||
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
className={cn('-mx-1 my-1 h-px bg-(--ui-stroke-tertiary)', className)}
|
||||
data-slot="dropdown-menu-separator"
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -169,7 +169,7 @@ function DropdownMenuSubTrigger({
|
|||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
"flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-bg-tertiary) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-bg-tertiary) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
|
|
@ -177,7 +177,7 @@ function DropdownMenuSubTrigger({
|
|||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
<Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
|
@ -189,7 +189,7 @@ function DropdownMenuSubContent({
|
|||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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',
|
||||
'z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
|
|
|
|||
37
apps/desktop/src/components/ui/kbd.tsx
Normal file
37
apps/desktop/src/components/ui/kbd.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||
return (
|
||||
<kbd
|
||||
className={cn(
|
||||
'inline-grid h-4 min-w-4 place-items-center rounded-sm border border-border/70 bg-muted/45 px-1 font-mono text-[0.5625rem] font-medium leading-none text-muted-foreground shadow-xs',
|
||||
className
|
||||
)}
|
||||
data-slot="kbd"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface KbdGroupProps extends Omit<React.ComponentProps<'span'>, 'children'> {
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
function KbdGroup({ className, keys, ...props }: KbdGroupProps) {
|
||||
return (
|
||||
<span
|
||||
aria-label={keys.join(' ')}
|
||||
className={cn('inline-flex shrink-0 items-center gap-0.5 opacity-55', className)}
|
||||
data-slot="kbd-group"
|
||||
{...props}
|
||||
>
|
||||
{keys.map(key => (
|
||||
<Kbd key={key}>{key}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
|
|
@ -59,7 +59,7 @@ function PaginationPrevious({ className, ...props }: React.ComponentProps<'butto
|
|||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
<Codicon name="chevron-left" size="0.75rem" />
|
||||
<span>Prev</span>
|
||||
</button>
|
||||
)
|
||||
|
|
@ -78,7 +78,7 @@ function PaginationNext({ className, ...props }: React.ComponentProps<'button'>)
|
|||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="size-3" />
|
||||
<Codicon name="chevron-right" size="0.75rem" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -91,7 +91,7 @@ function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'
|
|||
data-slot="pagination-ellipsis"
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-3" />
|
||||
<Codicon name="ellipsis" size="0.75rem" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Select as SelectPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
|
|
@ -20,7 +20,7 @@ function SelectTrigger({ className, children, ...props }: React.ComponentProps<t
|
|||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-60" />
|
||||
<Codicon className="opacity-60" name="chevron-down" size="1rem" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
|
|
@ -74,7 +74,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
|
|||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<Codicon name="check" size="1rem" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { Dialog as SheetPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { XIcon } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
|
|
@ -26,7 +26,7 @@ function SheetOverlay({ className, ...props }: React.ComponentProps<typeof Sheet
|
|||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-50 bg-black/22 backdrop-blur-[0.125rem] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
data-slot="sheet-overlay"
|
||||
|
|
@ -50,7 +50,7 @@ function SheetContent({
|
|||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
className={cn(
|
||||
'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
|
||||
'fixed z-50 flex flex-col gap-3 border-(--ui-stroke-secondary) bg-(--glass-sidebar-surface-background) text-[length:var(--conversation-text-font-size)] shadow-md transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
|
|
@ -66,8 +66,8 @@ function SheetContent({
|
|||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="size-4" />
|
||||
<SheetPrimitive.Close className="absolute top-3 right-3 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 ring-offset-background transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<Codicon name="close" size="1rem" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
|
|
@ -77,17 +77,17 @@ function SheetContent({
|
|||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('flex flex-col gap-1.5 p-4', className)} data-slot="sheet-header" {...props} />
|
||||
return <div className={cn('flex flex-col gap-1 p-3', className)} data-slot="sheet-header" {...props} />
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('mt-auto flex flex-col gap-2 p-4', className)} data-slot="sheet-footer" {...props} />
|
||||
return <div className={cn('mt-auto flex flex-col gap-2 p-3', className)} data-slot="sheet-footer" {...props} />
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
className={cn('font-semibold text-foreground', className)}
|
||||
className={cn('text-[0.9375rem] font-semibold text-foreground', className)}
|
||||
data-slot="sheet-title"
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -97,7 +97,7 @@ function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPr
|
|||
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
className={cn('text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)', className)}
|
||||
data-slot="sheet-description"
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
19
apps/desktop/src/global.d.ts
vendored
19
apps/desktop/src/global.d.ts
vendored
|
|
@ -29,6 +29,14 @@ declare global {
|
|||
fetchLinkTitle: (url: string) => Promise<string>
|
||||
readDir: (path: string) => Promise<HermesReadDirResult>
|
||||
gitRoot?: (path: string) => Promise<string | null>
|
||||
terminal: {
|
||||
dispose: (id: string) => Promise<boolean>
|
||||
onData: (id: string, callback: (payload: string) => void) => () => void
|
||||
onExit: (id: string, callback: (payload: HermesTerminalExit) => void) => () => void
|
||||
resize: (id: string, size: { cols: number; rows: number }) => Promise<boolean>
|
||||
start: (options?: { cols?: number; cwd?: string; rows?: number }) => Promise<HermesTerminalSession>
|
||||
write: (id: string, data: string) => Promise<boolean>
|
||||
}
|
||||
onClosePreviewRequested?: (callback: () => void) => () => void
|
||||
onOpenUpdatesRequested?: (callback: () => void) => () => void
|
||||
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
|
||||
|
|
@ -47,6 +55,17 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
export interface HermesTerminalSession {
|
||||
cwd: string
|
||||
id: string
|
||||
shell: string
|
||||
}
|
||||
|
||||
export interface HermesTerminalExit {
|
||||
code: number | null
|
||||
signal: string | null
|
||||
}
|
||||
|
||||
export interface DesktopVersionInfo {
|
||||
appVersion: string
|
||||
electronVersion: string
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
import { type RefObject, useEffect } from 'react'
|
||||
import { type RefObject, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
export function useResizeObserver(onResize: () => void, ...refs: readonly RefObject<Element | null>[]) {
|
||||
const elements = refs.map(ref => ref.current)
|
||||
const refsRef = useRef(refs)
|
||||
refsRef.current = refs
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
onResize()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => onResize())
|
||||
let observed = false
|
||||
|
||||
for (const element of elements) {
|
||||
for (const ref of refsRef.current) {
|
||||
const element = ref.current
|
||||
|
||||
if (!element) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -29,5 +34,5 @@ export function useResizeObserver(onResize: () => void, ...refs: readonly RefObj
|
|||
onResize()
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [onResize, ...elements])
|
||||
}, [onResize])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function chatMessageText(message: ChatMessage): string {
|
|||
|
||||
const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/
|
||||
const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/
|
||||
const CONTEXT_REF_RE = /@(file|folder|url|image|tool):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g
|
||||
const CONTEXT_REF_RE = /@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g
|
||||
|
||||
function textFromUnknown(value: unknown, depth = 0): string {
|
||||
if (typeof value === 'string') {
|
||||
|
|
@ -435,6 +435,7 @@ export function upsertToolPart(
|
|||
if (index === -1) {
|
||||
return [...next, base]
|
||||
}
|
||||
|
||||
next[index] = { ...next[index], ...base }
|
||||
|
||||
return next
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export function createClientSessionState(
|
|||
return {
|
||||
storedSessionId,
|
||||
messages,
|
||||
branch: '',
|
||||
cwd: '',
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
|
|
@ -145,6 +147,10 @@ export function pathLabel(path: string): string {
|
|||
}
|
||||
|
||||
export function attachmentDisplayText(attachment: ComposerAttachment): string | null {
|
||||
if (attachment.kind === 'terminal' && attachment.detail) {
|
||||
return `\`\`\`terminal\n${attachment.detail.trim()}\n\`\`\``
|
||||
}
|
||||
|
||||
if (attachment.refText) {
|
||||
return attachment.refText
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,4 +190,4 @@ export {
|
|||
Zap
|
||||
}
|
||||
|
||||
export type { LucideIcon } from 'lucide-react'
|
||||
export type { Icon as IconComponent } from '@tabler/icons-react'
|
||||
|
|
|
|||
|
|
@ -49,6 +49,65 @@ export function sanitizeLanguageTag(tag: string): string {
|
|||
return VALID_LANGUAGE_RE.test(first) && first.length <= 16 ? first.toLowerCase() : ''
|
||||
}
|
||||
|
||||
// Sanitized language tag → codicon glyph. Anything not listed falls back to
|
||||
// the generic `code` glyph, which matches what the tool-row icons use.
|
||||
const CODICON_BY_LANGUAGE: Record<string, string> = {
|
||||
bash: 'terminal',
|
||||
cmd: 'terminal',
|
||||
console: 'terminal',
|
||||
fish: 'terminal',
|
||||
powershell: 'terminal',
|
||||
ps1: 'terminal',
|
||||
sh: 'terminal',
|
||||
shell: 'terminal',
|
||||
zsh: 'terminal',
|
||||
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
|
||||
json: 'json',
|
||||
json5: 'json',
|
||||
|
||||
ini: 'settings-gear',
|
||||
toml: 'settings-gear',
|
||||
yaml: 'settings-gear',
|
||||
yml: 'settings-gear',
|
||||
dotenv: 'settings-gear',
|
||||
env: 'settings-gear',
|
||||
|
||||
graphql: 'database',
|
||||
gql: 'database',
|
||||
mysql: 'database',
|
||||
postgres: 'database',
|
||||
postgresql: 'database',
|
||||
sql: 'database',
|
||||
sqlite: 'database',
|
||||
|
||||
diff: 'diff',
|
||||
patch: 'diff',
|
||||
|
||||
css: 'symbol-color',
|
||||
less: 'symbol-color',
|
||||
sass: 'symbol-color',
|
||||
scss: 'symbol-color',
|
||||
svg: 'symbol-color',
|
||||
|
||||
regex: 'regex',
|
||||
regexp: 'regex',
|
||||
|
||||
curl: 'globe',
|
||||
http: 'globe',
|
||||
|
||||
docker: 'package',
|
||||
dockerfile: 'package',
|
||||
|
||||
mermaid: 'graph'
|
||||
}
|
||||
|
||||
export function codiconForLanguage(language: string | undefined): string {
|
||||
return CODICON_BY_LANGUAGE[sanitizeLanguageTag(language || '')] || 'code'
|
||||
}
|
||||
|
||||
function proseLineCount(body: string): number {
|
||||
return body.split('\n').filter(line => {
|
||||
const trimmed = line.trim()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
|
||||
export interface ComposerAttachment {
|
||||
id: string
|
||||
kind: 'image' | 'file' | 'folder' | 'url'
|
||||
kind: 'image' | 'file' | 'folder' | 'terminal' | 'url'
|
||||
label: string
|
||||
detail?: string
|
||||
refText?: string
|
||||
|
|
@ -15,11 +15,38 @@ export interface ComposerAttachment {
|
|||
|
||||
export const $composerDraft = atom('')
|
||||
export const $composerAttachments = atom<ComposerAttachment[]>([])
|
||||
export const $composerTerminalSelections = atom<Record<string, string>>({})
|
||||
|
||||
export function setComposerDraft(value: string) {
|
||||
$composerDraft.set(value)
|
||||
}
|
||||
|
||||
export function appendComposerDraft(value: string) {
|
||||
const text = value.trim()
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $composerDraft.get()
|
||||
const separator = current && !current.endsWith('\n') ? '\n\n' : ''
|
||||
|
||||
$composerDraft.set(`${current}${separator}${text}`)
|
||||
}
|
||||
|
||||
export function appendComposerInline(value: string) {
|
||||
const text = value.trim()
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $composerDraft.get().trimEnd()
|
||||
const separator = current ? ' ' : ''
|
||||
|
||||
$composerDraft.set(`${current}${separator}${text}`)
|
||||
}
|
||||
|
||||
export function clearComposerDraft() {
|
||||
$composerDraft.set('')
|
||||
}
|
||||
|
|
@ -46,6 +73,103 @@ export function clearComposerAttachments() {
|
|||
$composerAttachments.set([])
|
||||
}
|
||||
|
||||
const TERMINAL_REF_RE = /@terminal:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
function unquoteRefValue(raw: string) {
|
||||
const head = raw[0]
|
||||
const tail = raw[raw.length - 1]
|
||||
const quoted = (head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")
|
||||
|
||||
return (quoted ? raw.slice(1, -1) : raw).replace(/[,.;!?]+$/, '').trim()
|
||||
}
|
||||
|
||||
function terminalLabelsFromDraft(draft: string) {
|
||||
const labels: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of draft.matchAll(TERMINAL_REF_RE)) {
|
||||
const label = unquoteRefValue(match[1] || '')
|
||||
|
||||
if (!label || seen.has(label)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(label)
|
||||
labels.push(label)
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
export function setComposerTerminalSelection(label: string, text: string) {
|
||||
const nextLabel = label.trim()
|
||||
const nextText = text.trim()
|
||||
|
||||
if (!nextLabel || !nextText) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $composerTerminalSelections.get()
|
||||
|
||||
if (current[nextLabel] === nextText) {
|
||||
return
|
||||
}
|
||||
|
||||
$composerTerminalSelections.set({
|
||||
...current,
|
||||
[nextLabel]: nextText
|
||||
})
|
||||
}
|
||||
|
||||
export function reconcileComposerTerminalSelections(draft: string) {
|
||||
const current = $composerTerminalSelections.get()
|
||||
const labels = new Set(terminalLabelsFromDraft(draft))
|
||||
let changed = false
|
||||
const next: Record<string, string> = {}
|
||||
|
||||
for (const [label, text] of Object.entries(current)) {
|
||||
if (!labels.has(label)) {
|
||||
changed = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
next[label] = text
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
$composerTerminalSelections.set(next)
|
||||
}
|
||||
}
|
||||
|
||||
export function terminalContextBlocksFromDraft(draft: string) {
|
||||
const labels = terminalLabelsFromDraft(draft)
|
||||
|
||||
if (labels.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const selections = $composerTerminalSelections.get()
|
||||
|
||||
return labels.flatMap(label => {
|
||||
const text = selections[label]?.trim()
|
||||
|
||||
if (!text) {
|
||||
return []
|
||||
}
|
||||
|
||||
return `\`\`\`terminal\n${text}\n\`\`\``
|
||||
})
|
||||
}
|
||||
|
||||
export function clearComposerTerminalSelections() {
|
||||
if (Object.keys($composerTerminalSelections.get()).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
$composerTerminalSelections.set({})
|
||||
}
|
||||
|
||||
function upsertAttachment(attachments: ComposerAttachment[], attachment: ComposerAttachment) {
|
||||
const index = attachments.findIndex(item => item.id === attachment.id)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { atom, computed, type ReadableAtom } from 'nanostores'
|
||||
|
||||
import { arraysEqual, insertUniqueId, persistStringArray, storedStringArray } from '@/lib/storage'
|
||||
import { arraysEqual, insertUniqueId, persistBoolean, persistStringArray, storedBoolean, storedStringArray } from '@/lib/storage'
|
||||
|
||||
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes'
|
||||
|
||||
export const SIDEBAR_DEFAULT_WIDTH = 224
|
||||
export const SIDEBAR_MAX_WIDTH = 320
|
||||
export const SIDEBAR_DEFAULT_WIDTH = 237
|
||||
export const SIDEBAR_MAX_WIDTH = 360
|
||||
export const FILE_BROWSER_DEFAULT_WIDTH = '17rem'
|
||||
export const FILE_BROWSER_MIN_WIDTH = '14rem'
|
||||
export const FILE_BROWSER_MAX_WIDTH = '20rem'
|
||||
|
||||
export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
|
||||
|
||||
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
|
||||
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
|
||||
|
||||
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
|
||||
export const FILE_BROWSER_PANE_ID = 'file-browser'
|
||||
|
|
@ -42,9 +45,12 @@ export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states
|
|||
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
|
||||
export const $sidebarPinsOpen = atom(true)
|
||||
export const $sidebarRecentsOpen = atom(true)
|
||||
export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false))
|
||||
export const $isSidebarResizing = atom(false)
|
||||
export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
|
||||
|
||||
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
|
||||
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
|
||||
|
||||
export function setSidebarWidth(width: number) {
|
||||
const bounded = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_DEFAULT_WIDTH, width))
|
||||
|
|
@ -75,6 +81,10 @@ export function setSidebarRecentsOpen(open: boolean) {
|
|||
$sidebarRecentsOpen.set(open)
|
||||
}
|
||||
|
||||
export function setSidebarAgentsGrouped(grouped: boolean) {
|
||||
$sidebarAgentsGrouped.set(grouped)
|
||||
}
|
||||
|
||||
export function setSidebarResizing(resizing: boolean) {
|
||||
$isSidebarResizing.set(resizing)
|
||||
}
|
||||
|
|
@ -96,3 +106,28 @@ export function unpinSession(sessionId: string) {
|
|||
$pinnedSessionIds.set(next)
|
||||
}
|
||||
}
|
||||
|
||||
export function reorderPinnedSession(sessionId: string, targetIndex: number) {
|
||||
const prev = $pinnedSessionIds.get()
|
||||
|
||||
if (!prev.includes(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = insertUniqueId(prev, sessionId, targetIndex)
|
||||
|
||||
if (!arraysEqual(prev, next)) {
|
||||
$pinnedSessionIds.set(next)
|
||||
}
|
||||
}
|
||||
|
||||
export function bumpSessionsLimit(step: number = SIDEBAR_SESSIONS_PAGE_SIZE) {
|
||||
const safeStep = Math.max(1, Math.floor(step))
|
||||
$sessionsLimit.set($sessionsLimit.get() + safeStep)
|
||||
}
|
||||
|
||||
export function resetSessionsLimit() {
|
||||
if ($sessionsLimit.get() !== SIDEBAR_SESSIONS_PAGE_SIZE) {
|
||||
$sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
|
|||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
export const $sessionsTotal = atom<number>(0)
|
||||
export const $sessionsLoading = atom(true)
|
||||
export const $workingSessionIds = atom<string[]>([])
|
||||
export const $activeSessionId = atom<string | null>(null)
|
||||
|
|
@ -52,6 +53,7 @@ export const $modelPickerOpen = atom(false)
|
|||
export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next)
|
||||
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
|
||||
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
|
||||
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
|
||||
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
|
||||
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
|
||||
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
@plugin '@tailwindcss/typography';
|
||||
@import 'tw-shimmer';
|
||||
@import 'katex/dist/katex.min.css';
|
||||
@import '@vscode/codicons/dist/codicon.css';
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@font-face {
|
||||
|
|
@ -60,87 +61,217 @@
|
|||
--color-sidebar: var(--sidebar);
|
||||
|
||||
--shadow-ink: var(--dt-foreground);
|
||||
--shadow-xs: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
|
||||
--shadow-sm:
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 6%, transparent),
|
||||
0 0.125rem 0.5rem color-mix(in srgb, #000 4%, transparent);
|
||||
--shadow-md:
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 8%, transparent),
|
||||
0 0.25rem 1rem color-mix(in srgb, #000 8%, transparent),
|
||||
0 1rem 2rem -1.5rem color-mix(in srgb, #000 18%, transparent);
|
||||
--shadow-lg:
|
||||
inset 0 0.0625rem 0 color-mix(in srgb, #fff 28%, transparent),
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 8%, transparent),
|
||||
0 0.75rem 2rem color-mix(in srgb, #000 12%, transparent);
|
||||
--shadow-header:
|
||||
0 0.5rem 0.875rem -0.375rem color-mix(in srgb, var(--dt-background) 96%, transparent),
|
||||
0 1.25rem 2rem -0.875rem color-mix(in srgb, var(--dt-background) 82%, transparent),
|
||||
0 2rem 3rem -1.5rem color-mix(in srgb, var(--dt-background) 55%, transparent);
|
||||
0 0.0625rem 0 color-mix(in srgb, var(--dt-foreground) 7%, transparent),
|
||||
0 0.625rem 1.5rem -1.25rem color-mix(in srgb, #000 16%, transparent);
|
||||
--shadow-composer:
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--shadow-ink) 4%, transparent),
|
||||
0 0.0625rem 0.25rem color-mix(in srgb, var(--shadow-ink) 3%, transparent);
|
||||
0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
|
||||
--shadow-composer-focus:
|
||||
0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(14% * var(--composer-ring-strength)), transparent),
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(26% * var(--composer-ring-strength)), transparent),
|
||||
0 0.1875rem 0.625rem color-mix(in srgb, var(--shadow-ink) 4%, transparent);
|
||||
0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(10% * var(--composer-ring-strength)), transparent),
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(22% * var(--composer-ring-strength)), transparent),
|
||||
0 0.25rem 0.875rem color-mix(in srgb, #000 8%, transparent),
|
||||
0 0.75rem 2rem -1.25rem color-mix(in srgb, #000 14%, transparent);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
color-scheme: light;
|
||||
|
||||
--dt-background: #f7f7f7;
|
||||
--dt-foreground: #242424;
|
||||
--dt-card: #ffffff;
|
||||
--dt-card-foreground: #242424;
|
||||
--dt-muted: #f0f0ef;
|
||||
--dt-muted-foreground: #737373;
|
||||
--dt-popover: #ffffff;
|
||||
--dt-popover-foreground: #242424;
|
||||
--dt-primary: #cf806d;
|
||||
--dt-primary-foreground: #ffffff;
|
||||
--dt-secondary: #f1f1f0;
|
||||
--dt-secondary-foreground: #2d2d2d;
|
||||
--dt-accent: #eeeeed;
|
||||
--dt-accent-foreground: #242424;
|
||||
--dt-border: #dfdfdc;
|
||||
--dt-input: #ddddda;
|
||||
--dt-ring: #b97969;
|
||||
--dt-destructive: #b94a3a;
|
||||
--theme-foreground: #17171a;
|
||||
--theme-primary: #0053fd;
|
||||
--theme-secondary: color-mix(in srgb, #0053fd 7%, #ffffff);
|
||||
--theme-accent-soft: color-mix(in srgb, #0053fd 10%, #ffffff);
|
||||
--theme-midground: #0053fd;
|
||||
--theme-warm: #cf806d;
|
||||
--theme-background-seed: #f8faff;
|
||||
--theme-sidebar-seed: #f3f7ff;
|
||||
--theme-card-seed: #ffffff;
|
||||
--theme-elevated-seed: #ffffff;
|
||||
--theme-bubble-seed: color-mix(in srgb, #0053fd 6%, #ffffff);
|
||||
--theme-neutral-chrome: #f3f3f3;
|
||||
--theme-neutral-sidebar: #f3f3f3;
|
||||
--theme-neutral-card: #fcfcfc;
|
||||
--theme-mix-chrome: 44%;
|
||||
--theme-mix-sidebar: 36%;
|
||||
--theme-mix-card: 22%;
|
||||
--theme-mix-elevated: 28%;
|
||||
--theme-mix-bubble: 30%;
|
||||
--theme-fill-primary-accent-mix: 16%;
|
||||
--theme-fill-secondary-accent-mix: 11%;
|
||||
--theme-fill-tertiary-accent-mix: 8%;
|
||||
--theme-fill-quaternary-accent-mix: 5%;
|
||||
--theme-fill-quinary-accent-mix: 3%;
|
||||
--theme-stroke-primary-accent-mix: 24%;
|
||||
--theme-stroke-secondary-accent-mix: 16%;
|
||||
--theme-stroke-tertiary-accent-mix: 10%;
|
||||
--theme-stroke-quaternary-accent-mix: 6%;
|
||||
|
||||
--ui-base: var(--theme-foreground);
|
||||
--ui-accent: var(--theme-midground);
|
||||
--ui-accent-secondary: var(--theme-primary);
|
||||
--ui-warm: var(--theme-warm);
|
||||
--ui-red: #cf2d56;
|
||||
--ui-orange: #db704b;
|
||||
--ui-yellow: #c08532;
|
||||
--ui-green: #1f8a65;
|
||||
--ui-cyan: #4c7f8c;
|
||||
--ui-blue: #0053fd;
|
||||
--ui-purple: #9e94d5;
|
||||
--ui-bg-chrome: color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome));
|
||||
--ui-bg-sidebar: color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar));
|
||||
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
|
||||
--ui-bg-elevated: color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card));
|
||||
--ui-bg-card: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) 4%,
|
||||
color-mix(in srgb, var(--ui-base) 4%, transparent)
|
||||
);
|
||||
--ui-bg-input: #fcfcfc;
|
||||
--ui-bg-primary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-fill-primary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 10%, transparent)
|
||||
);
|
||||
--ui-bg-secondary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-fill-secondary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 7%, transparent)
|
||||
);
|
||||
--ui-bg-tertiary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-fill-tertiary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 5%, transparent)
|
||||
);
|
||||
--ui-bg-quaternary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-fill-quaternary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 4%, transparent)
|
||||
);
|
||||
--ui-bg-quinary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-fill-quinary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 3%, transparent)
|
||||
);
|
||||
--ui-text-primary: color-mix(in srgb, var(--ui-base) 94%, transparent);
|
||||
--ui-text-secondary: color-mix(in srgb, var(--ui-base) 74%, transparent);
|
||||
--ui-text-tertiary: color-mix(in srgb, var(--ui-base) 54%, transparent);
|
||||
--ui-text-quaternary: color-mix(in srgb, var(--ui-base) 36%, transparent);
|
||||
--ui-stroke-primary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-stroke-primary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 10%, transparent)
|
||||
);
|
||||
--ui-stroke-secondary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-stroke-secondary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 7%, transparent)
|
||||
);
|
||||
--ui-stroke-tertiary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-stroke-tertiary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 5%, transparent)
|
||||
);
|
||||
--ui-stroke-quaternary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-stroke-quaternary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 3%, transparent)
|
||||
);
|
||||
--glass-sash-hover-border: color-mix(in srgb, var(--ui-accent) 18%, var(--ui-stroke-tertiary));
|
||||
--glass-sash-hover-background: color-mix(in srgb, var(--ui-accent) 6%, transparent);
|
||||
--glass-surface-background: var(--ui-bg-editor);
|
||||
--glass-sidebar-surface-background: var(--ui-bg-sidebar);
|
||||
--glass-chat-surface-background: var(--ui-bg-chrome);
|
||||
--glass-editor-surface-background: var(--ui-bg-chrome);
|
||||
--glass-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card));
|
||||
--glass-chat-bubble-opaque-background: var(--ui-bg-editor);
|
||||
--glass-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
|
||||
--glass-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
|
||||
--glass-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent);
|
||||
|
||||
--dt-background: var(--ui-bg-chrome);
|
||||
--dt-foreground: var(--ui-text-primary);
|
||||
--dt-card: var(--ui-bg-editor);
|
||||
--dt-card-foreground: var(--ui-text-primary);
|
||||
--dt-muted: var(--ui-bg-tertiary);
|
||||
--dt-muted-foreground: var(--ui-text-tertiary);
|
||||
--dt-popover: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent);
|
||||
--dt-popover-foreground: var(--ui-text-primary);
|
||||
--dt-primary: var(--theme-primary);
|
||||
--dt-primary-foreground: #fcfcfc;
|
||||
--dt-secondary: var(--theme-secondary);
|
||||
--dt-secondary-foreground: var(--ui-text-secondary);
|
||||
--dt-accent: var(--theme-accent-soft);
|
||||
--dt-accent-foreground: var(--ui-text-primary);
|
||||
--dt-border: var(--ui-stroke-secondary);
|
||||
--dt-input: var(--ui-stroke-primary);
|
||||
--dt-ring: var(--ui-stroke-primary);
|
||||
--dt-composer-ring: var(--ui-base);
|
||||
--dt-destructive: #cf2d56;
|
||||
--dt-destructive-foreground: #ffffff;
|
||||
--dt-sidebar-bg: #fafafa;
|
||||
--dt-sidebar-border: #e2e2df;
|
||||
--dt-user-bubble: #f2f2f1;
|
||||
--dt-user-bubble-border: #dededb;
|
||||
--dt-sidebar-bg: var(--ui-bg-sidebar);
|
||||
--dt-sidebar-border: var(--ui-stroke-secondary);
|
||||
--dt-user-bubble: var(--ui-bg-editor);
|
||||
--dt-user-bubble-border: var(--ui-stroke-tertiary);
|
||||
|
||||
--dt-font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||
--dt-font-mono: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
--dt-base-size: 0.875rem;
|
||||
--dt-line-height: 1.55;
|
||||
--dt-font-sans: 'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
|
||||
--dt-font-mono: 'Cascadia Code', 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
--dt-base-size: 1rem;
|
||||
--dt-line-height: 1.5;
|
||||
--dt-letter-spacing: 0;
|
||||
--dt-spacing-mul: 1;
|
||||
|
||||
--radius: 0.75rem;
|
||||
--radius-scalar: 0.2;
|
||||
--radius-scalar: 0.6;
|
||||
|
||||
/* Space under last message vs overlay composer — driven by the measured composer height (see composer/index.tsx). */
|
||||
--thread-last-message-clearance: calc(var(--composer-measured-height) + 1.25rem);
|
||||
--thread-last-message-clearance: calc(var(--composer-measured-height) + 2rem);
|
||||
|
||||
--composer-shell-pad-block-end: 2.5rem;
|
||||
--message-text-indent: 1.5rem;
|
||||
--composer-shell-pad-block-end: 0.625rem;
|
||||
--message-text-indent: 0.75rem;
|
||||
--conversation-text-font-size: 0.8125rem;
|
||||
--conversation-tool-font-size: var(--conversation-text-font-size);
|
||||
--conversation-caption-font-size: 0.75rem;
|
||||
--conversation-line-height: 1.125rem;
|
||||
--conversation-caption-line-height: 1rem;
|
||||
--conversation-turn-gap: 0.375rem;
|
||||
--file-tree-row-height: 1.375rem;
|
||||
|
||||
--composer-width: 88%;
|
||||
--composer-control-size: 2rem;
|
||||
--composer-control-primary-size: 2.125rem;
|
||||
--composer-control-gap: 0.375rem;
|
||||
--composer-row-gap: 0.375rem;
|
||||
--composer-width: 48.75rem;
|
||||
--composer-control-size: 1.75rem;
|
||||
--composer-control-primary-size: 1.875rem;
|
||||
--composer-control-gap: 0.25rem;
|
||||
--composer-row-gap: 0.25rem;
|
||||
--composer-ring-strength: 1;
|
||||
--composer-surface-pad-x: 0.625rem;
|
||||
--composer-surface-pad-y: 0.5rem;
|
||||
--composer-input-min-height: 2rem;
|
||||
--composer-surface-pad-x: 0.5rem;
|
||||
--composer-surface-pad-y: 0.3125rem;
|
||||
--composer-input-min-height: 1.625rem;
|
||||
--composer-input-max-height: 9.375rem;
|
||||
--composer-input-inline-min-width: 8rem;
|
||||
--composer-fallback-height: 2.75rem;
|
||||
--composer-measured-height: calc(0.5rem + var(--composer-shell-pad-block-end) + var(--composer-fallback-height));
|
||||
--composer-surface-measured-height: var(--composer-fallback-height);
|
||||
--thread-viewport-height: max(
|
||||
0px,
|
||||
0rem,
|
||||
calc(100% - var(--composer-measured-height) + var(--composer-surface-measured-height))
|
||||
);
|
||||
--vsq: min(0.5vh, 0.5vw);
|
||||
--image-preview-max-width: 34rem;
|
||||
--image-preview-height: clamp(16.25rem, calc(var(--vsq) * 100), 26.25rem);
|
||||
|
||||
--sidebar-width: 14rem;
|
||||
--chat-min-width: 24rem;
|
||||
--sidebar-width: 14.8125rem;
|
||||
--chat-min-width: 28rem;
|
||||
--titlebar-control-size: 1.25rem;
|
||||
--titlebar-control-height: 1.375rem;
|
||||
--sidebar-content-inline-padding: 1rem;
|
||||
|
|
@ -153,23 +284,64 @@
|
|||
--sidebar-accent-foreground: var(--dt-accent-foreground);
|
||||
--sidebar-border: var(--dt-sidebar-border);
|
||||
--sidebar-ring: var(--dt-ring);
|
||||
--sidebar-edge-border: color-mix(in srgb, var(--dt-sidebar-border) 42%, transparent);
|
||||
--chrome-action-hover: color-mix(in srgb, var(--dt-accent) 72%, transparent);
|
||||
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 7.5%, transparent);
|
||||
--chrome-action-hover: var(--ui-bg-tertiary);
|
||||
|
||||
--midground: var(--dt-midground);
|
||||
--background: var(--dt-background);
|
||||
--foreground: var(--dt-foreground);
|
||||
|
||||
--warm-glow: color-mix(in srgb, var(--dt-midground) 35%, transparent);
|
||||
--warm-glow: color-mix(in srgb, var(--ui-warm) 32%, color-mix(in srgb, var(--ui-accent) 6%, transparent));
|
||||
/* `--noise-opacity-mul` is set per-mode by `applyTheme()`. */
|
||||
--noise-opacity-mul: 1;
|
||||
--backdrop-invert-mul: 1;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--sidebar-edge-border: color-mix(in srgb, var(--dt-sidebar-border) 78%, transparent);
|
||||
--composer-ring-strength: 1.65;
|
||||
--ui-base: #ffe6cb;
|
||||
--ui-accent: #0053fd;
|
||||
--ui-accent-secondary: #ffe6cb;
|
||||
--ui-warm: #ffe6cb;
|
||||
--ui-red: #e75e78;
|
||||
--ui-orange: #db704b;
|
||||
--ui-yellow: #c08532;
|
||||
--ui-green: #55a583;
|
||||
--ui-cyan: #6f9ba6;
|
||||
--ui-blue: #0053fd;
|
||||
--ui-purple: #9e94d5;
|
||||
--ui-bg-chrome: #0d1d3a;
|
||||
--ui-bg-sidebar: #0a1833;
|
||||
--ui-bg-editor: #101827;
|
||||
--ui-bg-elevated: #121d32;
|
||||
--ui-bg-input: #131316;
|
||||
--dt-background: var(--ui-bg-chrome);
|
||||
--dt-foreground: var(--ui-text-primary);
|
||||
--dt-card: var(--ui-bg-elevated);
|
||||
--dt-card-foreground: var(--ui-text-primary);
|
||||
--dt-muted: var(--ui-bg-tertiary);
|
||||
--dt-muted-foreground: var(--ui-text-tertiary);
|
||||
--dt-popover: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent);
|
||||
--dt-popover-foreground: var(--ui-text-primary);
|
||||
--dt-primary: var(--ui-accent);
|
||||
--dt-primary-foreground: #0b0b0c;
|
||||
--dt-secondary: var(--ui-bg-secondary);
|
||||
--dt-secondary-foreground: var(--ui-text-secondary);
|
||||
--dt-accent: var(--ui-bg-tertiary);
|
||||
--dt-accent-foreground: var(--ui-text-primary);
|
||||
--dt-border: var(--ui-stroke-secondary);
|
||||
--dt-input: var(--ui-stroke-primary);
|
||||
--dt-ring: var(--ui-stroke-primary);
|
||||
--dt-composer-ring: var(--ui-base);
|
||||
--dt-sidebar-bg: var(--ui-bg-sidebar);
|
||||
--dt-sidebar-border: var(--ui-stroke-secondary);
|
||||
--dt-user-bubble: var(--ui-bg-elevated);
|
||||
--dt-user-bubble-border: var(--ui-stroke-tertiary);
|
||||
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 12%, transparent);
|
||||
--composer-ring-strength: 1.3;
|
||||
--backdrop-invert-mul: 0;
|
||||
--glass-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
|
||||
--glass-inline-code-border: color-mix(in srgb, #ffffff 10%, transparent);
|
||||
--glass-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -189,9 +361,10 @@
|
|||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--dt-background);
|
||||
background: var(--glass-chat-surface-background);
|
||||
color: var(--dt-foreground);
|
||||
font-family: var(--dt-font-sans);
|
||||
font-size: 0.8125rem;
|
||||
line-height: var(--dt-line-height, 1.55);
|
||||
letter-spacing: var(--dt-letter-spacing, 0);
|
||||
overflow: hidden;
|
||||
|
|
@ -212,7 +385,7 @@
|
|||
}
|
||||
|
||||
.dither {
|
||||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 2px 2px;
|
||||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
:root:not([style*='--theme-asset-bg:']) .theme-default-filler {
|
||||
|
|
@ -226,7 +399,7 @@
|
|||
@layer utilities {
|
||||
[class*='rounded-full'],
|
||||
[class*=':rounded-full'] {
|
||||
border-radius: calc(var(--radius-scalar) * 9999px);
|
||||
border-radius: calc(var(--radius-scalar) * 9999rem);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,8 +417,8 @@
|
|||
--arc-c1: var(--dt-midground);
|
||||
--arc-c2: var(--dt-background);
|
||||
--arc-angle: 160deg;
|
||||
--arc-width: 1.25px;
|
||||
--arc-inset: -2px;
|
||||
--arc-width: 0.078125rem;
|
||||
--arc-inset: -0.125rem;
|
||||
--arc-duration: 2.23s;
|
||||
|
||||
pointer-events: none;
|
||||
|
|
@ -300,6 +473,46 @@ button {
|
|||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
[data-slot='button'] {
|
||||
box-shadow: none;
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
|
||||
[data-slot='button'][data-variant='outline'],
|
||||
[data-slot='button'][data-variant='secondary'] {
|
||||
border-color: var(--ui-stroke-secondary);
|
||||
background: var(--ui-bg-tertiary);
|
||||
color: var(--ui-text-primary);
|
||||
}
|
||||
|
||||
[data-slot='button'][data-variant='ghost'] {
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
[data-slot='button'][data-variant='outline']:hover,
|
||||
[data-slot='button'][data-variant='secondary']:hover,
|
||||
[data-slot='button'][data-variant='ghost']:hover {
|
||||
background: var(--chrome-action-hover);
|
||||
color: var(--ui-text-primary);
|
||||
}
|
||||
|
||||
[data-slot='dropdown-menu-content'],
|
||||
[data-slot='select-content'],
|
||||
[data-slot='dialog-content'] {
|
||||
border-color: var(--ui-stroke-secondary);
|
||||
background: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent);
|
||||
box-shadow: var(--shadow-md);
|
||||
backdrop-filter: blur(0.75rem) saturate(1.08);
|
||||
-webkit-backdrop-filter: blur(0.75rem) saturate(1.08);
|
||||
}
|
||||
|
||||
[data-slot='dropdown-menu-item']:focus,
|
||||
[data-slot='dropdown-menu-checkbox-item']:focus,
|
||||
[data-slot='dropdown-menu-radio-item']:focus {
|
||||
background: var(--ui-bg-tertiary);
|
||||
color: var(--ui-text-primary);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable]:not([contenteditable='false']),
|
||||
|
|
@ -396,7 +609,7 @@ canvas {
|
|||
.scrollbar-dt::-webkit-scrollbar-thumb,
|
||||
.scrollbar-dt *::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dt-midground) 18%, transparent);
|
||||
border-radius: 9999px;
|
||||
border-radius: 9999rem;
|
||||
border: 0.125rem solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
|
@ -431,6 +644,147 @@ canvas {
|
|||
|
||||
[data-slot='aui_assistant-message-content'] {
|
||||
padding-left: var(--message-text-indent);
|
||||
font-size: var(--conversation-text-font-size);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-root'] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :where(p, li, blockquote, table, pre) {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Streamed prose hangs slightly indented from the tool/todo column so the
|
||||
reading column reads as a "reply" within the conversation gutter. Tools,
|
||||
todos, and thinking blocks keep the existing --message-text-indent so they
|
||||
remain flush with the user message text above them. */
|
||||
[data-slot='aui_assistant-message-content'] > .aui-md {
|
||||
padding-inline-start: var(--md-text-indent, 0.5rem);
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_edit-composer-root'] {
|
||||
font-size: var(--conversation-text-font-size);
|
||||
}
|
||||
|
||||
[data-slot='aui_thread-content'] {
|
||||
max-width: var(--composer-width);
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_intro'] {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: var(--composer-measured-height);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-slot='aui_intro'] > div {
|
||||
max-width: min(var(--composer-width), 82vw);
|
||||
}
|
||||
|
||||
[data-slot='aui_intro'] p:last-child {
|
||||
max-width: 34rem;
|
||||
margin-inline: auto;
|
||||
color: var(--ui-text-tertiary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.fit-text {
|
||||
display: flex;
|
||||
font-size: var(--fit-text-min, 1rem);
|
||||
container-type: inline-size;
|
||||
--captured-length: initial;
|
||||
--support-sentinel: var(--captured-length, 9999px);
|
||||
}
|
||||
|
||||
.fit-text > [aria-hidden='true'] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.fit-text > :not([aria-hidden='true']) {
|
||||
flex-grow: 1;
|
||||
container-type: inline-size;
|
||||
--captured-length: 100cqi;
|
||||
--available-space: var(--captured-length);
|
||||
}
|
||||
|
||||
.fit-text > :not([aria-hidden='true']) > * {
|
||||
display: block;
|
||||
inline-size: var(--available-space);
|
||||
line-height: var(--fit-text-line-height, 1);
|
||||
--support-sentinel: inherit;
|
||||
--captured-length: 100cqi;
|
||||
--ratio: tan(atan2(var(--available-space), var(--available-space) - var(--captured-length)));
|
||||
--font-size: clamp(
|
||||
var(--fit-text-min, 1em),
|
||||
1em * var(--ratio),
|
||||
var(--fit-text-max, infinity * 1px) - var(--support-sentinel)
|
||||
);
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
@container (inline-size > 0) {
|
||||
.fit-text > :not([aria-hidden='true']) > * {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@property --captured-length {
|
||||
syntax: '<length>';
|
||||
initial-value: 0px;
|
||||
inherits: true;
|
||||
}
|
||||
|
||||
@property --captured-length2 {
|
||||
syntax: '<length>';
|
||||
initial-value: 0px;
|
||||
inherits: true;
|
||||
}
|
||||
|
||||
[data-slot='composer-root'] {
|
||||
width: min(var(--composer-width), calc(100% - 2rem));
|
||||
padding-bottom: var(--composer-shell-pad-block-end);
|
||||
}
|
||||
|
||||
[data-slot='composer-root'] > .pointer-events-none {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--glass-chat-surface-background) 88%, transparent)
|
||||
) !important;
|
||||
}
|
||||
|
||||
[data-slot='composer-surface'] {
|
||||
border-color: var(--ui-stroke-secondary) !important;
|
||||
box-shadow: var(--shadow-composer) !important;
|
||||
}
|
||||
|
||||
[data-slot='composer-surface'] > [aria-hidden='true'] {
|
||||
background: var(--glass-chat-bubble-background) !important;
|
||||
backdrop-filter: blur(0.75rem) saturate(1.08);
|
||||
-webkit-backdrop-filter: blur(0.75rem) saturate(1.08);
|
||||
}
|
||||
|
||||
[data-slot='composer-fade'] {
|
||||
min-height: 2.375rem;
|
||||
}
|
||||
|
||||
[data-slot='composer-rich-input'] {
|
||||
color: var(--ui-text-primary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
[data-slot='composer-rich-input']:empty::before {
|
||||
color: var(--ui-text-tertiary) !important;
|
||||
}
|
||||
|
||||
[data-slot='composer-root']:focus-within [data-slot='composer-surface'] > [aria-hidden='true'] {
|
||||
background: var(--glass-chat-bubble-background) !important;
|
||||
}
|
||||
|
||||
/* Tool/thinking blocks now live at message-text alignment (no leading
|
||||
|
|
@ -457,10 +811,33 @@ canvas {
|
|||
white-space: inherit;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :where(.aui-code-header, .aui-shiki, .aui-shiki > pre) {
|
||||
/* Streamdown's adapter wraps code fences in a `data-streamdown="code-block"`
|
||||
container with its own card chrome. We render our own <CodeCard>, so this
|
||||
strips the upstream chrome down to a layout-only passthrough. */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] {
|
||||
contain: none;
|
||||
overflow: visible;
|
||||
margin-block: 0.375rem !important;
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block']:has(.aui-prose-fence) {
|
||||
margin-block: 0 !important;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :not(pre) > code {
|
||||
border: 0.0625rem solid var(--glass-inline-code-border);
|
||||
background: var(--glass-inline-code-background);
|
||||
color: var(--glass-inline-code-foreground);
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :where(.aui-shiki, .aui-shiki > pre) {
|
||||
margin: 0 !important;
|
||||
margin-block-start: 0 !important;
|
||||
margin-block-end: 0 !important;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table {
|
||||
|
|
@ -478,23 +855,54 @@ canvas {
|
|||
margin-block-end: 0 !important;
|
||||
}
|
||||
|
||||
/* Tool / thinking blocks are scaffolding around the model's reply, so we
|
||||
fade them slightly. The reading column (prose) stays at full strength;
|
||||
scaffolding recedes and lifts back to full opacity on hover/focus so it
|
||||
stays legible when the user actually wants to read it. */
|
||||
[data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
background: transparent !important;
|
||||
opacity: 0.67;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot='tool-block'] {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']):is(:hover, :focus-within) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Conversation block rhythm. Consecutive tool calls stay tight so a step
|
||||
sequence reads as one action group; the gap between any scaffolding
|
||||
block and adjacent prose bumps up so the model's reply visually
|
||||
separates from its scaffolding. */
|
||||
[data-slot='tool-block'] + [data-slot='tool-block'] {
|
||||
margin-top: 0.25rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
[data-slot='tool-block']:has(> :nth-child(2)) + [data-slot='tool-block'] {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + .aui-md,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md + [data-slot='tool-block'] {
|
||||
margin-top: 0.875rem;
|
||||
[data-slot='aui_assistant-message-content']
|
||||
:is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'])
|
||||
+ .aui-md,
|
||||
[data-slot='aui_assistant-message-content']
|
||||
.aui-md
|
||||
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Reasoning “Thinking” row — same column as message body, not full-bleed like tools */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md + [data-slot='aui_thinking-disclosure'],
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='aui_thinking-disclosure'] + .aui-md {
|
||||
margin-top: 0.375rem;
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-slot='aui_thinking-disclosure']
|
||||
+ [data-slot='tool-block'],
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-slot='tool-block']
|
||||
+ [data-slot='aui_thinking-disclosure'] {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:first-child {
|
||||
|
|
@ -552,8 +960,8 @@ canvas {
|
|||
height: 1em;
|
||||
margin-left: 0.18em;
|
||||
vertical-align: middle;
|
||||
border-radius: 1.5px;
|
||||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 2px 2px;
|
||||
border-radius: 0.09375rem;
|
||||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem;
|
||||
color: color-mix(in srgb, var(--dt-foreground) 72%, transparent);
|
||||
box-shadow:
|
||||
-0.8ch 0 1.4ch 0.55em color-mix(in srgb, var(--dt-background) 80%, transparent),
|
||||
|
|
|
|||
|
|
@ -159,6 +159,14 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
|||
const rendered = renderedModeFor(c, mode)
|
||||
const midground = c.midground ?? c.ring
|
||||
const skinName = theme.name.endsWith(`-${mode}`) ? theme.name.slice(0, -mode.length - 1) : theme.name
|
||||
const neutralChrome = rendered === 'dark' ? '#0d0d0e' : '#f3f3f3'
|
||||
const neutralSidebar = rendered === 'dark' ? '#0a0a0b' : '#f3f3f3'
|
||||
const neutralCard = rendered === 'dark' ? '#161618' : '#fcfcfc'
|
||||
const chromeMix = rendered === 'dark' ? 36 : 44
|
||||
const sidebarMix = rendered === 'dark' ? 42 : 36
|
||||
const cardMix = rendered === 'dark' ? 38 : 22
|
||||
const elevatedMix = rendered === 'dark' ? 46 : 28
|
||||
const bubbleMix = rendered === 'dark' ? 48 : 30
|
||||
|
||||
root.style.setProperty('color-scheme', rendered)
|
||||
root.dataset.hermesTheme = skinName
|
||||
|
|
@ -166,31 +174,74 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
|||
root.classList.toggle('dark', rendered === 'dark')
|
||||
|
||||
const set = (k: string, v: string) => root.style.setProperty(k, v)
|
||||
set('--dt-background', c.background)
|
||||
set('--dt-foreground', c.foreground)
|
||||
set('--dt-card', c.card)
|
||||
set('--dt-card-foreground', c.cardForeground)
|
||||
set('--theme-foreground', c.foreground)
|
||||
set('--theme-primary', c.primary)
|
||||
set('--theme-secondary', c.secondary)
|
||||
set('--theme-accent-soft', c.accent)
|
||||
set('--theme-midground', midground)
|
||||
set('--theme-warm', c.primary)
|
||||
set('--theme-background-seed', c.background)
|
||||
set('--theme-sidebar-seed', c.sidebarBackground ?? c.background)
|
||||
set('--theme-card-seed', c.card)
|
||||
set('--theme-elevated-seed', c.popover)
|
||||
set('--theme-bubble-seed', c.userBubble ?? c.popover)
|
||||
set('--theme-neutral-chrome', neutralChrome)
|
||||
set('--theme-neutral-sidebar', neutralSidebar)
|
||||
set('--theme-neutral-card', neutralCard)
|
||||
set('--theme-mix-chrome', `${chromeMix}%`)
|
||||
set('--theme-mix-sidebar', `${sidebarMix}%`)
|
||||
set('--theme-mix-card', `${cardMix}%`)
|
||||
set('--theme-mix-elevated', `${elevatedMix}%`)
|
||||
set('--theme-mix-bubble', `${bubbleMix}%`)
|
||||
set('--theme-fill-primary-accent-mix', '16%')
|
||||
set('--theme-fill-secondary-accent-mix', '11%')
|
||||
set('--theme-fill-tertiary-accent-mix', '8%')
|
||||
set('--theme-fill-quaternary-accent-mix', '5%')
|
||||
set('--theme-fill-quinary-accent-mix', '3%')
|
||||
set('--theme-stroke-primary-accent-mix', '24%')
|
||||
set('--theme-stroke-secondary-accent-mix', '16%')
|
||||
set('--theme-stroke-tertiary-accent-mix', '10%')
|
||||
set('--theme-stroke-quaternary-accent-mix', '6%')
|
||||
set('--ui-base', 'var(--theme-foreground)')
|
||||
set('--ui-accent', 'var(--theme-midground)')
|
||||
set('--ui-accent-secondary', 'var(--theme-primary)')
|
||||
set('--ui-warm', 'var(--theme-warm)')
|
||||
set('--ui-bg-chrome', 'color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome))')
|
||||
set('--ui-bg-sidebar', 'color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar))')
|
||||
set('--ui-bg-editor', 'color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card))')
|
||||
set('--ui-bg-elevated', 'color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card))')
|
||||
set('--ui-bg-input', 'var(--ui-bg-editor)')
|
||||
set('--glass-surface-background', 'var(--ui-bg-editor)')
|
||||
set('--glass-sidebar-surface-background', 'var(--ui-bg-sidebar)')
|
||||
set('--glass-chat-surface-background', 'var(--ui-bg-chrome)')
|
||||
set('--glass-editor-surface-background', 'var(--ui-bg-chrome)')
|
||||
set('--glass-chat-bubble-background', 'color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card))')
|
||||
set('--glass-chat-bubble-opaque-background', 'var(--ui-bg-editor)')
|
||||
set('--dt-background', 'var(--ui-bg-chrome)')
|
||||
set('--dt-foreground', 'var(--ui-text-primary)')
|
||||
set('--dt-card', 'var(--ui-bg-editor)')
|
||||
set('--dt-card-foreground', 'var(--ui-text-primary)')
|
||||
set('--dt-muted', c.muted)
|
||||
set('--dt-muted-foreground', c.mutedForeground)
|
||||
set('--dt-popover', c.popover)
|
||||
set('--dt-popover-foreground', c.popoverForeground)
|
||||
set('--dt-primary', c.primary)
|
||||
set('--dt-muted-foreground', 'var(--ui-text-tertiary)')
|
||||
set('--dt-popover', 'var(--ui-bg-elevated)')
|
||||
set('--dt-popover-foreground', 'var(--ui-text-primary)')
|
||||
set('--dt-primary', 'var(--theme-primary)')
|
||||
set('--dt-primary-foreground', c.primaryForeground)
|
||||
set('--dt-secondary', c.secondary)
|
||||
set('--dt-secondary', 'var(--theme-secondary)')
|
||||
set('--dt-secondary-foreground', c.secondaryForeground)
|
||||
set('--dt-accent', c.accent)
|
||||
set('--dt-accent', 'var(--theme-accent-soft)')
|
||||
set('--dt-accent-foreground', c.accentForeground)
|
||||
set('--dt-border', c.border)
|
||||
set('--dt-input', c.input)
|
||||
set('--dt-ring', c.ring)
|
||||
set('--dt-midground', midground)
|
||||
set('--dt-midground', 'var(--theme-midground)')
|
||||
set('--dt-midground-foreground', c.midgroundForeground ?? readableOn(midground))
|
||||
set('--dt-composer-ring', c.composerRing ?? midground)
|
||||
set('--dt-destructive', c.destructive)
|
||||
set('--dt-destructive-foreground', c.destructiveForeground)
|
||||
set('--dt-sidebar-bg', c.sidebarBackground ?? c.background)
|
||||
set('--dt-sidebar-bg', 'var(--ui-bg-sidebar)')
|
||||
set('--dt-sidebar-border', c.sidebarBorder ?? c.border)
|
||||
set('--dt-user-bubble', c.userBubble ?? c.muted)
|
||||
set('--dt-user-bubble', 'var(--glass-chat-bubble-background)')
|
||||
set('--dt-user-bubble-border', c.userBubbleBorder ?? c.border)
|
||||
set('--dt-font-sans', typo.fontSans)
|
||||
set('--dt-font-mono', typo.fontMono)
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@
|
|||
import type { DesktopTheme, DesktopThemeTypography } from './types'
|
||||
|
||||
const SYSTEM_SANS =
|
||||
'ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, system-ui, sans-serif'
|
||||
'"Segoe WPC", "Segoe UI", -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", system-ui, sans-serif'
|
||||
|
||||
const SYSTEM_MONO =
|
||||
'ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Monaco, Consolas, "Liberation Mono", monospace'
|
||||
const SYSTEM_MONO = '"Cascadia Code", "JetBrains Mono", "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace'
|
||||
|
||||
export const DEFAULT_TYPOGRAPHY: DesktopThemeTypography = { fontSans: SYSTEM_SANS, fontMono: SYSTEM_MONO }
|
||||
|
||||
|
|
@ -17,43 +16,44 @@ const NOUS_BLUE = '#0053FD'
|
|||
const PSYCHE_BLUE = '#1540B1'
|
||||
const PSYCHE_WARM = '#FFE6CB'
|
||||
|
||||
const tint = (pct: number) => `color-mix(in srgb, ${NOUS_BLUE} ${pct}%, #FFFFFF)`
|
||||
const tintTransparent = (pct: number) => `color-mix(in srgb, ${NOUS_BLUE} ${pct}%, transparent)`
|
||||
const nousTint = (pct: number) => `color-mix(in srgb, ${NOUS_BLUE} ${pct}%, #FFFFFF)`
|
||||
const nousTintTransparent = (pct: number) => `color-mix(in srgb, ${NOUS_BLUE} ${pct}%, transparent)`
|
||||
|
||||
/**
|
||||
* Nous — canonical Hermes/Nous identity. Replaces the historical trio of
|
||||
* `nous-light`, `default`, and `gold`. Light = bright Nous blue on white;
|
||||
* dark = Hermes lens-blue with cream foreground.
|
||||
* Nous — canonical Hermes desktop identity. The palette keeps the current
|
||||
* glass geometry neutral, then lets the old bb/gui blue and psyche cream
|
||||
* return as accent seeds.
|
||||
*/
|
||||
export const nousTheme: DesktopTheme = {
|
||||
name: 'nous',
|
||||
label: 'Nous',
|
||||
description: 'Bright Nous blue in light mode, Hermes blue in dark mode',
|
||||
description: 'Glass neutrals with Nous blue accents',
|
||||
colors: {
|
||||
background: '#FFFFFF',
|
||||
background: '#F8FAFF',
|
||||
foreground: '#17171A',
|
||||
card: '#FFFFFF',
|
||||
cardForeground: '#17171A',
|
||||
muted: tint(5),
|
||||
muted: nousTint(5),
|
||||
mutedForeground: '#666678',
|
||||
popover: '#FFFFFF',
|
||||
popoverForeground: '#17171A',
|
||||
primary: NOUS_BLUE,
|
||||
primaryForeground: '#FFFFFF',
|
||||
secondary: tint(7),
|
||||
primaryForeground: '#FCFCFC',
|
||||
secondary: nousTint(7),
|
||||
secondaryForeground: '#242432',
|
||||
accent: tint(10),
|
||||
accent: nousTint(10),
|
||||
accentForeground: '#202030',
|
||||
border: tintTransparent(22),
|
||||
input: tintTransparent(30),
|
||||
border: nousTintTransparent(22),
|
||||
input: nousTintTransparent(30),
|
||||
ring: NOUS_BLUE,
|
||||
midground: NOUS_BLUE,
|
||||
composerRing: NOUS_BLUE,
|
||||
destructive: '#C72E4D',
|
||||
destructiveForeground: '#FFFFFF',
|
||||
sidebarBackground: tint(2.5),
|
||||
sidebarBorder: tintTransparent(18),
|
||||
userBubble: tint(6),
|
||||
userBubbleBorder: tintTransparent(24)
|
||||
sidebarBackground: '#F3F7FF',
|
||||
sidebarBorder: nousTintTransparent(18),
|
||||
userBubble: nousTint(6),
|
||||
userBubbleBorder: nousTintTransparent(24)
|
||||
},
|
||||
darkColors: {
|
||||
background: '#0D2F86',
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ export interface SessionCreateResponse {
|
|||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
cwd?: null | string
|
||||
ended_at: null | number
|
||||
id: string
|
||||
input_tokens: number
|
||||
|
|
|
|||
|
|
@ -18,8 +18,13 @@ export default defineConfig({
|
|||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@hermes/shared': path.resolve(__dirname, '../shared/src')
|
||||
}
|
||||
'@hermes/shared': path.resolve(__dirname, '../shared/src'),
|
||||
react: path.resolve(__dirname, '../../node_modules/react'),
|
||||
'react-dom': path.resolve(__dirname, '../../node_modules/react-dom'),
|
||||
'react/jsx-dev-runtime': path.resolve(__dirname, '../../node_modules/react/jsx-dev-runtime.js'),
|
||||
'react/jsx-runtime': path.resolve(__dirname, '../../node_modules/react/jsx-runtime.js')
|
||||
},
|
||||
dedupe: ['react', 'react-dom']
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
|
|
|
|||
|
|
@ -1055,6 +1055,10 @@ async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0):
|
|||
total = db.session_count(min_message_count=min_message_count)
|
||||
now = time.time()
|
||||
for s in sessions:
|
||||
# Return only persisted per-session cwd from SessionDB.
|
||||
# Falling back to process-level terminal.cwd causes historical
|
||||
# sessions to "teleport" between workspaces as config changes.
|
||||
s["cwd"] = s.get("cwd") or None
|
||||
s["is_active"] = (
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||
cache_read_tokens INTEGER DEFAULT 0,
|
||||
cache_write_tokens INTEGER DEFAULT 0,
|
||||
reasoning_tokens INTEGER DEFAULT 0,
|
||||
cwd TEXT,
|
||||
billing_provider TEXT,
|
||||
billing_base_url TEXT,
|
||||
billing_mode TEXT,
|
||||
|
|
@ -690,13 +691,14 @@ class SessionDB:
|
|||
system_prompt: str = None,
|
||||
user_id: str = None,
|
||||
parent_session_id: str = None,
|
||||
cwd: str = None,
|
||||
) -> None:
|
||||
"""Shared INSERT OR IGNORE for session rows."""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,
|
||||
system_prompt, parent_session_id, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
system_prompt, parent_session_id, cwd, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
session_id,
|
||||
source,
|
||||
|
|
@ -705,6 +707,7 @@ class SessionDB:
|
|||
json.dumps(model_config) if model_config else None,
|
||||
system_prompt,
|
||||
parent_session_id,
|
||||
cwd,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
|
|
@ -741,6 +744,16 @@ class SessionDB:
|
|||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
def update_session_cwd(self, session_id: str, cwd: str) -> None:
|
||||
"""Persist the session working directory when a frontend knows it."""
|
||||
if not session_id or not cwd:
|
||||
return
|
||||
|
||||
def _do(conn):
|
||||
conn.execute("UPDATE sessions SET cwd = ? WHERE id = ?", (cwd, session_id))
|
||||
|
||||
self._execute_write(_do)
|
||||
|
||||
def update_system_prompt(self, session_id: str, system_prompt: str) -> None:
|
||||
"""Store the full assembled system prompt snapshot."""
|
||||
def _do(conn):
|
||||
|
|
@ -1343,7 +1356,7 @@ class SessionDB:
|
|||
for key in (
|
||||
"id", "ended_at", "end_reason", "message_count",
|
||||
"tool_call_count", "title", "last_active", "preview",
|
||||
"model", "system_prompt",
|
||||
"model", "system_prompt", "cwd",
|
||||
):
|
||||
if key in tip_row:
|
||||
merged[key] = tip_row[key]
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue