Group recents as parent-repo → worktree → sessions using local git metadata (probed over IPC, with a path-name heuristic fallback for remote backends). Single-worktree repos collapse to one level. Sessions order by creation time and never reshuffle on new messages. Also: fuse the status stack to the composer border, restore icon actions in the queue panel, fix sidebar label truncation and drag styling, hide sticky-message attachments while pinned, and bump the terminal font.
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import type * as React from 'react'
|
|
|
|
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
|
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Codicon } from '@/components/ui/codicon'
|
|
import { Tip } from '@/components/ui/tooltip'
|
|
import type { SessionInfo } from '@/hermes'
|
|
import { type Translations, useI18n } from '@/i18n'
|
|
import { sessionTitle } from '@/lib/chat-runtime'
|
|
import { triggerHaptic } from '@/lib/haptics'
|
|
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
|
import { cn } from '@/lib/utils'
|
|
import { $attentionSessionIds } from '@/store/session'
|
|
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
|
|
|
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
|
|
|
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
|
session: SessionInfo
|
|
isPinned: boolean
|
|
isSelected: boolean
|
|
isWorking: boolean
|
|
onArchive: () => void
|
|
onDelete: () => void
|
|
onPin: () => void
|
|
onResume: () => void
|
|
reorderable?: boolean
|
|
dragging?: boolean
|
|
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
|
}
|
|
|
|
const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [
|
|
[86_400_000, 'ageDay'],
|
|
[3_600_000, 'ageHour'],
|
|
[60_000, 'ageMin']
|
|
]
|
|
|
|
function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
|
|
const delta = Math.max(0, Date.now() - seconds * 1000)
|
|
|
|
for (const [ms, key] of AGE_TICKS) {
|
|
if (delta >= ms) {
|
|
return `${Math.floor(delta / ms)}${r[key]}`
|
|
}
|
|
}
|
|
|
|
return r.ageNow
|
|
}
|
|
|
|
export function SidebarSessionRow({
|
|
session,
|
|
isPinned,
|
|
isSelected,
|
|
isWorking,
|
|
onArchive,
|
|
onDelete,
|
|
onPin,
|
|
onResume,
|
|
reorderable = false,
|
|
dragging = false,
|
|
dragHandleProps,
|
|
className,
|
|
style,
|
|
ref,
|
|
...rest
|
|
}: SidebarSessionRowProps) {
|
|
const { t } = useI18n()
|
|
const r = t.sidebar.row
|
|
const title = sessionTitle(session)
|
|
const age = formatAge(session.last_active || session.started_at, r)
|
|
const handleLabel = `Reorder ${title}`
|
|
// A handed-off session's live source is local, but it originated on a
|
|
// messaging platform — surface that origin as a small badge so e.g. a
|
|
// Telegram thread continued here still reads as Telegram.
|
|
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
|
|
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
|
|
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
|
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
|
// session is waiting on the user.
|
|
const needsInput = useStore($attentionSessionIds).includes(session.id)
|
|
|
|
return (
|
|
<SessionContextMenu
|
|
onArchive={onArchive}
|
|
onDelete={onDelete}
|
|
onPin={onPin}
|
|
pinned={isPinned}
|
|
profile={session.profile}
|
|
sessionId={session.id}
|
|
title={title}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
|
|
isSelected && 'bg-(--ui-row-active-background)',
|
|
isWorking && 'text-foreground',
|
|
// Opaque surface while lifted so the dragged row erases what's under
|
|
// it (translucency let the rows below bleed through).
|
|
dragging && 'z-10 cursor-grabbing bg-(--ui-sidebar-surface-background)',
|
|
className
|
|
)}
|
|
data-working={isWorking ? 'true' : undefined}
|
|
draggable
|
|
onDragStart={event => {
|
|
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
|
|
// native drag so the two DnD systems don't fight.
|
|
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
|
|
event.preventDefault()
|
|
|
|
return
|
|
}
|
|
|
|
writeSessionDrag(event.dataTransfer, {
|
|
id: session.id,
|
|
profile: session.profile || 'default',
|
|
title
|
|
})
|
|
}}
|
|
ref={ref}
|
|
style={style}
|
|
{...rest}
|
|
>
|
|
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
|
|
<button
|
|
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
|
|
onClick={event => {
|
|
if (event.shiftKey) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
triggerHaptic('selection')
|
|
onPin()
|
|
|
|
return
|
|
}
|
|
|
|
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
|
|
// window — the universal "open in a new window" gesture. Archive
|
|
// lives in the row's ⋯ and right-click menus. Falls through to a
|
|
// normal resume when standalone windows aren't available (web embed).
|
|
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
triggerHaptic('selection')
|
|
void openSessionInNewWindow(session.id)
|
|
|
|
return
|
|
}
|
|
|
|
onResume()
|
|
}}
|
|
type="button"
|
|
>
|
|
{reorderable ? (
|
|
<span
|
|
{...dragHandleProps}
|
|
aria-label={handleLabel}
|
|
className={cn(
|
|
// Scope the dot↔grabber swap to a local group so the grabber
|
|
// only reveals when hovering/focusing the handle itself, not
|
|
// anywhere on the row. Width MUST match the non-reorderable dot
|
|
// column (w-3.5) so rows don't shift horizontally when reorder is
|
|
// toggled (e.g. scoped → ALL-profiles view).
|
|
'group/handle relative -my-0.5 grid w-3.5 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
|
|
// The quest-glow box-shadow extends past the dot; let it bleed
|
|
// out instead of being clipped by this handle's overflow-hidden.
|
|
needsInput && 'overflow-visible'
|
|
)}
|
|
data-reorder-handle
|
|
onClick={event => event.stopPropagation()}
|
|
>
|
|
<SidebarRowDot
|
|
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
|
isWorking={isWorking}
|
|
needsInput={needsInput}
|
|
/>
|
|
<Codicon
|
|
className={cn(
|
|
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
|
|
dragging && 'text-(--ui-text-secondary) opacity-100'
|
|
)}
|
|
name="grabber"
|
|
size="0.75rem"
|
|
/>
|
|
</span>
|
|
) : (
|
|
<span
|
|
className={cn(
|
|
'grid w-3.5 shrink-0 place-items-center',
|
|
needsInput ? 'overflow-visible' : 'overflow-hidden'
|
|
)}
|
|
>
|
|
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
|
</span>
|
|
)}
|
|
{handoffSource && handoffLabel ? (
|
|
<Tip label={r.handoffOrigin(handoffLabel)}>
|
|
<PlatformAvatar
|
|
className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
|
|
platformId={handoffSource}
|
|
platformName={handoffLabel}
|
|
/>
|
|
</Tip>
|
|
) : null}
|
|
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
|
{title}
|
|
</span>
|
|
</button>
|
|
<div className="relative z-2 grid w-[1.375rem] place-items-center">
|
|
{!isWorking && (
|
|
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
|
|
{age}
|
|
</span>
|
|
)}
|
|
<SessionActionsMenu
|
|
onArchive={onArchive}
|
|
onDelete={onDelete}
|
|
onPin={onPin}
|
|
pinned={isPinned}
|
|
profile={session.profile}
|
|
sessionId={session.id}
|
|
title={title}
|
|
>
|
|
<Button
|
|
aria-label={r.actionsFor(title)}
|
|
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
|
size="icon"
|
|
title={r.sessionActions}
|
|
variant="ghost"
|
|
>
|
|
<Codicon name="ellipsis" size="0.875rem" />
|
|
</Button>
|
|
</SessionActionsMenu>
|
|
</div>
|
|
</div>
|
|
</SessionContextMenu>
|
|
)
|
|
}
|
|
|
|
function SidebarRowDot({
|
|
isWorking,
|
|
needsInput = false,
|
|
className
|
|
}: {
|
|
isWorking: boolean
|
|
needsInput?: boolean
|
|
className?: string
|
|
}) {
|
|
const { t } = useI18n()
|
|
const r = t.sidebar.row
|
|
|
|
// "Needs input" wins over "working": a clarify-blocked session is technically
|
|
// still running, but the actionable state is that it's waiting on the user.
|
|
// Amber + steady (no ping) reads as "your turn", distinct from the accent
|
|
// pulse of an active turn.
|
|
if (needsInput) {
|
|
return (
|
|
<span
|
|
aria-label={r.needsInput}
|
|
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
|
|
role="status"
|
|
title={r.waitingForAnswer}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<span
|
|
aria-label={isWorking ? r.sessionRunning : undefined}
|
|
className={cn(
|
|
'rounded-full',
|
|
isWorking
|
|
? "relative size-1.5 bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] before:absolute before:inset-0 before:animate-ping before:rounded-full before:bg-(--ui-accent) before:opacity-70 before:content-['']"
|
|
: 'size-1 bg-(--ui-text-quaternary) opacity-80',
|
|
className
|
|
)}
|
|
role={isWorking ? 'status' : undefined}
|
|
/>
|
|
)
|
|
}
|