mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(desktop): add shared project UI primitives
This commit is contained in:
parent
e2b8018729
commit
344415892f
38 changed files with 1867 additions and 440 deletions
|
|
@ -17,5 +17,5 @@
|
|||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"iconLibrary": "tabler"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'reac
|
|||
import { composerPanelCard } from '@/components/chat/composer-dock'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setPaneHoverRevealSuppressed } from '@/store/panes'
|
||||
|
||||
import {
|
||||
activeTimelineIndex,
|
||||
|
|
@ -60,6 +59,51 @@ function userPromptText(content: unknown): string {
|
|||
return out
|
||||
}
|
||||
|
||||
/** Index-keyed ref-array setter — `ref={listRef(refs, i)}`. */
|
||||
const listRef =
|
||||
<T,>(refs: React.RefObject<(T | null)[]>, index: number) =>
|
||||
(node: T | null) => {
|
||||
refs.current[index] = node
|
||||
}
|
||||
|
||||
/** Mouse enter/leave pair forwarding `on` to the shared paint(). */
|
||||
const hoverProps = (index: number, paint: (index: number, on: boolean) => void) => ({
|
||||
onMouseEnter: () => paint(index, true),
|
||||
onMouseLeave: () => paint(index, false)
|
||||
})
|
||||
|
||||
// Constant-duration jump (eased), NOT native `behavior:'smooth'` — Chromium's
|
||||
// smooth scroll animates proportional to distance, so jumping across a long
|
||||
// thread crawls for seconds. A fixed ~260ms feels instant near or far. A
|
||||
// shared rAF handle cancels a prior jump so rapid tick clicks don't fight.
|
||||
let jumpRaf = 0
|
||||
|
||||
function jumpScroll(viewport: HTMLElement, top: number, duration = 170): void {
|
||||
cancelAnimationFrame(jumpRaf)
|
||||
const start = viewport.scrollTop
|
||||
const delta = top - start
|
||||
|
||||
if (Math.abs(delta) < 2) {
|
||||
viewport.scrollTop = top
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const t0 = performance.now()
|
||||
const ease = (t: number) => 1 - (1 - t) ** 3 // easeOutCubic
|
||||
|
||||
const step = (now: number) => {
|
||||
const p = Math.min(1, (now - t0) / duration)
|
||||
viewport.scrollTop = start + delta * ease(p)
|
||||
|
||||
if (p < 1) {
|
||||
jumpRaf = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
jumpRaf = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
function scrollToPrompt(id: string) {
|
||||
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
|
||||
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
|
||||
|
|
@ -71,7 +115,7 @@ function scrollToPrompt(id: string) {
|
|||
const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8
|
||||
|
||||
triggerHaptic('selection')
|
||||
viewport.scrollTo({ behavior: 'smooth', top: Math.max(0, top) })
|
||||
jumpScroll(viewport, Math.max(0, top))
|
||||
}
|
||||
|
||||
/** Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only. */
|
||||
|
|
@ -96,36 +140,36 @@ export const ThreadTimeline: FC = () => {
|
|||
)
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const closeTimerRef = useRef<number | undefined>(undefined)
|
||||
|
||||
// Hover sync lives on the DOM, not in React state — the tick and its popover
|
||||
// row are siblings in different subtrees, so a shared index-keyed paint() lights
|
||||
// both without a re-render (and without coupling them through a parent atom).
|
||||
const tickRefs = useRef<(HTMLSpanElement | null)[]>([])
|
||||
const rowRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
const paint = useCallback((index: number, on: boolean) => {
|
||||
const tick = tickRefs.current[index]
|
||||
|
||||
if (tick) {
|
||||
tick.style.opacity = on ? '1' : ''
|
||||
}
|
||||
|
||||
rowRefs.current[index]?.classList.toggle('bg-(--ui-row-hover-background)', on)
|
||||
}, [])
|
||||
|
||||
const keepOpen = useCallback(() => {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
setPaneHoverRevealSuppressed(true)
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const closeSoon = useCallback(() => {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
setHoverIndex(null)
|
||||
setPaneHoverRevealSuppressed(false)
|
||||
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
setPaneHoverRevealSuppressed(false)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (entries.length < MIN_ENTRIES) {
|
||||
setPaneHoverRevealSuppressed(false)
|
||||
}
|
||||
}, [entries.length])
|
||||
useEffect(() => () => window.clearTimeout(closeTimerRef.current), [])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
|
||||
|
|
@ -179,6 +223,7 @@ export const ThreadTimeline: FC = () => {
|
|||
aria-label="Conversation timeline"
|
||||
className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end"
|
||||
data-slot="thread-timeline"
|
||||
data-suppress-pane-reveal=""
|
||||
onMouseEnter={keepOpen}
|
||||
onMouseLeave={closeSoon}
|
||||
role="navigation"
|
||||
|
|
@ -186,16 +231,17 @@ export const ThreadTimeline: FC = () => {
|
|||
<TimelineTicks
|
||||
activeIndex={activeIndex}
|
||||
entries={entries}
|
||||
onHover={setHoverIndex}
|
||||
onHover={paint}
|
||||
onJump={scrollToPrompt}
|
||||
tickRefs={tickRefs}
|
||||
/>
|
||||
<TimelinePopover
|
||||
activeIndex={activeIndex}
|
||||
entries={entries}
|
||||
hoverIndex={hoverIndex}
|
||||
onHover={setHoverIndex}
|
||||
onHover={paint}
|
||||
onJump={scrollToPrompt}
|
||||
open={open}
|
||||
rowRefs={rowRefs}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -204,11 +250,11 @@ export const ThreadTimeline: FC = () => {
|
|||
const TimelinePopover: FC<{
|
||||
activeIndex: number
|
||||
entries: TimelineEntry[]
|
||||
hoverIndex: number | null
|
||||
onHover: (index: number) => void
|
||||
onHover: (index: number, on: boolean) => void
|
||||
onJump: (id: string) => void
|
||||
open: boolean
|
||||
}> = ({ activeIndex, entries, hoverIndex, onHover, onJump, open }) => (
|
||||
rowRefs: React.RefObject<(HTMLButtonElement | null)[]>
|
||||
}> = ({ activeIndex, entries, onHover, onJump, open, rowRefs }) => (
|
||||
<div
|
||||
className={cn(
|
||||
POPOVER_SHELL,
|
||||
|
|
@ -216,55 +262,49 @@ const TimelinePopover: FC<{
|
|||
)}
|
||||
data-slot="thread-timeline-popover"
|
||||
>
|
||||
{entries.map((entry, index) => {
|
||||
const hovered = index === hoverIndex
|
||||
const active = index === activeIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={entry.preview}
|
||||
className={cn(
|
||||
ROW_CLASS,
|
||||
active && 'bg-(--ui-row-active-background) text-foreground',
|
||||
hovered && 'bg-(--ui-row-hover-background) text-foreground transition-none'
|
||||
)}
|
||||
key={entry.id}
|
||||
onClick={() => onJump(entry.id)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
|
||||
{entry.preview}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{entries.map((entry, index) => (
|
||||
<button
|
||||
aria-label={entry.preview}
|
||||
className={cn(ROW_CLASS, index === activeIndex && 'bg-(--ui-row-active-background) text-foreground')}
|
||||
key={entry.id}
|
||||
onClick={() => onJump(entry.id)}
|
||||
ref={listRef(rowRefs, index)}
|
||||
type="button"
|
||||
{...hoverProps(index, onHover)}
|
||||
>
|
||||
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
|
||||
{entry.preview}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const TimelineTicks: FC<{
|
||||
activeIndex: number
|
||||
entries: TimelineEntry[]
|
||||
onHover: (index: number) => void
|
||||
onHover: (index: number, on: boolean) => void
|
||||
onJump: (id: string) => void
|
||||
}> = ({ activeIndex, entries, onHover, onJump }) => (
|
||||
tickRefs: React.RefObject<(HTMLSpanElement | null)[]>
|
||||
}> = ({ activeIndex, entries, onHover, onJump, tickRefs }) => (
|
||||
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
|
||||
{entries.map((entry, index) => (
|
||||
<button
|
||||
aria-label={entry.preview}
|
||||
className="group/tick flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
|
||||
className="flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
|
||||
key={entry.id}
|
||||
onClick={() => onJump(entry.id)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
{...hoverProps(index, onHover)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block h-px w-3 transition-opacity duration-100 ease-out',
|
||||
index === activeIndex
|
||||
? 'bg-(--theme-primary)'
|
||||
: 'dither text-(--ui-text-quaternary) opacity-70 group-hover/tick:opacity-100 group-hover/tick:transition-none'
|
||||
: 'dither text-(--ui-text-quaternary) opacity-70'
|
||||
)}
|
||||
ref={listRef(tickRefs, index)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
useMessageRuntime
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { IconPlayerStopFilled } from '@tabler/icons-react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type ComponentProps,
|
||||
|
|
@ -92,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti
|
|||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { LinkifiedText } from '@/lib/external-link'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||
import { GitBranchIcon, Loader2Icon, StopFilled, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -105,6 +104,10 @@ import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scro
|
|||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
interface RestoreMessageTarget {
|
||||
text: string
|
||||
userOrdinal: number | null
|
||||
}
|
||||
|
||||
interface MessageActionProps {
|
||||
messageId: string
|
||||
|
|
@ -171,7 +174,7 @@ export const Thread: FC<{
|
|||
onBranchInNewChat?: (messageId: string) => void
|
||||
onCancel?: () => Promise<void> | void
|
||||
onDismissError?: (messageId: string) => void
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise<void> | void
|
||||
sessionId?: string | null
|
||||
sessionKey?: string | null
|
||||
}> = ({
|
||||
|
|
@ -187,14 +190,45 @@ export const Thread: FC<{
|
|||
sessionId = null,
|
||||
sessionKey
|
||||
}) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
|
||||
const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<(RestoreMessageTarget & { messageId: string }) | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const closeRestoreConfirm = useCallback(() => setRestoreConfirmTarget(null), [])
|
||||
|
||||
const confirmRestore = useCallback(() => {
|
||||
if (!restoreConfirmTarget || !onRestoreToMessage) {
|
||||
throw new Error('Restore is unavailable for this message.')
|
||||
}
|
||||
|
||||
const { messageId, text, userOrdinal } = restoreConfirmTarget
|
||||
|
||||
closeRestoreConfirm()
|
||||
void Promise.resolve(onRestoreToMessage(messageId, { text, userOrdinal })).catch((error: unknown) => {
|
||||
notifyError(error, 'Restore failed')
|
||||
})
|
||||
}, [closeRestoreConfirm, onRestoreToMessage, restoreConfirmTarget])
|
||||
|
||||
const requestRestoreConfirm = useCallback((messageId: string, target: RestoreMessageTarget) => {
|
||||
setRestoreConfirmTarget({ messageId, ...target })
|
||||
}, [])
|
||||
|
||||
const messageComponents = useMemo(
|
||||
() => ({
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
|
||||
UserMessage: () => (
|
||||
<UserMessage
|
||||
onCancel={onCancel}
|
||||
onRequestRestoreConfirm={onRestoreToMessage ? requestRestoreConfirm : undefined}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId]
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, requestRestoreConfirm, sessionId]
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
|
|
@ -214,6 +248,15 @@ export const Thread: FC<{
|
|||
/>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
<ThreadTimeline />
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.restoreConfirm}
|
||||
description={copy.restoreBody}
|
||||
destructive
|
||||
onClose={closeRestoreConfirm}
|
||||
onConfirm={confirmRestore}
|
||||
open={Boolean(restoreConfirmTarget)}
|
||||
title={copy.restoreTitle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -844,7 +887,7 @@ const USER_ACTION_ICON_BUTTON_CLASS =
|
|||
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||
|
||||
const USER_ACTION_ICON_SIZE = '0.6875rem'
|
||||
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
const StopGlyph = <StopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
|
||||
// Background-process notifications are injected into the conversation as user
|
||||
// messages (the agent must react to them, and message-role alternation forbids
|
||||
|
|
@ -884,11 +927,10 @@ const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
|
|||
|
||||
const UserMessage: FC<{
|
||||
onCancel?: () => Promise<void> | void
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
}> = ({ onCancel, onRestoreToMessage }) => {
|
||||
onRequestRestoreConfirm?: (messageId: string, target: RestoreMessageTarget) => void
|
||||
}> = ({ onCancel, onRequestRestoreConfirm }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
|
|
@ -906,6 +948,24 @@ const UserMessage: FC<{
|
|||
return null
|
||||
})
|
||||
|
||||
const runtimeUserOrdinal = useAuiState(s => {
|
||||
let ordinal = 0
|
||||
|
||||
for (const message of s.thread.messages) {
|
||||
if (message.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.id === s.message.id) {
|
||||
return ordinal
|
||||
}
|
||||
|
||||
ordinal += 1
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const attachmentRefs = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
|
||||
|
||||
|
|
@ -976,7 +1036,7 @@ const UserMessage: FC<{
|
|||
// Restore (re-run this exact prompt) is available everywhere the Stop button
|
||||
// isn't — including mid-stream on older prompts, since the action interrupts
|
||||
// the live turn before rewinding.
|
||||
const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody
|
||||
const showRestore = !showStop && Boolean(onRequestRestoreConfirm) && hasBody
|
||||
|
||||
const bubbleClassName = cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
|
|
@ -1001,7 +1061,6 @@ const UserMessage: FC<{
|
|||
return (
|
||||
<MessagePrimitive.Root asChild>
|
||||
<StickyHumanMessageContainer
|
||||
messageId={messageId}
|
||||
attachments={
|
||||
// Attachments live BELOW the sticky bubble in normal flow, so they
|
||||
// scroll away behind the pinned bubble instead of riding along with
|
||||
|
|
@ -1012,6 +1071,7 @@ const UserMessage: FC<{
|
|||
</div>
|
||||
) : null
|
||||
}
|
||||
messageId={messageId}
|
||||
>
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
|
|
@ -1054,7 +1114,14 @@ const UserMessage: FC<{
|
|||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
setRestoreConfirmOpen(true)
|
||||
onRequestRestoreConfirm?.(messageId, {
|
||||
text: messageText,
|
||||
userOrdinal: runtimeUserOrdinal
|
||||
})
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
|
|
@ -1088,17 +1155,6 @@ const UserMessage: FC<{
|
|||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
{showRestore && (
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.restoreConfirm}
|
||||
description={copy.restoreBody}
|
||||
destructive
|
||||
onClose={() => setRestoreConfirmOpen(false)}
|
||||
onConfirm={() => onRestoreToMessage?.(messageId)}
|
||||
open={restoreConfirmOpen}
|
||||
title={copy.restoreTitle}
|
||||
/>
|
||||
)}
|
||||
</StickyHumanMessageContainer>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1291,10 +1291,6 @@ function toolDetailLabel(toolName: string): string {
|
|||
return 'Snapshot summary'
|
||||
}
|
||||
|
||||
if (toolName === 'terminal' || toolName === 'execute_code') {
|
||||
return 'Command output'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-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.
|
||||
// than a chrome heading. Used for "stdout", "stderr", "Search results", 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
|
||||
|
|
@ -423,7 +423,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
|
||||
'group/tool-block min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
|
||||
open && TOOL_EXPANDED_SHELL_CLASS
|
||||
)}
|
||||
data-file-edit={isFileEdit && open ? '' : undefined}
|
||||
|
|
@ -472,7 +472,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
{copyAction.text && (
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-100 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md px-1 opacity-5 transition-opacity group-hover/tool-block:opacity-100 hover:opacity-100 focus-visible:opacity-100"
|
||||
iconClassName="size-3"
|
||||
label={copyAction.label}
|
||||
showLabel={false}
|
||||
|
|
@ -491,7 +491,9 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
<SearchResultsList hits={view.searchHits} />
|
||||
</div>
|
||||
)}
|
||||
{view.inlineDiff && <FileDiffPanel diff={view.inlineDiff} path={isFileEdit ? view.subtitle : undefined} />}
|
||||
{view.inlineDiff && (
|
||||
<FileDiffPanel className="-mt-1.5" diff={view.inlineDiff} path={isFileEdit ? view.subtitle : undefined} />
|
||||
)}
|
||||
{showDetail &&
|
||||
toolViewMode !== 'technical' &&
|
||||
(view.status === 'error' ? (
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useShikiHighlighter } from 'react-shiki'
|
||||
import type { ShikiTransformer } from 'shiki'
|
||||
import { type BundledLanguage, codeToTokens, type ShikiTransformer, type ThemedToken } from 'shiki'
|
||||
|
||||
import { chunkLines, type LineChunk, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import { exceedsHighlightBudget, SHIKI_THEME } from '@/components/chat/shiki-highlighter'
|
||||
import { shikiLanguageForFilename } from '@/lib/markdown-code'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -20,9 +21,20 @@ import { cn } from '@/lib/utils'
|
|||
*/
|
||||
type DiffKind = 'add' | 'context' | 'remove'
|
||||
|
||||
interface DiffLine {
|
||||
export interface DiffLine {
|
||||
kind: DiffKind
|
||||
text: string
|
||||
/** 1-based line number in the old/new file (absent on the "other" side of an
|
||||
* add/remove, and on hunk-separator blanks). Only used when line numbers are
|
||||
* shown (the preview's full diff). */
|
||||
newNo?: number
|
||||
oldNo?: number
|
||||
}
|
||||
|
||||
interface ParsedHunk {
|
||||
lines: Array<{ kind: DiffKind; text: string }>
|
||||
newStart: number
|
||||
oldStart: number
|
||||
}
|
||||
|
||||
// Tint + 2px gutter accent per change kind. Text color is included for the
|
||||
|
|
@ -41,12 +53,19 @@ const DIFF_KIND_TEXT: Record<DiffKind, string> = {
|
|||
}
|
||||
|
||||
const DIFF_LINE_BASE = 'block min-w-max whitespace-pre border-l-2 px-2.5 py-px'
|
||||
const PREVIEW_DIFF_LINE_BASE = 'block h-5 min-w-max whitespace-pre px-2.5 leading-5'
|
||||
const PREVIEW_CHUNK_LINES = 200
|
||||
const PREVIEW_LINE_PX = 20
|
||||
const PREVIEW_OVERSCAN_LINES = 400
|
||||
|
||||
// Bleed out of the tool-card body's `p-1.5` so tints/borders run flush to the
|
||||
// card edges (rounded corners clip via the card's overflow); compact height
|
||||
// with internal scroll like a code block.
|
||||
// `overscroll-y-auto` so reaching the box's top/bottom hands the wheel back to
|
||||
// the page (no scroll-trap); `overscroll-x-contain` keeps a trackpad's sideways
|
||||
// overscroll on long code lines from firing browser back/forward navigation.
|
||||
const DIFF_BOX_CLASS =
|
||||
'-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-contain font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)'
|
||||
'-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-x-contain overscroll-y-auto font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)'
|
||||
|
||||
function diffKind(line: string): DiffKind {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
|
|
@ -75,7 +94,16 @@ function stripDiffMarker(line: string): string {
|
|||
// arrow line. That preamble just repeats the path (which the tool row already
|
||||
// shows) and reads especially badly for absolute paths (`a//Users/…`). Strip
|
||||
// the leading header zone up to the first hunk.
|
||||
const DIFF_HEADER_PREFIXES = ['diff --git', 'index ', '--- ', '+++ ', 'similarity ', 'rename ', 'new file', 'deleted file']
|
||||
const DIFF_HEADER_PREFIXES = [
|
||||
'diff --git',
|
||||
'index ',
|
||||
'--- ',
|
||||
'+++ ',
|
||||
'similarity ',
|
||||
'rename ',
|
||||
'new file',
|
||||
'deleted file'
|
||||
]
|
||||
|
||||
function isArrowHeaderLine(line: string): boolean {
|
||||
const trimmed = line.trim()
|
||||
|
|
@ -105,23 +133,144 @@ export function stripDiffFileHeaders(diff: string): string {
|
|||
return lines.slice(start).join('\n')
|
||||
}
|
||||
|
||||
// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank
|
||||
// separator kept between hunks), markers stripped, kind recorded.
|
||||
function parseDiff(diff: string): DiffLine[] {
|
||||
const out: DiffLine[] = []
|
||||
let emitted = false
|
||||
function parseHunks(diff: string): ParsedHunk[] {
|
||||
const hunks: ParsedHunk[] = []
|
||||
let active: null | ParsedHunk = null
|
||||
|
||||
for (const line of stripDiffFileHeaders(diff).split('\n')) {
|
||||
if (line.startsWith('@@')) {
|
||||
if (emitted) {
|
||||
out.push({ kind: 'context', text: '' })
|
||||
const match = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
|
||||
|
||||
if (!match) {
|
||||
active = null
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
active = { oldStart: Number(match[1]), newStart: Number(match[2]), lines: [] }
|
||||
hunks.push(active)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out.push({ kind: diffKind(line), text: stripDiffMarker(line) })
|
||||
emitted = true
|
||||
if (!active || line.startsWith('\\')) {
|
||||
continue
|
||||
}
|
||||
|
||||
active.lines.push({ kind: diffKind(line), text: stripDiffMarker(line) })
|
||||
}
|
||||
|
||||
return hunks
|
||||
}
|
||||
|
||||
// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank
|
||||
// separator kept between hunks), markers stripped, kind recorded. Old/new line
|
||||
// numbers are tracked from each `@@ -a,b +c,d @@` header so a caller that wants
|
||||
// a gutter (the preview) can render them; the blank separator carries none.
|
||||
function parseDiff(diff: string): DiffLine[] {
|
||||
const hunks = parseHunks(diff)
|
||||
|
||||
if (hunks.length === 0) {
|
||||
// Fallback for unexpected non-hunk payloads.
|
||||
return stripDiffFileHeaders(diff)
|
||||
.split('\n')
|
||||
.map(line => ({ kind: diffKind(line), text: stripDiffMarker(line) }))
|
||||
}
|
||||
|
||||
const out: DiffLine[] = []
|
||||
let emitted = false
|
||||
let oldNo = 1
|
||||
let newNo = 1
|
||||
|
||||
for (const hunk of hunks) {
|
||||
oldNo = hunk.oldStart
|
||||
newNo = hunk.newStart
|
||||
|
||||
if (emitted) {
|
||||
out.push({ kind: 'context', text: '' })
|
||||
}
|
||||
|
||||
for (const line of hunk.lines) {
|
||||
const entry: DiffLine = { kind: line.kind, text: line.text }
|
||||
|
||||
if (line.kind === 'add') {
|
||||
entry.newNo = newNo++
|
||||
} else if (line.kind === 'remove') {
|
||||
entry.oldNo = oldNo++
|
||||
} else {
|
||||
entry.oldNo = oldNo++
|
||||
entry.newNo = newNo++
|
||||
}
|
||||
|
||||
out.push(entry)
|
||||
emitted = true
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Build a full-file diff view anchored to the CURRENT file text. Every current
|
||||
// line is emitted from `fullText` with its real new-file line number; hunks only
|
||||
// mark those rows as added and insert deleted rows between them. That keeps the
|
||||
// preview's SOURCE and DIFF views on the same line map even when git returns
|
||||
// compact hunks or removed-only rows.
|
||||
function parseFullFileDiff(diff: string, fullText: string): DiffLine[] {
|
||||
const hunks = parseHunks(diff)
|
||||
const fullLines = fullText.split('\n')
|
||||
|
||||
if (hunks.length === 0) {
|
||||
return fullLines.map((text, index) => ({ kind: 'context', newNo: index + 1, oldNo: index + 1, text }))
|
||||
}
|
||||
|
||||
const added = new Set<number>()
|
||||
const oldNoByNewNo = new Map<number, number>()
|
||||
const removalsByNewNo = new Map<number, DiffLine[]>()
|
||||
const out: DiffLine[] = []
|
||||
|
||||
for (const hunk of hunks) {
|
||||
let oldNo = hunk.oldStart
|
||||
let newNo = hunk.newStart
|
||||
|
||||
for (const line of hunk.lines) {
|
||||
if (line.kind === 'add') {
|
||||
added.add(newNo)
|
||||
newNo += 1
|
||||
} else if (line.kind === 'remove') {
|
||||
const anchor = Math.max(1, Math.min(newNo, fullLines.length + 1))
|
||||
const bucket = removalsByNewNo.get(anchor) ?? []
|
||||
|
||||
bucket.push({ kind: 'remove', oldNo, text: line.text })
|
||||
removalsByNewNo.set(anchor, bucket)
|
||||
oldNo += 1
|
||||
} else {
|
||||
oldNoByNewNo.set(newNo, oldNo)
|
||||
oldNo += 1
|
||||
newNo += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < fullLines.length; index += 1) {
|
||||
const newNo = index + 1
|
||||
const removals = removalsByNewNo.get(newNo)
|
||||
|
||||
if (removals) {
|
||||
out.push(...removals)
|
||||
}
|
||||
|
||||
out.push({
|
||||
kind: added.has(newNo) ? 'add' : 'context',
|
||||
newNo,
|
||||
oldNo: oldNoByNewNo.get(newNo),
|
||||
text: fullLines[index] ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
const trailingRemovals = removalsByNewNo.get(fullLines.length + 1)
|
||||
|
||||
if (trailingRemovals) {
|
||||
out.push(...trailingRemovals)
|
||||
}
|
||||
|
||||
return out
|
||||
|
|
@ -142,6 +291,159 @@ function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// shiki FontStyle is a bitmask: Italic=1, Bold=2, Underline=4.
|
||||
function tokenStyle({ bgColor, color, fontStyle = 0 }: ThemedToken): React.CSSProperties | undefined {
|
||||
if (!color && !bgColor && !fontStyle) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
color,
|
||||
fontStyle: fontStyle & 1 ? 'italic' : undefined,
|
||||
fontWeight: fontStyle & 2 ? 700 : undefined,
|
||||
textDecorationLine: fontStyle & 4 ? 'underline' : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function useThemeName() {
|
||||
const current = () => (document.documentElement.classList.contains('dark') ? SHIKI_THEME.dark : SHIKI_THEME.light)
|
||||
const [theme, setTheme] = React.useState(current)
|
||||
|
||||
React.useEffect(() => {
|
||||
const observer = new MutationObserver(() => setTheme(current()))
|
||||
|
||||
observer.observe(document.documentElement, { attributeFilter: ['class'], attributes: true })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
function PreviewDiffRows({
|
||||
afterLines = 0,
|
||||
beforeLines = 0,
|
||||
chunks,
|
||||
tokens
|
||||
}: {
|
||||
afterLines?: number
|
||||
beforeLines?: number
|
||||
chunks: Array<LineChunk<DiffLine>>
|
||||
tokens?: ThemedToken[][] | null
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{beforeLines > 0 && <div aria-hidden style={{ height: beforeLines * PREVIEW_LINE_PX }} />}
|
||||
{chunks.map(chunk => (
|
||||
<div className="block" key={chunk.start}>
|
||||
{chunk.lines.map((line, offset) => {
|
||||
const index = chunk.start + offset
|
||||
const rowTokens = tokens?.[index] ?? []
|
||||
|
||||
return (
|
||||
<span className={cn(PREVIEW_DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind])} key={`${index}-${line.text}`}>
|
||||
{rowTokens.length > 0
|
||||
? rowTokens.map((token, tokenIndex) => (
|
||||
<span key={`${tokenIndex}-${token.offset}`} style={tokenStyle(token)}>
|
||||
{token.content}
|
||||
</span>
|
||||
))
|
||||
: line.text || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{afterLines > 0 && <div aria-hidden style={{ height: afterLines * PREVIEW_LINE_PX }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TokenizedDiffBody({
|
||||
afterLines,
|
||||
beforeLines,
|
||||
chunked = false,
|
||||
chunks,
|
||||
language,
|
||||
lines
|
||||
}: {
|
||||
afterLines?: number
|
||||
beforeLines?: number
|
||||
chunked?: boolean
|
||||
chunks?: Array<LineChunk<DiffLine>>
|
||||
language: string
|
||||
lines: DiffLine[]
|
||||
}) {
|
||||
const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||||
const theme = useThemeName()
|
||||
const [tokens, setTokens] = React.useState<ThemedToken[][] | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
setTokens(null)
|
||||
void codeToTokens(code, { lang: language as BundledLanguage, theme })
|
||||
.then(result => {
|
||||
if (!cancelled) {
|
||||
setTokens(result.tokens)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setTokens([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code, language, theme])
|
||||
|
||||
if (!tokens) {
|
||||
return chunked ? (
|
||||
<PreviewDiffRows
|
||||
afterLines={afterLines}
|
||||
beforeLines={beforeLines}
|
||||
chunks={chunks ?? chunkLines(lines, PREVIEW_CHUNK_LINES)}
|
||||
/>
|
||||
) : (
|
||||
<DiffBody lines={lines} />
|
||||
)
|
||||
}
|
||||
|
||||
if (chunked) {
|
||||
return (
|
||||
<PreviewDiffRows
|
||||
afterLines={afterLines}
|
||||
beforeLines={beforeLines}
|
||||
chunks={chunks ?? chunkLines(lines, PREVIEW_CHUNK_LINES)}
|
||||
tokens={tokens}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, index) => {
|
||||
const rowTokens = tokens[index] ?? []
|
||||
|
||||
return (
|
||||
<span className={cn(PREVIEW_DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind])} key={`${index}-${line.text}`}>
|
||||
{rowTokens.length > 0
|
||||
? rowTokens.map((token, tokenIndex) => (
|
||||
<span key={`${tokenIndex}-${token.offset}`} style={tokenStyle(token)}>
|
||||
{token.content}
|
||||
</span>
|
||||
))
|
||||
: line.text || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Shiki transformer: tag each `.line` with the diff tint for its kind, so the
|
||||
// syntax-highlighted output keeps add/remove backgrounds + the gutter accent.
|
||||
function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer {
|
||||
|
|
@ -187,19 +489,164 @@ export function DiffLines({ className, text, ...props }: DiffLinesProps) {
|
|||
)
|
||||
}
|
||||
|
||||
interface FileDiffPanelProps {
|
||||
diff: string
|
||||
path?: string
|
||||
// Coalesce consecutive same-kind changed rows into runs, each placed by line
|
||||
// fraction (no DOM measurement). Context rows produce no tick.
|
||||
function overviewRuns(lines: DiffLine[]): { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] {
|
||||
const total = lines.length || 1
|
||||
const runs: { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] = []
|
||||
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const kind = lines[i].kind
|
||||
|
||||
if (kind === 'context') {
|
||||
i += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
let j = i + 1
|
||||
|
||||
while (j < lines.length && lines[j].kind === kind) {
|
||||
j += 1
|
||||
}
|
||||
|
||||
runs.push({ kind, sizePct: ((j - i) / total) * 100, startPct: (i / total) * 100 })
|
||||
i = j
|
||||
}
|
||||
|
||||
return runs
|
||||
}
|
||||
|
||||
export function FileDiffPanel({ diff, path }: FileDiffPanelProps) {
|
||||
const lines = React.useMemo(() => parseDiff(diff), [diff])
|
||||
const language = shikiLanguageForFilename(path)
|
||||
const canHighlight = Boolean(language) && !exceedsHighlightBudget(diff)
|
||||
// VS Code-style overview ruler: a thin strip pinned to the diff's right edge with
|
||||
// a green/red tick per change, positioned by line fraction. Pinned to the
|
||||
// viewport (not the scrolled content) by living as an absolute sibling of the
|
||||
// scroller inside a relative wrapper — so no scroll listener or measurement.
|
||||
function DiffOverviewRuler({ lines }: { lines: DiffLine[] }) {
|
||||
const runs = React.useMemo(() => overviewRuns(lines), [lines])
|
||||
|
||||
if (runs.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={DIFF_BOX_CLASS} data-slot="file-diff-panel">
|
||||
{canHighlight ? <SyntaxDiff language={language} lines={lines} /> : <DiffBody lines={lines} />}
|
||||
<div aria-hidden className="pointer-events-none absolute top-0 right-0 bottom-0 w-1.5 opacity-80">
|
||||
{/* Cap the tick field to the diff's natural height (rows × line px) so a
|
||||
short diff renders thin, line-aligned ticks instead of stretching a few
|
||||
changes into gross full-height blocks. A long diff hits the 100% cap and
|
||||
compresses into a true overview. */}
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: `min(100%, ${lines.length * PREVIEW_LINE_PX}px)` }}
|
||||
>
|
||||
{runs.map((run, index) => (
|
||||
<div
|
||||
className={cn('absolute inset-x-0', run.kind === 'add' ? 'bg-(--ui-green)' : 'bg-(--ui-red)')}
|
||||
key={index}
|
||||
style={{ height: `max(0.125rem, ${run.sizePct}%)`, top: `${run.startPct}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileDiffPanelProps {
|
||||
/** Override the default (tool-card) box styling — the full-height preview
|
||||
* cancels the bleed/clamp so the diff fills its pane. */
|
||||
className?: string
|
||||
diff: string
|
||||
/** Current file text. When provided, the panel expands hunked diffs into a
|
||||
* full-file view so unchanged lines are preserved between hunks. */
|
||||
fullText?: string
|
||||
path?: string
|
||||
/** Render an old/new line-number gutter (the full preview diff). The compact
|
||||
* tool-card + inline review diff leave this off. */
|
||||
showLineNumbers?: boolean
|
||||
}
|
||||
|
||||
export function FileDiffPanel({ className, diff, fullText, path, showLineNumbers = false }: FileDiffPanelProps) {
|
||||
const lines = React.useMemo(
|
||||
() => (fullText != null ? parseFullFileDiff(diff, fullText) : parseDiff(diff)),
|
||||
[diff, fullText]
|
||||
)
|
||||
|
||||
const lineChunks = React.useMemo(() => chunkLines(lines, PREVIEW_CHUNK_LINES), [lines])
|
||||
|
||||
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
|
||||
overscanRows: PREVIEW_OVERSCAN_LINES,
|
||||
rowPx: PREVIEW_LINE_PX,
|
||||
rowsPerChunk: PREVIEW_CHUNK_LINES,
|
||||
totalRows: lines.length
|
||||
})
|
||||
|
||||
const visibleLineChunks = lineChunks.slice(startChunk, endChunk + 1)
|
||||
|
||||
const language = shikiLanguageForFilename(path)
|
||||
const canHighlight = Boolean(language) && !exceedsHighlightBudget(fullText ?? diff)
|
||||
|
||||
// Full-file preview: we own the rows (tokens rendered inside) so blank lines
|
||||
// can't collapse. Compact tool/review diffs let Shiki own the rows.
|
||||
const body = !canHighlight ? (
|
||||
showLineNumbers ? (
|
||||
<PreviewDiffRows afterLines={afterRows} beforeLines={beforeRows} chunks={visibleLineChunks} />
|
||||
) : (
|
||||
<DiffBody lines={lines} />
|
||||
)
|
||||
) : fullText != null ? (
|
||||
<TokenizedDiffBody
|
||||
afterLines={afterRows}
|
||||
beforeLines={beforeRows}
|
||||
chunked={showLineNumbers}
|
||||
chunks={visibleLineChunks}
|
||||
language={language}
|
||||
lines={lines}
|
||||
/>
|
||||
) : (
|
||||
<SyntaxDiff language={language} lines={lines} />
|
||||
)
|
||||
|
||||
if (!showLineNumbers) {
|
||||
return (
|
||||
<div className={cn(DIFF_BOX_CLASS, className)} data-slot="file-diff-panel">
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A single line-number gutter (VS Code's inline-diff style): each row shows its
|
||||
// own file's number — the new number for context/adds, the old number for
|
||||
// removals — with an overview ruler pinned to the right edge. The inner div
|
||||
// owns the scroll so the ruler (an absolute sibling) stays viewport-fixed.
|
||||
return (
|
||||
<div className={cn(DIFF_BOX_CLASS, 'relative overflow-hidden', className)} data-slot="file-diff-panel">
|
||||
<div className="absolute inset-0 overflow-auto pr-2.5" onScroll={onScroll} ref={scrollerRef}>
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)]">
|
||||
<div className="sticky left-0 z-1 select-none bg-(--ui-editor-surface-background) py-3 text-muted-foreground/55">
|
||||
{beforeRows > 0 && (
|
||||
<div aria-hidden style={{ height: beforeRows * PREVIEW_LINE_PX }} />
|
||||
)}
|
||||
{visibleLineChunks.map(chunk => (
|
||||
<div className="block" key={chunk.start}>
|
||||
{chunk.lines.map((line, offset) => {
|
||||
const index = chunk.start + offset
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-5 w-9 pr-2 text-right leading-5 tabular-nums"
|
||||
key={`${index}-${line.oldNo}-${line.newNo}`}
|
||||
>
|
||||
{line.newNo ?? ''}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{afterRows > 0 && <div aria-hidden style={{ height: afterRows * PREVIEW_LINE_PX }} />}
|
||||
</div>
|
||||
<div className="min-w-0">{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
<DiffOverviewRuler lines={lines} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
155
apps/desktop/src/components/chat/fixed-row-window.ts
Normal file
155
apps/desktop/src/components/chat/fixed-row-window.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import type { RefObject, UIEvent } from 'react'
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
export interface LineChunk<T> {
|
||||
lines: T[]
|
||||
start: number
|
||||
}
|
||||
|
||||
export interface TextLineChunk extends LineChunk<string> {
|
||||
text: string
|
||||
}
|
||||
|
||||
interface FixedRowWindowOptions {
|
||||
overscanRows: number
|
||||
rowPx: number
|
||||
rowsPerChunk: number
|
||||
totalRows: number
|
||||
}
|
||||
|
||||
export interface FixedRowWindow {
|
||||
afterRows: number
|
||||
beforeRows: number
|
||||
endChunk: number
|
||||
onScroll: (event: UIEvent<HTMLDivElement>) => void
|
||||
scrollerRef: RefObject<HTMLDivElement | null>
|
||||
startChunk: number
|
||||
}
|
||||
|
||||
export function chunkLines<T>(lines: T[], perChunk: number): Array<LineChunk<T>> {
|
||||
if (lines.length <= perChunk) {
|
||||
return [{ lines, start: 0 }]
|
||||
}
|
||||
|
||||
const chunks: Array<LineChunk<T>> = []
|
||||
|
||||
for (let start = 0; start < lines.length; start += perChunk) {
|
||||
chunks.push({ lines: lines.slice(start, start + perChunk), start })
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
export function chunkTextLines(text: string, perChunk: number): TextLineChunk[] {
|
||||
return chunkLines(text.split('\n'), perChunk).map(chunk => ({
|
||||
...chunk,
|
||||
text: chunk.lines.join('\n')
|
||||
}))
|
||||
}
|
||||
|
||||
type ChunkWindow = Pick<FixedRowWindow, 'afterRows' | 'beforeRows' | 'endChunk' | 'startChunk'>
|
||||
|
||||
export function useFixedRowWindow({
|
||||
overscanRows,
|
||||
rowPx,
|
||||
rowsPerChunk,
|
||||
totalRows
|
||||
}: FixedRowWindowOptions): FixedRowWindow {
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
|
||||
// Derive the visible chunk window from a node's scroll geometry. Pure so we
|
||||
// can compare results and skip a re-render unless the window actually moved.
|
||||
const compute = useCallback(
|
||||
(node: HTMLDivElement | null): ChunkWindow => {
|
||||
const height = node?.clientHeight || 800
|
||||
const scrollTop = node?.scrollTop ?? 0
|
||||
const firstRow = Math.max(0, Math.floor(scrollTop / rowPx) - overscanRows)
|
||||
const lastRow = Math.min(totalRows, Math.ceil((scrollTop + height) / rowPx) + overscanRows)
|
||||
const startChunk = Math.floor(firstRow / rowsPerChunk)
|
||||
const endChunk = Math.max(startChunk, Math.floor(Math.max(firstRow, lastRow - 1) / rowsPerChunk))
|
||||
|
||||
return {
|
||||
afterRows: Math.max(0, totalRows - Math.min(totalRows, (endChunk + 1) * rowsPerChunk)),
|
||||
beforeRows: Math.min(totalRows, startChunk * rowsPerChunk),
|
||||
endChunk,
|
||||
startChunk
|
||||
}
|
||||
},
|
||||
[overscanRows, rowPx, rowsPerChunk, totalRows]
|
||||
)
|
||||
|
||||
const [win, setWin] = useState<ChunkWindow>(() => compute(null))
|
||||
|
||||
// Only commit a new window when a boundary is crossed — scrolling within the
|
||||
// current chunk span (the common case, every rAF) keeps the same object and
|
||||
// re-renders nothing.
|
||||
const sync = useCallback(
|
||||
(node: HTMLDivElement | null = scrollerRef.current) => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = compute(node)
|
||||
|
||||
setWin(prev =>
|
||||
prev.startChunk === next.startChunk &&
|
||||
prev.endChunk === next.endChunk &&
|
||||
prev.beforeRows === next.beforeRows &&
|
||||
prev.afterRows === next.afterRows
|
||||
? prev
|
||||
: next
|
||||
)
|
||||
},
|
||||
[compute]
|
||||
)
|
||||
|
||||
const cancelFrame = useCallback(() => {
|
||||
if (rafRef.current == null) {
|
||||
return
|
||||
}
|
||||
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}, [])
|
||||
|
||||
const onScroll = useCallback(
|
||||
(event: UIEvent<HTMLDivElement>) => {
|
||||
const node = event.currentTarget
|
||||
|
||||
cancelFrame()
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null
|
||||
sync(node)
|
||||
})
|
||||
},
|
||||
[cancelFrame, sync]
|
||||
)
|
||||
|
||||
// Re-sync on mount, on resize, and whenever the row geometry changes (new
|
||||
// file/diff → `compute` identity changes → effect re-runs).
|
||||
useLayoutEffect(() => {
|
||||
const node = scrollerRef.current
|
||||
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
sync(node)
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return cancelFrame
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => sync(node))
|
||||
|
||||
observer.observe(node)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
cancelFrame()
|
||||
}
|
||||
}, [cancelFrame, sync])
|
||||
|
||||
return { ...win, onScroll, scrollerRef }
|
||||
}
|
||||
47
apps/desktop/src/components/chat/skeletons.tsx
Normal file
47
apps/desktop/src/components/chat/skeletons.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// Shared loading skeletons for the file/git trees and diffs — quieter than a
|
||||
// spinner and shaped like the content that's about to land.
|
||||
|
||||
const TREE_ROWS: { indent: number; width: string }[] = [
|
||||
{ indent: 0, width: '55%' },
|
||||
{ indent: 1, width: '72%' },
|
||||
{ indent: 1, width: '46%' },
|
||||
{ indent: 0, width: '60%' },
|
||||
{ indent: 1, width: '52%' },
|
||||
{ indent: 2, width: '40%' },
|
||||
{ indent: 0, width: '64%' }
|
||||
]
|
||||
|
||||
/** Rows of icon + label bars, mimicking a file tree mid-load. */
|
||||
export function TreeSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 px-3 py-2.5" data-slot="tree-skeleton">
|
||||
{TREE_ROWS.map((row, index) => (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
key={`${index}-${row.width}`}
|
||||
style={{ paddingLeft: `${row.indent * 12}px` }}
|
||||
>
|
||||
<Skeleton className="size-3.5 shrink-0 rounded-[3px]" />
|
||||
<Skeleton className="h-3" style={{ width: row.width }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DIFF_ROWS: string[] = ['72%', '40%', '88%', '55%', '64%', '30%', '80%', '48%', '60%', '36%', '70%']
|
||||
|
||||
/** Stacked line bars, mimicking a unified diff mid-load. */
|
||||
export function DiffSkeleton({ style }: { style?: CSSProperties }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 px-3 py-2" data-slot="diff-skeleton" style={style}>
|
||||
{DIFF_ROWS.map((width, index) => (
|
||||
<Skeleton className="h-3" key={`${index}-${width}`} style={{ width }} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { type ReactNode } from 'react'
|
||||
import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -8,8 +8,10 @@ interface StatusRowProps {
|
|||
/** Leading glyph slot (spinner / status dot / selection circle). */
|
||||
leading?: ReactNode
|
||||
/** Makes the whole row activatable (adds `cursor-pointer` + keyboard a11y).
|
||||
* Trailing-slot buttons should `stopPropagation` so they don't also fire it. */
|
||||
onActivate?: () => void
|
||||
* Receives the originating event so consumers can branch on modifier keys
|
||||
* (e.g. ⌘/Ctrl-click). Trailing-slot buttons should `stopPropagation` so
|
||||
* they don't also fire it. */
|
||||
onActivate?: (event: KeyboardEvent | MouseEvent) => void
|
||||
/** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */
|
||||
trailing?: ReactNode
|
||||
trailingVisible?: boolean
|
||||
|
|
@ -43,7 +45,7 @@ export function StatusRow({
|
|||
? event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onActivate()
|
||||
onActivate(event)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ErrorBoundary } from './error-boundary'
|
||||
|
||||
// The real assistant-ui stale-index throw the root boundary must survive
|
||||
// (open chat / session switch render race), reproduced verbatim so the
|
||||
// recoverable-pattern match is exercised against the actual error text.
|
||||
const TAP_ERROR = 'tapClientLookup: Index 23 out of bounds (length: 18)'
|
||||
|
||||
// Throws purely from `box.error` so a render replay (React dev) throws
|
||||
// identically; the test mutates the box only from timers, never during render —
|
||||
// modelling a transient race that clears once the boundary remounts against
|
||||
// fresh state.
|
||||
function makeBomb(box: { error: Error | null }) {
|
||||
return function Bomb() {
|
||||
if (box.error) {
|
||||
throw box.error
|
||||
}
|
||||
|
||||
return <div>recovered</div>
|
||||
}
|
||||
}
|
||||
|
||||
const RELOAD_WINDOW = { name: 'Reload window', role: 'button' } as const
|
||||
|
||||
const countRecoverWarnings = (calls: unknown[][]) =>
|
||||
calls.filter(call => call.some(value => String(value).includes('auto-recovering from transient render error'))).length
|
||||
|
||||
describe('ErrorBoundary root auto-recovery', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('recovers the root boundary from a transient stale-index render race', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const box: { error: Error | null } = { error: new Error(TAP_ERROR) }
|
||||
const Bomb = makeBomb(box)
|
||||
|
||||
// Disarm before the scheduled next-tick reset re-renders the subtree, so the
|
||||
// race genuinely resolves on recovery instead of throwing forever.
|
||||
queueMicrotask(() => {
|
||||
box.error = null
|
||||
})
|
||||
|
||||
render(
|
||||
<ErrorBoundary label="root">
|
||||
<Bomb />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('recovered')).toBeTruthy())
|
||||
expect(screen.queryByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeNull()
|
||||
expect(countRecoverWarnings(warnSpy.mock.calls)).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('stops auto-recovering a persistent error after the cap and leaves the fallback up', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// Never disarmed: the boundary must not spin a reset -> throw -> reset loop
|
||||
// forever — it caps recovery and surfaces the fallback to the user.
|
||||
const box: { error: Error | null } = { error: new Error(TAP_ERROR) }
|
||||
const Bomb = makeBomb(box)
|
||||
|
||||
render(
|
||||
<ErrorBoundary label="root">
|
||||
<Bomb />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// The fallback showing up at all IS the cap working: with unbounded recovery
|
||||
// the boundary would reset -> throw -> reset forever and 'Reload window'
|
||||
// would never render (this waitFor would hang). The recovery attempts are
|
||||
// bounded by MAX_RECOVERIES (3), never an unbounded storm.
|
||||
await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy())
|
||||
const warnings = countRecoverWarnings(warnSpy.mock.calls)
|
||||
expect(warnings).toBeGreaterThanOrEqual(1)
|
||||
expect(warnings).toBeLessThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('does not auto-recover a non-root boundary', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const box: { error: Error | null } = { error: new Error(TAP_ERROR) }
|
||||
const Bomb = makeBomb(box)
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={() => <div>scoped-fallback</div>} label="thread">
|
||||
<Bomb />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('scoped-fallback')).toBeTruthy())
|
||||
expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0)
|
||||
})
|
||||
|
||||
it('does not auto-recover an unrecognized error even at the root', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const box: { error: Error | null } = { error: new Error('some unrelated application error') }
|
||||
const Bomb = makeBomb(box)
|
||||
|
||||
render(
|
||||
<ErrorBoundary label="root">
|
||||
<Bomb />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy())
|
||||
expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -20,31 +20,8 @@ interface ErrorBoundaryState {
|
|||
error: Error | null
|
||||
}
|
||||
|
||||
// assistant-ui can momentarily render a stale message index against a thread
|
||||
// that just shrank (session switch / teardown), throwing a render-race error
|
||||
// that latches the WHOLE app on the root "Reload window" screen. These throws
|
||||
// clear themselves on the next render against fresh state, so the root boundary
|
||||
// recovers itself once the storm settles instead of stranding the user.
|
||||
const RECOVERABLE_ERROR_PATTERNS = [
|
||||
/tapClientLookup: Index \d+\s+out of bounds \(length:\s*\d+\)/i,
|
||||
/Cannot read properties of undefined \(reading 'type'\)/i,
|
||||
/Tried to unmount a fiber that is already unmounted/i
|
||||
]
|
||||
|
||||
const isRecoverableDesktopRenderError = (error: Error): boolean =>
|
||||
RECOVERABLE_ERROR_PATTERNS.some(pattern => pattern.test(error.message))
|
||||
|
||||
// Bound auto-recovery so a *persistent* (non-transient) error can't spin the
|
||||
// boundary in a reset -> throw -> reset loop: at most MAX_RECOVERIES attempts
|
||||
// inside RECOVERY_WINDOW_MS, after which the fallback is left up for the user.
|
||||
const MAX_RECOVERIES = 3
|
||||
const RECOVERY_WINDOW_MS = 5_000
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { error: null }
|
||||
private recoverTimer: null | number = null
|
||||
private recoverCount = 0
|
||||
private recoverWindowStart = 0
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error }
|
||||
|
|
@ -54,55 +31,9 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
|
||||
console.error(tag, error, info.componentStack)
|
||||
this.props.onError?.(error, info)
|
||||
|
||||
if (this.props.label === 'root' && isRecoverableDesktopRenderError(error) && this.canRecover()) {
|
||||
console.warn(`${tag} auto-recovering from transient render error`, error.message)
|
||||
this.scheduleRecover()
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.clearRecoverTimer()
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.clearRecoverTimer()
|
||||
// A manual retry (button) starts a clean recovery budget.
|
||||
this.recoverCount = 0
|
||||
this.recoverWindowStart = 0
|
||||
this.setState({ error: null })
|
||||
}
|
||||
|
||||
// True while the boundary still has recovery budget. Each storm gets a fresh
|
||||
// window; auto-recovery (autoReset) deliberately does NOT reset the count, so
|
||||
// a tight reset -> throw loop is capped at MAX_RECOVERIES and then falls back.
|
||||
private canRecover(): boolean {
|
||||
const now = Date.now()
|
||||
|
||||
if (now - this.recoverWindowStart > RECOVERY_WINDOW_MS) {
|
||||
this.recoverWindowStart = now
|
||||
this.recoverCount = 0
|
||||
}
|
||||
|
||||
this.recoverCount += 1
|
||||
|
||||
return this.recoverCount <= MAX_RECOVERIES
|
||||
}
|
||||
|
||||
private clearRecoverTimer() {
|
||||
if (this.recoverTimer !== null) {
|
||||
window.clearTimeout(this.recoverTimer)
|
||||
this.recoverTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRecover() {
|
||||
this.clearRecoverTimer()
|
||||
this.recoverTimer = window.setTimeout(this.autoReset, 0)
|
||||
}
|
||||
|
||||
private autoReset = () => {
|
||||
this.recoverTimer = null
|
||||
this.setState({ error: null })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneHoverRevealSuppressed, $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
|
||||
import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
|
||||
|
||||
|
|
@ -38,6 +38,8 @@ export interface PaneProps {
|
|||
forceCollapsed?: boolean
|
||||
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
|
||||
hoverReveal?: boolean
|
||||
/** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */
|
||||
overlayWidth?: WidthValue
|
||||
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
|
||||
onOverlayActiveChange?: (overlayActive: boolean) => void
|
||||
id: string
|
||||
|
|
@ -227,7 +229,7 @@ export function PaneShell({ children, className, style }: PaneShellProps) {
|
|||
|
||||
return (
|
||||
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
|
||||
<div className={cn('relative grid h-full min-h-0', className)} style={composedStyle}>
|
||||
<div className={cn('relative grid h-full min-h-0', className)} data-pane-shell="" style={composedStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</PaneShellContext.Provider>
|
||||
|
|
@ -241,6 +243,7 @@ export function Pane({
|
|||
divider = false,
|
||||
disabled = false,
|
||||
hoverReveal = false,
|
||||
overlayWidth: overlayWidthProp,
|
||||
id,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
|
|
@ -250,7 +253,6 @@ export function Pane({
|
|||
}: PaneProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
const paneStates = useStore($paneStates)
|
||||
const hoverRevealSuppressed = useStore($paneHoverRevealSuppressed)
|
||||
const registered = useRef(false)
|
||||
const paneRef = useRef<HTMLDivElement | null>(null)
|
||||
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
|
||||
|
|
@ -263,7 +265,14 @@ export function Pane({
|
|||
// hover/focus instead of hiding them. Honors any persisted resize width.
|
||||
const overlayActive = !open && hoverReveal && !disabled
|
||||
const override = resizable ? paneStates[id]?.widthOverride : undefined
|
||||
const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH)
|
||||
// Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins,
|
||||
// else the persisted resize override, else the docked width.
|
||||
const overlayWidth =
|
||||
overlayWidthProp !== undefined
|
||||
? widthToCss(overlayWidthProp, DEFAULT_WIDTH)
|
||||
: override !== undefined
|
||||
? `${override}px`
|
||||
: widthToCss(width, DEFAULT_WIDTH)
|
||||
|
||||
useEffect(() => {
|
||||
if (registered.current) {
|
||||
|
|
@ -379,10 +388,8 @@ export function Pane({
|
|||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-30 [-webkit-app-region:no-drag]',
|
||||
hoverRevealSuppressed ? 'pointer-events-none' : 'pointer-events-auto'
|
||||
)}
|
||||
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
|
||||
data-pane-reveal-trigger=""
|
||||
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
|
||||
/>
|
||||
|
||||
|
|
@ -392,8 +399,7 @@ export function Pane({
|
|||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
|
||||
offscreen,
|
||||
!hoverRevealSuppressed &&
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
|
||||
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
|
||||
)}
|
||||
key={edge}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Icon } from '@tabler/icons-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -18,3 +19,13 @@ export function Codicon({ className, name, size, spinning, style, ...props }: Co
|
|||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrap a codicon as a Tabler-shaped icon for nav rows that expect `IconComponent`. */
|
||||
export function codiconIcon(name: string): Icon {
|
||||
function CodiconIcon({ className }: { className?: string }) {
|
||||
return <Codicon aria-hidden className={cn('leading-none', className)} name={name} size="1em" />
|
||||
}
|
||||
|
||||
CodiconIcon.displayName = `Codicon(${name})`
|
||||
return CodiconIcon as Icon
|
||||
}
|
||||
|
|
|
|||
50
apps/desktop/src/components/ui/color-swatches.tsx
Normal file
50
apps/desktop/src/components/ui/color-swatches.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Codicon } from './codicon'
|
||||
|
||||
interface ColorSwatchesProps {
|
||||
swatches: readonly string[]
|
||||
value: null | string
|
||||
onChange: (color: null | string) => void
|
||||
clearLabel: string
|
||||
clearIcon?: string
|
||||
swatchLabel?: (color: string) => string
|
||||
}
|
||||
|
||||
// Shared swatch grid + clear row used by the profile rail and the project
|
||||
// dialog, so color picking looks and behaves identically everywhere.
|
||||
export function ColorSwatches({
|
||||
swatches,
|
||||
value,
|
||||
onChange,
|
||||
clearLabel,
|
||||
clearIcon = 'circle-slash',
|
||||
swatchLabel
|
||||
}: ColorSwatchesProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{swatches.map(swatch => (
|
||||
<button
|
||||
aria-label={swatchLabel?.(swatch) ?? swatch}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
key={swatch}
|
||||
onClick={() => onChange(swatch)}
|
||||
style={{
|
||||
backgroundColor: swatch,
|
||||
boxShadow: swatch === value ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
|
||||
color: swatch
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => onChange(null)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={clearIcon} size="0.75rem" />
|
||||
{clearLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -175,7 +175,7 @@ function DialogTitle({
|
|||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
|
||||
// Pass a lucide icon to get the canonical dialog-header glyph: a plain
|
||||
// Pass an icon (from `@/lib/icons`) to get the canonical dialog-header glyph: a plain
|
||||
// primary-tinted icon inline with the title (no bg chip / ring). This is the
|
||||
// single source of truth for dialog header icons — don't hand-roll wrappers.
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
|
|
|
|||
52
apps/desktop/src/components/ui/diff-count.tsx
Normal file
52
apps/desktop/src/components/ui/diff-count.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { motion, useSpring, useTransform } from 'motion/react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Snappy spring — fast transitions per the design.
|
||||
const SPRING = { stiffness: 320, damping: 30, mass: 0.5 } as const
|
||||
|
||||
// A single integer that springs to its value via Motion (renders the motion
|
||||
// value straight to the DOM, no per-frame React re-render). It initialises AT
|
||||
// its value, so mounting/navigating shows it instantly — only a real change to
|
||||
// the number (a live edit) springs it up/down. Switching threads in the same
|
||||
// worktree (same numbers) therefore doesn't animate.
|
||||
function AnimatedInt({ value }: { value: number }) {
|
||||
const spring = useSpring(value, SPRING)
|
||||
const text = useTransform(spring, latest => Math.round(latest).toString())
|
||||
|
||||
useEffect(() => {
|
||||
spring.set(value)
|
||||
}, [value, spring])
|
||||
|
||||
return <motion.span>{text}</motion.span>
|
||||
}
|
||||
|
||||
interface DiffCountProps {
|
||||
added: number
|
||||
removed: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Animated `+A −B` line-count, green/red via the top-level theme vars. Each
|
||||
* number springs up/down via Motion (0 → value on first mount). */
|
||||
export function DiffCount({ added, removed, className }: DiffCountProps) {
|
||||
if (!added && !removed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn('flex shrink-0 items-center gap-1 tabular-nums', className)}>
|
||||
{added > 0 && (
|
||||
<span className="text-(--ui-green)">
|
||||
+<AnimatedInt value={added} />
|
||||
</span>
|
||||
)}
|
||||
{removed > 0 && (
|
||||
<span className="text-(--ui-red)">
|
||||
−<AnimatedInt value={removed} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,12 +7,18 @@ import { type ControlVariantProps, controlVariants } from './control'
|
|||
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
|
||||
return (
|
||||
<input
|
||||
// Off by default for every consumer — these are code/config/search fields,
|
||||
// not prose. Callers can re-enable per-instance by passing the prop.
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
controlVariants({ size }),
|
||||
'selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground',
|
||||
className
|
||||
)}
|
||||
data-slot="input"
|
||||
spellCheck={false}
|
||||
type={type}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitiv
|
|||
|
||||
function PopoverContent({
|
||||
align = 'center',
|
||||
// Keeps the arrow clear of the rounded corners (rounded-lg = 8px): Radix
|
||||
// clamps the arrow this far from each edge and shifts the popover to
|
||||
// compensate, so the arrow never jams into a corner on start/end alignment.
|
||||
arrowPadding = 12,
|
||||
children,
|
||||
className,
|
||||
collisionPadding = 8,
|
||||
sideOffset = 6,
|
||||
|
|
@ -26,17 +31,30 @@ function PopoverContent({
|
|||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
// Mirrors DropdownMenuContent: themed elevated surface, viewport-aware
|
||||
// (Radix flips/shifts off edges), with the standard open/close motion.
|
||||
arrowPadding={arrowPadding}
|
||||
// Themed glass surface, viewport-aware (Radix flips/shifts off edges),
|
||||
// standard open/close motion. Border-only (no shadow).
|
||||
className={cn(
|
||||
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-2 text-popover-foreground shadow-md backdrop-blur-md outline-hidden 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',
|
||||
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border border-(--ui-stroke-secondary) bg-[var(--popover-surface)] p-2 text-popover-foreground backdrop-blur-md outline-hidden 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 [--popover-surface:color-mix(in_srgb,var(--ui-bg-elevated)_92%,transparent)]',
|
||||
className
|
||||
)}
|
||||
collisionPadding={collisionPadding}
|
||||
data-slot="popover-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
{/* CSS arrow that truly inherits the surface: a rotated square sharing the
|
||||
body's exact bg + backdrop-blur (so it matches even through glass), with
|
||||
the border on its two outer edges only. Radix authors the child pointing
|
||||
"down" and rotates the wrapper per side, so the V always faces outward.
|
||||
The square's inner half tucks under the body, opening the border seam. */}
|
||||
<PopoverPrimitive.Arrow asChild height={7} width={16}>
|
||||
<span className="relative block h-[7px] w-4 overflow-visible">
|
||||
<span className="absolute top-0 left-1/2 size-[11px] -translate-x-1/2 -translate-y-1/2 rotate-45 border-r border-b border-(--ui-stroke-secondary) bg-[var(--popover-surface)] backdrop-blur-md" />
|
||||
</span>
|
||||
</PopoverPrimitive.Arrow>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
17
apps/desktop/src/components/ui/sanitized-input.tsx
Normal file
17
apps/desktop/src/components/ui/sanitized-input.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import { Input } from './input'
|
||||
|
||||
interface SanitizedInputProps extends Omit<React.ComponentProps<typeof Input>, 'onChange' | 'value'> {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
// A formatter from `@/lib/sanitize` (gitRef, slug, …) run on every keystroke.
|
||||
sanitize: (raw: string) => string
|
||||
}
|
||||
|
||||
// An <Input> that can only ever hold a valid value: every keystroke is run
|
||||
// through `sanitize`, so callers never have to validate-then-reject (a space in
|
||||
// a branch name becomes "-" as you type instead of erroring at submit).
|
||||
export function SanitizedInput({ value, onValueChange, sanitize, ...props }: SanitizedInputProps) {
|
||||
return <Input {...props} onChange={event => onValueChange(sanitize(event.target.value))} value={value} />
|
||||
}
|
||||
98
apps/desktop/src/components/ui/split-button.tsx
Normal file
98
apps/desktop/src/components/ui/split-button.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { buttonVariants } from './button';
|
||||
import { Button } from './button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './dropdown-menu'
|
||||
|
||||
export interface SplitButtonAction {
|
||||
id: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
interface SplitButtonProps {
|
||||
actions: SplitButtonAction[]
|
||||
/** The id of the action the primary button runs (the user's current default). */
|
||||
value: string
|
||||
/** Picking from the menu changes the default (so the next primary click repeats it). */
|
||||
onValueChange: (id: string) => void
|
||||
/** Run an action by id (primary click or menu pick both call this). */
|
||||
onTrigger: (id: string) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
/** Icon shown on the primary button only (e.g. a ✓ for Commit). */
|
||||
primaryIcon?: ReactNode
|
||||
variant?: VariantProps<typeof buttonVariants>['variant']
|
||||
size?: VariantProps<typeof buttonVariants>['size']
|
||||
}
|
||||
|
||||
/**
|
||||
* A primary action fused to a caret that opens alternates — VS Code's
|
||||
* Commit / Commit & Push pattern. The primary button runs `value`; picking a
|
||||
* menu item runs it AND makes it the new default, so the control adapts to how
|
||||
* the user works without a separate settings toggle.
|
||||
*/
|
||||
export function SplitButton({
|
||||
actions,
|
||||
value,
|
||||
onValueChange,
|
||||
onTrigger,
|
||||
disabled,
|
||||
className,
|
||||
primaryIcon,
|
||||
variant = 'secondary',
|
||||
size = 'sm'
|
||||
}: SplitButtonProps) {
|
||||
const active = actions.find(action => action.id === value) ?? actions[0]
|
||||
|
||||
if (!active) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex min-w-0', className)}>
|
||||
<Button
|
||||
className="min-w-0 flex-1 rounded-r-none"
|
||||
disabled={disabled}
|
||||
onClick={() => onTrigger(active.id)}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
{primaryIcon ?? active.icon}
|
||||
<span className="truncate">{active.label}</span>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="More actions"
|
||||
className="rounded-l-none border-l border-current/25 px-2"
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
<Codicon name="chevron-down" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
{actions.map(action => (
|
||||
<DropdownMenuItem
|
||||
key={action.id}
|
||||
onSelect={() => {
|
||||
onValueChange(action.id)
|
||||
onTrigger(action.id)
|
||||
}}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="flex-1 truncate">{action.label}</span>
|
||||
{action.id === value && <Codicon className="text-(--ui-text-tertiary)" name="check" size="0.75rem" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,7 +5,19 @@ import { cn } from '@/lib/utils'
|
|||
import { type ControlVariantProps, controlVariants } from './control'
|
||||
|
||||
function Textarea({ className, size, ...props }: React.ComponentProps<'textarea'> & ControlVariantProps) {
|
||||
return <textarea className={cn(controlVariants({ size }), 'min-h-16', className)} data-slot="textarea" {...props} />
|
||||
return (
|
||||
<textarea
|
||||
// Off by default for every consumer — these are code/config/prompt fields,
|
||||
// not prose. Callers can re-enable per-instance by passing the prop.
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(controlVariants({ size }), 'min-h-16', className)}
|
||||
data-slot="textarea"
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
|
|
|||
26
apps/desktop/src/hooks/use-delayed-true.ts
Normal file
26
apps/desktop/src/hooks/use-delayed-true.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Returns true only after `active` has stayed true continuously for `delayMs`.
|
||||
* Flips back to false the instant `active` goes false. Use it to gate loading
|
||||
* skeletons so a fast operation doesn't flash one — the UI just stays blank for
|
||||
* the (sub-perceptible) delay window, and the skeleton appears only when a load
|
||||
* is genuinely slow.
|
||||
*/
|
||||
export function useDelayedTrue(active: boolean, delayMs = 180): boolean {
|
||||
const [shown, setShown] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setShown(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setTimeout(() => setShown(true), delayMs)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [active, delayMs])
|
||||
|
||||
return shown
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { uniqueCwds, type WorktreeResolver } from '@/app/chat/sidebar/workspace-groups'
|
||||
import type { HermesWorktreeInfo } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { desktopFsCacheKey, desktopWorktrees } from '@/lib/desktop-fs'
|
||||
|
||||
type WorktreeMap = Record<string, HermesWorktreeInfo | null>
|
||||
|
||||
/**
|
||||
* Probe the local filesystem for the git-worktree identity of each session cwd
|
||||
* and return a resolver the grouping uses to build `parent → worktree`. Results
|
||||
* are cached per cwd (and reset when the backend connection changes), so a probe
|
||||
* runs once per directory. Unresolved cwds (probe pending, remote backend, or
|
||||
* non-git dirs) fall back to the path-name heuristic in `workspaceTreeFor`.
|
||||
*/
|
||||
export function useWorktreeInfo(sessions: SessionInfo[], enabled: boolean): WorktreeResolver {
|
||||
const [map, setMap] = useState<WorktreeMap>({})
|
||||
const cacheRef = useRef<{ data: WorktreeMap; key: string }>({ data: {}, key: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = desktopFsCacheKey()
|
||||
|
||||
if (cacheRef.current.key !== key) {
|
||||
cacheRef.current = { data: {}, key }
|
||||
setMap({})
|
||||
}
|
||||
|
||||
const missing = uniqueCwds(sessions).filter(cwd => !(cwd in cacheRef.current.data))
|
||||
|
||||
if (!missing.length) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void desktopWorktrees(missing)
|
||||
.then(result => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Record every probed cwd (null when absent) so we never re-probe it.
|
||||
const next: WorktreeMap = { ...cacheRef.current.data }
|
||||
|
||||
for (const cwd of missing) {
|
||||
next[cwd] = result[cwd] ?? null
|
||||
}
|
||||
|
||||
cacheRef.current = { data: next, key }
|
||||
setMap(next)
|
||||
})
|
||||
.catch(() => {
|
||||
// Bridge unavailable / probe failed — leave cwds unresolved so the
|
||||
// heuristic fallback handles them.
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [sessions, enabled])
|
||||
|
||||
return useMemo<WorktreeResolver>(() => (cwd: string) => map[cwd], [map])
|
||||
}
|
||||
|
|
@ -69,6 +69,9 @@ export type GatewayEventPayload = {
|
|||
count?: number
|
||||
// status.update (kind=process → background process completion/watch-match)
|
||||
kind?: string
|
||||
// session.title (live auto-title push) — stored session id + generated title
|
||||
session_id?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function textPart(text: string): ChatMessagePart {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import type {
|
|||
HermesConnection,
|
||||
HermesReadDirResult,
|
||||
HermesReadFileTextResult,
|
||||
HermesSelectPathsOptions,
|
||||
HermesWorktreeInfo
|
||||
HermesSelectPathsOptions
|
||||
} from '@/global'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
|
|
@ -21,6 +20,7 @@ function connectionCacheKey(connection: HermesConnection | null) {
|
|||
if (!connection) {
|
||||
return 'local:'
|
||||
}
|
||||
|
||||
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
|
||||
}
|
||||
|
||||
|
|
@ -38,61 +38,58 @@ function fsPath(endpoint: string, filePath: string) {
|
|||
|
||||
function bridge() {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop) {
|
||||
throw new Error('Hermes Desktop bridge is unavailable')
|
||||
}
|
||||
|
||||
return desktop
|
||||
}
|
||||
|
||||
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
|
||||
const desktop = bridge()
|
||||
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readDir(path)
|
||||
}
|
||||
|
||||
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
|
||||
}
|
||||
|
||||
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
|
||||
const desktop = bridge()
|
||||
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readFileText(path)
|
||||
}
|
||||
|
||||
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
|
||||
}
|
||||
|
||||
export async function readDesktopFileDataUrl(path: string): Promise<string> {
|
||||
const desktop = bridge()
|
||||
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readFileDataUrl(path)
|
||||
}
|
||||
|
||||
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
|
||||
|
||||
return typeof result === 'string' ? result : result.dataUrl || ''
|
||||
}
|
||||
|
||||
export async function desktopGitRoot(path: string): Promise<string | null> {
|
||||
const desktop = bridge()
|
||||
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.gitRoot ? desktop.gitRoot(path) : null
|
||||
}
|
||||
|
||||
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
|
||||
|
||||
return result.root
|
||||
}
|
||||
|
||||
// Worktree detection runs against the LOCAL filesystem (the electron main
|
||||
// process). For a remote backend the session cwds live on another machine, so
|
||||
// we can't resolve them here — callers fall back to the path-name heuristic.
|
||||
export async function desktopWorktrees(cwds: string[]): Promise<Record<string, HermesWorktreeInfo | null>> {
|
||||
if (isDesktopFsRemoteMode()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const desktop = bridge()
|
||||
|
||||
return desktop.worktrees ? desktop.worktrees(cwds) : {}
|
||||
}
|
||||
|
||||
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return null
|
||||
|
|
@ -101,13 +98,61 @@ export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string
|
|||
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
|
||||
}
|
||||
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files). Local only.
|
||||
export async function revealDesktopPath(path: string): Promise<void> {
|
||||
await bridge().revealPath?.(path)
|
||||
}
|
||||
|
||||
// Rename a file/folder in place; returns the new absolute path. Local only.
|
||||
export async function renameDesktopPath(path: string, newName: string): Promise<string> {
|
||||
const desktop = bridge()
|
||||
|
||||
if (!desktop.renamePath) {
|
||||
throw new Error('Rename is not available')
|
||||
}
|
||||
|
||||
const result = await desktop.renamePath(path, newName)
|
||||
|
||||
return result.path
|
||||
}
|
||||
|
||||
// Move a file/folder to the OS trash (recoverable). Local only.
|
||||
export async function trashDesktopPath(path: string): Promise<void> {
|
||||
const desktop = bridge()
|
||||
|
||||
if (!desktop.trashPath) {
|
||||
throw new Error('Delete is not available')
|
||||
}
|
||||
|
||||
await desktop.trashPath(path)
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
await bridge().writeClipboard(text)
|
||||
}
|
||||
|
||||
// Working-tree-vs-HEAD diff for one file. Empty when unchanged / not a repo /
|
||||
// remote backend (the diff view simply doesn't show then). Local only.
|
||||
export async function desktopFileDiff(repoRoot: string, filePath: string): Promise<string> {
|
||||
const desktop = bridge()
|
||||
|
||||
if (isDesktopFsRemoteMode() || !desktop.git?.fileDiff) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return desktop.git.fileDiff(repoRoot, filePath)
|
||||
}
|
||||
|
||||
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
|
||||
const desktop = bridge()
|
||||
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.selectPaths(options)
|
||||
}
|
||||
|
||||
if (!options?.directories || options.multiple !== false) {
|
||||
return []
|
||||
}
|
||||
|
||||
return remotePicker ? remotePicker.selectPaths(options) : []
|
||||
}
|
||||
|
|
|
|||
45
apps/desktop/src/lib/excluded-paths.ts
Normal file
45
apps/desktop/src/lib/excluded-paths.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Always hidden across the file tree and review (git) tree, regardless of
|
||||
// .gitignore: the VCS internals, heavyweight dep/build/cache dirs, and OS noise.
|
||||
// These bloat both trees and are never worth browsing or reviewing — even in
|
||||
// repos that track them, and in plain non-git folders.
|
||||
export const ALWAYS_EXCLUDED = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'node_modules',
|
||||
'bower_components',
|
||||
'.venv',
|
||||
'venv',
|
||||
'env',
|
||||
'__pycache__',
|
||||
'.mypy_cache',
|
||||
'.pytest_cache',
|
||||
'.ruff_cache',
|
||||
'.tox',
|
||||
'.gradle',
|
||||
'.idea',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'target',
|
||||
'vendor',
|
||||
'Pods',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.output',
|
||||
'.turbo',
|
||||
'.parcel-cache',
|
||||
'.cache',
|
||||
'.terraform',
|
||||
'.expo',
|
||||
'.angular',
|
||||
'coverage',
|
||||
'.DS_Store',
|
||||
'Thumbs.db'
|
||||
])
|
||||
|
||||
// True when any segment of a relative path is excluded (review rows like
|
||||
// `node_modules/.bin/foo` or a bare `.DS_Store`). Handles `/` and `\`.
|
||||
export const isExcludedPath = (relPath: string): boolean =>
|
||||
relPath.split(/[/\\]/).some(seg => ALWAYS_EXCLUDED.has(seg))
|
||||
|
|
@ -10,6 +10,8 @@ import {
|
|||
IconWaveSine as AudioLines,
|
||||
IconChartBar as BarChart3,
|
||||
IconBell as Bell,
|
||||
IconBookmark as Bookmark,
|
||||
IconBookmarkFilled as BookmarkFilled,
|
||||
IconBrain as Brain,
|
||||
IconBug as Bug,
|
||||
IconCheck as Check,
|
||||
|
|
@ -44,6 +46,7 @@ import {
|
|||
IconPhoto as ImageIcon,
|
||||
IconInfoCircle as Info,
|
||||
IconKey as KeyRound,
|
||||
IconLayoutDashboard as LayoutDashboard,
|
||||
IconLayersIntersect2 as Layers3,
|
||||
IconLink as Link,
|
||||
IconLink as Link2,
|
||||
|
|
@ -85,12 +88,13 @@ import {
|
|||
IconSettings as Settings,
|
||||
IconSettings2 as Settings2,
|
||||
IconAdjustmentsHorizontal as SlidersHorizontal,
|
||||
IconSparkles as Sparkles,
|
||||
IconSquare as Square,
|
||||
IconPlayerStopFilled as StopFilled,
|
||||
IconSteeringWheel as SteeringWheel,
|
||||
IconSun as Sun,
|
||||
IconTerminal2 as Terminal,
|
||||
IconTrash as Trash2,
|
||||
IconUpload as Upload,
|
||||
IconUsers as Users,
|
||||
IconVolume2 as Volume2,
|
||||
IconVolume2 as Volume2Icon,
|
||||
|
|
@ -115,6 +119,8 @@ export {
|
|||
AudioLines,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Bookmark,
|
||||
BookmarkFilled,
|
||||
Brain,
|
||||
Bug,
|
||||
Check,
|
||||
|
|
@ -149,6 +155,7 @@ export {
|
|||
ImageIcon,
|
||||
Info,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Layers3,
|
||||
Link,
|
||||
Link2,
|
||||
|
|
@ -190,12 +197,13 @@ export {
|
|||
Settings,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Square,
|
||||
StopFilled,
|
||||
SteeringWheel,
|
||||
Sun,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Upload,
|
||||
Users,
|
||||
Volume2,
|
||||
Volume2Icon,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
|||
...SESSION_SLOT_ACTIONS,
|
||||
{ id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] },
|
||||
{ id: 'session.togglePin', category: 'session', defaults: [] },
|
||||
// ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo.
|
||||
{ id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] },
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────
|
||||
{ id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] },
|
||||
|
|
@ -97,6 +99,8 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
|||
// ── View (layout + appearance + the shortcuts panel itself) ───────────────
|
||||
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
|
||||
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
|
||||
// ⌘G — "g" for git; the review pane is the source-control view.
|
||||
{ id: 'view.toggleReview', category: 'view', defaults: ['mod+g'] },
|
||||
{ id: 'view.showFiles', category: 'view', defaults: [] },
|
||||
{ id: 'view.showTerminal', category: 'view', defaults: TERMINAL_TOGGLE_DEFAULTS },
|
||||
// ⌘\ — the backslash reads like a mirror line flipping the layout.
|
||||
|
|
|
|||
58
apps/desktop/src/lib/oneshot.ts
Normal file
58
apps/desktop/src/lib/oneshot.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { $gateway } from '@/store/gateway'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
// Shared client for one-off ("one-shot") LLM requests: a single stateless model
|
||||
// call that runs OUTSIDE the conversation. It never appends to session history,
|
||||
// so prompt caching stays intact. Use it for small generative chores (commit
|
||||
// messages, rename ideas, summaries) where an agent turn would be wrong.
|
||||
//
|
||||
// Pair with a registered backend template (agent/oneshot.py PROMPT_TEMPLATES)
|
||||
// for reusable prompt engineering, or pass raw instructions/input ad hoc.
|
||||
|
||||
export interface OneShotRequest {
|
||||
/** Registered backend template id (e.g. 'commit_message'). */
|
||||
template?: string
|
||||
/** Variables for the template. */
|
||||
variables?: Record<string, unknown>
|
||||
/** Raw system prompt (used when no template is given). */
|
||||
instructions?: string
|
||||
/** Raw user content (used when no template is given). */
|
||||
input?: string
|
||||
/** Auxiliary task name for model routing (defaults backend-side). */
|
||||
task?: string
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
/**
|
||||
* Session whose model to inherit. Defaults to the active session so output
|
||||
* matches the model the user is coding with; pass null to force the
|
||||
* configured auxiliary backend instead.
|
||||
*/
|
||||
sessionId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a one-off request to Hermes and return the generated text.
|
||||
* Throws when the gateway is offline or the backend reports an error.
|
||||
*/
|
||||
export async function requestOneShot(req: OneShotRequest): Promise<string> {
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Gateway not connected')
|
||||
}
|
||||
|
||||
const sessionId = req.sessionId === undefined ? $activeSessionId.get() : req.sessionId
|
||||
|
||||
const result = await gateway.request<{ text?: string }>('llm.oneshot', {
|
||||
input: req.input,
|
||||
instructions: req.instructions,
|
||||
max_tokens: req.maxTokens,
|
||||
session_id: sessionId ?? undefined,
|
||||
task: req.task,
|
||||
temperature: req.temperature,
|
||||
template: req.template,
|
||||
variables: req.variables
|
||||
})
|
||||
|
||||
return (result?.text ?? '').trim()
|
||||
}
|
||||
78
apps/desktop/src/lib/persisted.ts
Normal file
78
apps/desktop/src/lib/persisted.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { atom, type WritableAtom } from 'nanostores'
|
||||
|
||||
import { readKey, writeKey } from './storage'
|
||||
|
||||
// A nanostore that auto-persists. Reads its seed from localStorage through the
|
||||
// storage choke point (so every read/write is observable in one place) and
|
||||
// writes back on every change — no per-atom subscribe boilerplate.
|
||||
//
|
||||
// export const $foo = persistentAtom('hermes.desktop.foo', false, Codecs.bool)
|
||||
|
||||
// Maps a value to/from its stored string form. `decode` only ever sees a real
|
||||
// stored string (absence falls back); `encode` returning null removes the key.
|
||||
export interface Codec<T> {
|
||||
decode(raw: string): T
|
||||
encode(value: T): null | string
|
||||
}
|
||||
|
||||
export const Codecs = {
|
||||
bool: { decode: raw => raw === 'true', encode: (value: boolean) => String(value) } as Codec<boolean>,
|
||||
nullableText: { decode: raw => raw, encode: value => value } as Codec<null | string>,
|
||||
text: { decode: raw => raw, encode: (value: string) => value } as Codec<string>,
|
||||
// Mirrors storedStringArray/persistStringArray: drops non-strings, empty → removed.
|
||||
stringArray: {
|
||||
decode: raw => {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
||||
: []
|
||||
},
|
||||
encode: value => (value.length === 0 ? null : JSON.stringify(value))
|
||||
} as Codec<string[]>,
|
||||
// Mirrors storedStringRecord/persistStringRecord: keeps only string values.
|
||||
stringRecord: {
|
||||
decode: raw => {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
||||
)
|
||||
},
|
||||
encode: value => JSON.stringify(value)
|
||||
} as Codec<Record<string, string>>,
|
||||
/** JSON with an optional sanitizer for untrusted persisted shapes. */
|
||||
json<T>(sanitize?: (value: unknown) => T): Codec<T> {
|
||||
return {
|
||||
decode: raw => {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
return sanitize ? sanitize(parsed) : (parsed as T)
|
||||
},
|
||||
encode: value => JSON.stringify(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function persistentAtom<T>(key: string, fallback: T, codec: Codec<T> = Codecs.json<T>()): WritableAtom<T> {
|
||||
const raw = readKey(key)
|
||||
let initial = fallback
|
||||
|
||||
if (raw !== null) {
|
||||
try {
|
||||
initial = codec.decode(raw)
|
||||
} catch {
|
||||
initial = fallback
|
||||
}
|
||||
}
|
||||
|
||||
const $value = atom<T>(initial)
|
||||
|
||||
$value.subscribe(value => writeKey(key, codec.encode(value)))
|
||||
|
||||
return $value
|
||||
}
|
||||
28
apps/desktop/src/lib/pool.test.ts
Normal file
28
apps/desktop/src/lib/pool.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapPool } from './pool'
|
||||
|
||||
describe('mapPool', () => {
|
||||
it('preserves input order regardless of completion order', async () => {
|
||||
const out = await mapPool([30, 10, 20], 3, async ms => {
|
||||
await new Promise(r => setTimeout(r, ms))
|
||||
return ms
|
||||
})
|
||||
|
||||
expect(out).toEqual([30, 10, 20])
|
||||
})
|
||||
|
||||
it('never exceeds the concurrency limit', async () => {
|
||||
let active = 0
|
||||
let peak = 0
|
||||
|
||||
await mapPool([...Array(10).keys()], 3, async () => {
|
||||
active += 1
|
||||
peak = Math.max(peak, active)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
active -= 1
|
||||
})
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
20
apps/desktop/src/lib/pool.ts
Normal file
20
apps/desktop/src/lib/pool.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* `Promise.all(items.map(fn))` with a concurrency cap: at most `limit` calls run
|
||||
* at once, results stay in input order. Keeps a many-repo probe from spawning a
|
||||
* `git` process per repo all at once.
|
||||
*/
|
||||
export async function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out = new Array<R>(items.length)
|
||||
let next = 0
|
||||
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next++
|
||||
out[i] = await fn(items[i])
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
|
||||
|
||||
return out
|
||||
}
|
||||
116
apps/desktop/src/lib/project-idea-templates.ts
Normal file
116
apps/desktop/src/lib/project-idea-templates.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// Fun starter ideas for the new-project dialog. Pills prefill IDEA.md; the set
|
||||
// shown is a random handful from this pool (reshuffled on open / via the dice),
|
||||
// so creating a project always feels a little playful. Pure content — edit
|
||||
// freely, order doesn't matter.
|
||||
|
||||
export interface ProjectIdeaTemplate {
|
||||
emoji: string
|
||||
label: string
|
||||
idea: string
|
||||
}
|
||||
|
||||
export const PROJECT_IDEA_TEMPLATES: ProjectIdeaTemplate[] = [
|
||||
{
|
||||
emoji: '🎮',
|
||||
label: 'Game jam',
|
||||
idea: 'A tiny browser game built in a weekend.\n\n- One core mechanic, juicy feedback\n- No build step — single HTML/JS file\n- Playable in under 60 seconds',
|
||||
},
|
||||
{
|
||||
emoji: '📚',
|
||||
label: 'Novel',
|
||||
idea: 'A novel-in-progress.\n\n- Track chapters, characters, and timeline\n- Daily word-count goal\n- Keep research notes beside the draft',
|
||||
},
|
||||
{
|
||||
emoji: '🤖',
|
||||
label: 'Discord bot',
|
||||
idea: 'A Discord bot for a small community.\n\n- Slash commands + a fun daily ritual\n- Lightweight persistence\n- Deploy somewhere free',
|
||||
},
|
||||
{
|
||||
emoji: '📊',
|
||||
label: 'Data viz',
|
||||
idea: 'An interactive visualization of a dataset I care about.\n\n- Pick the dataset and the one question it answers\n- Clean → chart → annotate\n- Shareable as a single page',
|
||||
},
|
||||
{
|
||||
emoji: '🎨',
|
||||
label: 'Generative art',
|
||||
idea: 'A generative art piece.\n\n- One algorithm, lots of seeds\n- Export high-res stills\n- A gallery of the best outputs',
|
||||
},
|
||||
{
|
||||
emoji: '🍳',
|
||||
label: 'Recipe box',
|
||||
idea: 'A personal recipe collection.\n\n- Searchable by ingredient and mood\n- Scale servings on the fly\n- Auto-build a shopping list',
|
||||
},
|
||||
{
|
||||
emoji: '🧪',
|
||||
label: 'Research log',
|
||||
idea: 'A research notebook for an open question.\n\n- Log experiments, results, and dead ends\n- Cite sources inline\n- Weekly synthesis of what I learned',
|
||||
},
|
||||
{
|
||||
emoji: '💸',
|
||||
label: 'Budget tracker',
|
||||
idea: 'A no-nonsense budget tracker.\n\n- Import transactions, tag them fast\n- Monthly burn vs. plan\n- One chart that tells the truth',
|
||||
},
|
||||
{
|
||||
emoji: '🌱',
|
||||
label: 'Habit tracker',
|
||||
idea: 'A habit tracker that actually sticks.\n\n- A handful of daily checkboxes\n- Streaks without guilt\n- A calm weekly review',
|
||||
},
|
||||
{
|
||||
emoji: '🗺️',
|
||||
label: 'Trip planner',
|
||||
idea: 'A trip planner for an upcoming adventure.\n\n- Day-by-day itinerary\n- Map of pins + notes\n- Packing + budget checklist',
|
||||
},
|
||||
{
|
||||
emoji: '🎵',
|
||||
label: 'Music toy',
|
||||
idea: 'A little music-making toy.\n\n- One instrument or sequencer\n- Web Audio, no installs\n- Record + share a loop',
|
||||
},
|
||||
{
|
||||
emoji: '🧩',
|
||||
label: 'Puzzle maker',
|
||||
idea: 'A generator for a puzzle I love.\n\n- Procedurally make solvable puzzles\n- Difficulty dial\n- Printable + playable',
|
||||
},
|
||||
{
|
||||
emoji: '📝',
|
||||
label: 'Digital garden',
|
||||
idea: 'A digital garden / personal wiki.\n\n- Atomic notes that link to each other\n- Grows over time, never "done"\n- Publish the public ones',
|
||||
},
|
||||
{
|
||||
emoji: '🛰️',
|
||||
label: 'API wrapper',
|
||||
idea: 'A clean wrapper around an API I keep reaching for.\n\n- Typed client + sensible defaults\n- One example per endpoint\n- Publish it',
|
||||
},
|
||||
{
|
||||
emoji: '🏋️',
|
||||
label: 'Workout plan',
|
||||
idea: 'A workout planner / logger.\n\n- Build a weekly split\n- Log sets fast on mobile\n- Track progress over months',
|
||||
},
|
||||
{
|
||||
emoji: '🧠',
|
||||
label: 'Flashcards',
|
||||
idea: 'A spaced-repetition flashcard app.\n\n- Quick card capture\n- Simple SM-2 scheduling\n- A daily review that fits in 5 minutes',
|
||||
},
|
||||
{
|
||||
emoji: '✍️',
|
||||
label: 'Screenplay',
|
||||
idea: 'A short screenplay.\n\n- Logline → beats → scenes\n- Proper format, distraction-free\n- A table read by the end',
|
||||
},
|
||||
{
|
||||
emoji: '🔭',
|
||||
label: 'Learn-by-building',
|
||||
idea: "A project to learn a thing I've been avoiding.\n\n- Smallest real thing that teaches it\n- Notes on every gotcha\n- A writeup when it works",
|
||||
},
|
||||
]
|
||||
|
||||
// A shuffled slice of the pool — the pills shown at any moment.
|
||||
export function randomIdeaTemplates(count = 6): ProjectIdeaTemplate[] {
|
||||
const pool = [...PROJECT_IDEA_TEMPLATES]
|
||||
|
||||
for (let i = pool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
|
||||
;[pool[i], pool[j]] = [pool[j], pool[i]]
|
||||
}
|
||||
|
||||
return pool.slice(0, Math.min(count, pool.length))
|
||||
}
|
||||
32
apps/desktop/src/lib/sanitize.test.ts
Normal file
32
apps/desktop/src/lib/sanitize.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { gitRef, slug } from './sanitize'
|
||||
|
||||
describe('gitRef', () => {
|
||||
it('turns spaces into hyphens and keeps slashes', () => {
|
||||
expect(gitRef('beach vibes')).toBe('beach-vibes')
|
||||
expect(gitRef('feat/cool thing')).toBe('feat/cool-thing')
|
||||
})
|
||||
|
||||
it('drops chars git refs forbid and collapses separators', () => {
|
||||
expect(gitRef('wip~^:?*[]')).toBe('wip')
|
||||
expect(gitRef('a b///c..d')).toBe('a-b/c.d')
|
||||
})
|
||||
|
||||
it('strips a leading separator but stays typeable (keeps a trailing one)', () => {
|
||||
expect(gitRef('/foo')).toBe('foo')
|
||||
expect(gitRef('feat/')).toBe('feat/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('slug', () => {
|
||||
it('lowercases and kebabs runs of non-alphanumerics', () => {
|
||||
expect(slug('My Profile')).toBe('my-profile')
|
||||
expect(slug('a__b c')).toBe('a-b-c')
|
||||
})
|
||||
|
||||
it('strips a leading separator but keeps a trailing one while typing', () => {
|
||||
expect(slug('--x')).toBe('x')
|
||||
expect(slug('work ')).toBe('work-')
|
||||
})
|
||||
})
|
||||
21
apps/desktop/src/lib/sanitize.ts
Normal file
21
apps/desktop/src/lib/sanitize.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Format enforcers for identifier-style inputs, applied live (per keystroke) via
|
||||
// <SanitizedInput>. They're intentionally lenient on a trailing separator so a
|
||||
// value stays typeable (e.g. "feat/" then keep going); the final trim happens on
|
||||
// submit / in the backend.
|
||||
|
||||
/** A git-ref-safe branch name: spaces → "-", drop chars git forbids, keep "/". */
|
||||
export const gitRef = (raw: string): string =>
|
||||
raw
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w./-]/g, '') // \w = [A-Za-z0-9_]
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/\/{2,}/g, '/')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[-./]+/, '')
|
||||
|
||||
/** A kebab slug: lowercase, runs of non-alphanumerics → a single "-". */
|
||||
export const slug = (raw: string): string =>
|
||||
raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+/, '')
|
||||
53
apps/desktop/src/lib/session-branch-tree.test.ts
Normal file
53
apps/desktop/src/lib/session-branch-tree.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { flattenSessionsWithBranches } from './session-branch-tree'
|
||||
|
||||
const session = (id: string, overrides: Partial<SessionInfo> = {}): SessionInfo =>
|
||||
({
|
||||
ended_at: null,
|
||||
id,
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 0,
|
||||
message_count: 1,
|
||||
model: null,
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
source: 'cli',
|
||||
started_at: 0,
|
||||
title: id,
|
||||
tool_call_count: 0,
|
||||
...overrides
|
||||
}) as SessionInfo
|
||||
|
||||
describe('flattenSessionsWithBranches', () => {
|
||||
it('nests branch rows under their parent with tree stems', () => {
|
||||
const parent = session('parent', { last_active: 20 })
|
||||
const branchA = session('branch-a', { last_active: 15, parent_session_id: 'parent' })
|
||||
const branchB = session('branch-b', { last_active: 10, parent_session_id: 'parent' })
|
||||
|
||||
expect(flattenSessionsWithBranches([parent, branchA, branchB])).toEqual([
|
||||
{ session: parent },
|
||||
{ branchStem: '├─ ', session: branchA },
|
||||
{ branchStem: '└─ ', session: branchB }
|
||||
])
|
||||
})
|
||||
|
||||
it('follows a compressed parent via lineage root id', () => {
|
||||
const tip = session('tip', { _lineage_root_id: 'root', last_active: 30 })
|
||||
const branch = session('branch', { parent_session_id: 'root', last_active: 10 })
|
||||
|
||||
expect(flattenSessionsWithBranches([tip, branch])).toEqual([
|
||||
{ session: tip },
|
||||
{ branchStem: '└─ ', session: branch }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps orphan branches at the top level when the parent is missing', () => {
|
||||
const branch = session('branch', { parent_session_id: 'missing' })
|
||||
|
||||
expect(flattenSessionsWithBranches([branch])).toEqual([{ session: branch }])
|
||||
})
|
||||
})
|
||||
100
apps/desktop/src/lib/session-branch-tree.ts
Normal file
100
apps/desktop/src/lib/session-branch-tree.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
export interface SidebarSessionEntry {
|
||||
branchStem?: string
|
||||
session: SessionInfo
|
||||
}
|
||||
|
||||
const recency = (session: SessionInfo): number => session.last_active || session.started_at || 0
|
||||
|
||||
/** Flat list with branch/fork sessions nested visually under their parent. */
|
||||
export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): SidebarSessionEntry[] {
|
||||
if (sessions.length < 2) {
|
||||
return sessions.map(session => ({ session }))
|
||||
}
|
||||
|
||||
const byVisibleId = new Map<string, SessionInfo>()
|
||||
for (const session of sessions) {
|
||||
byVisibleId.set(session.id, session)
|
||||
const rootId = session._lineage_root_id?.trim()
|
||||
if (rootId) {
|
||||
byVisibleId.set(rootId, session)
|
||||
}
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string, SessionInfo[]>()
|
||||
const nestedIds = new Set<string>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const parentId = session.parent_session_id?.trim()
|
||||
if (!parentId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parent = byVisibleId.get(parentId)
|
||||
if (!parent || parent.id === session.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
nestedIds.add(session.id)
|
||||
const siblings = childrenByParent.get(parent.id) ?? []
|
||||
siblings.push(session)
|
||||
childrenByParent.set(parent.id, siblings)
|
||||
}
|
||||
|
||||
for (const siblings of childrenByParent.values()) {
|
||||
siblings.sort((left, right) => recency(right) - recency(left))
|
||||
}
|
||||
|
||||
// A group sorts by its freshest member, so activity on any branch lifts the
|
||||
// whole parent→branches cluster together instead of stranding the parent at
|
||||
// its own stale timestamp. Memoized — each subtree is folded at most once.
|
||||
const groupRecencyMemo = new Map<string, number>()
|
||||
const groupRecency = (session: SessionInfo): number => {
|
||||
const cached = groupRecencyMemo.get(session.id)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
groupRecencyMemo.set(session.id, recency(session)) // cycle guard
|
||||
const max = (childrenByParent.get(session.id) ?? []).reduce(
|
||||
(acc, child) => Math.max(acc, groupRecency(child)),
|
||||
recency(session)
|
||||
)
|
||||
groupRecencyMemo.set(session.id, max)
|
||||
|
||||
return max
|
||||
}
|
||||
|
||||
// Depth-first so a branch-of-a-branch still renders under its own parent. The
|
||||
// `seen` set guards against pathological parent cycles, and the trailing sweep
|
||||
// emits anything the walk somehow missed — nothing in the input is ever dropped.
|
||||
const out: SidebarSessionEntry[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const emit = (session: SessionInfo, branchStem?: string) => {
|
||||
if (seen.has(session.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(session.id)
|
||||
out.push(branchStem ? { branchStem, session } : { session })
|
||||
|
||||
const children = childrenByParent.get(session.id)
|
||||
children?.forEach((child, index) => emit(child, index === children.length - 1 ? '└─ ' : '├─ '))
|
||||
}
|
||||
|
||||
sessions
|
||||
.filter(session => !nestedIds.has(session.id))
|
||||
.map((session, index) => ({ index, session }))
|
||||
.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index)
|
||||
.forEach(({ session }) => emit(session))
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!seen.has(session.id)) {
|
||||
out.push({ session })
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,30 +1,48 @@
|
|||
export function storedBoolean(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const value = window.localStorage.getItem(key)
|
||||
// ── Persistence choke point ─────────────────────────────────────────────────
|
||||
// Every persisted read/write in the app funnels through readKey/writeKey, so a
|
||||
// single subscriber (telemetry, cross-window sync, an audit log) can observe all
|
||||
// of it without instrumenting each call site. No listeners by default → no cost.
|
||||
|
||||
return value === null ? fallback : value === 'true'
|
||||
} catch {
|
||||
return fallback
|
||||
export interface PersistenceEvent {
|
||||
key: string
|
||||
op: 'read' | 'remove' | 'write'
|
||||
value: null | string
|
||||
}
|
||||
|
||||
type PersistenceListener = (event: PersistenceEvent) => void
|
||||
|
||||
const persistenceListeners = new Set<PersistenceListener>()
|
||||
|
||||
/** Observe every persisted get/set (e.g. pipe into telemetry/sync). */
|
||||
export function onPersistenceEvent(listener: PersistenceListener): () => void {
|
||||
persistenceListeners.add(listener)
|
||||
|
||||
return () => void persistenceListeners.delete(listener)
|
||||
}
|
||||
|
||||
function emitPersistence(event: PersistenceEvent) {
|
||||
for (const listener of persistenceListeners) {
|
||||
listener(event)
|
||||
}
|
||||
}
|
||||
|
||||
export function persistBoolean(key: string, value: boolean) {
|
||||
/** Raw read. Returns null when absent or storage is unavailable. */
|
||||
export function readKey(key: string): null | string {
|
||||
let value: null | string = null
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(key, String(value))
|
||||
value = window.localStorage.getItem(key)
|
||||
} catch {
|
||||
// Local storage is a convenience; ignore failures in restricted contexts.
|
||||
// Restricted contexts (private mode, disabled storage) read as absent.
|
||||
}
|
||||
|
||||
emitPersistence({ key, op: 'read', value })
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export function storedString(key: string): null | string {
|
||||
try {
|
||||
return window.localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function persistString(key: string, value: null | string) {
|
||||
/** Raw write. A null value removes the key. Best-effort. */
|
||||
export function writeKey(key: string, value: null | string) {
|
||||
try {
|
||||
if (value === null) {
|
||||
window.localStorage.removeItem(key)
|
||||
|
|
@ -32,18 +50,38 @@ export function persistString(key: string, value: null | string) {
|
|||
window.localStorage.setItem(key, value)
|
||||
}
|
||||
} catch {
|
||||
// Storage is best-effort.
|
||||
// Storage is best-effort; never let a quota/permission error break the UI.
|
||||
}
|
||||
|
||||
emitPersistence({ key, op: value === null ? 'remove' : 'write', value })
|
||||
}
|
||||
|
||||
export function storedBoolean(key: string, fallback: boolean): boolean {
|
||||
const value = readKey(key)
|
||||
|
||||
return value === null ? fallback : value === 'true'
|
||||
}
|
||||
|
||||
export function persistBoolean(key: string, value: boolean) {
|
||||
writeKey(key, String(value))
|
||||
}
|
||||
|
||||
export function storedString(key: string): null | string {
|
||||
return readKey(key)
|
||||
}
|
||||
|
||||
export function persistString(key: string, value: null | string) {
|
||||
writeKey(key, value)
|
||||
}
|
||||
|
||||
export function storedStringArray(key: string): string[] {
|
||||
const value = readKey(key)
|
||||
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const value = window.localStorage.getItem(key)
|
||||
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
|
|
@ -57,25 +95,17 @@ export function storedStringArray(key: string): string[] {
|
|||
}
|
||||
|
||||
export function persistStringArray(key: string, value: string[]) {
|
||||
try {
|
||||
if (value.length === 0) {
|
||||
window.localStorage.removeItem(key)
|
||||
} else {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
} catch {
|
||||
// Pins are a local preference; restricted storage should not break chat.
|
||||
}
|
||||
writeKey(key, value.length === 0 ? null : JSON.stringify(value))
|
||||
}
|
||||
|
||||
export function storedStringRecord(key: string): Record<string, string> {
|
||||
const value = readKey(key)
|
||||
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const value = window.localStorage.getItem(key)
|
||||
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
|
|
@ -91,11 +121,7 @@ export function storedStringRecord(key: string): Record<string, string> {
|
|||
}
|
||||
|
||||
export function persistStringRecord(key: string, value: Record<string, string>) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
// Local preference; restricted storage should not break the app.
|
||||
}
|
||||
writeKey(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
export function arraysEqual(left: string[], right: string[]) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue