584 lines
22 KiB
TypeScript
584 lines
22 KiB
TypeScript
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
|
|
import { Loader2, Pencil, Trash2 } from "lucide-solid"
|
|
|
|
import type { Instance } from "../types/instance"
|
|
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading, renameSession } from "../stores/sessions"
|
|
import InstanceInfo from "./instance-info"
|
|
import Kbd from "./kbd"
|
|
import SessionRenameDialog from "./session-rename-dialog"
|
|
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
|
import { isMac } from "../lib/keyboard-utils"
|
|
import { showToastNotification } from "../lib/notifications"
|
|
import { useI18n } from "../lib/i18n"
|
|
import { getLogger } from "../lib/logger"
|
|
const log = getLogger("actions")
|
|
|
|
|
|
|
|
interface InstanceWelcomeViewProps {
|
|
instance: Instance
|
|
}
|
|
|
|
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|
const { t } = useI18n()
|
|
const [isCreating, setIsCreating] = createSignal(false)
|
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
|
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
|
const [showInstanceInfoOverlay, setShowInstanceInfoOverlay] = createSignal(false)
|
|
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
|
|
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
|
|
)
|
|
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
|
const [isRenaming, setIsRenaming] = createSignal(false)
|
|
|
|
const parentSessions = () => getParentSessions(props.instance.id)
|
|
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
|
|
const isSessionDeleting = (sessionId: string) => {
|
|
const deleting = loading().deletingSession.get(props.instance.id)
|
|
return deleting ? deleting.has(sessionId) : false
|
|
}
|
|
const newSessionShortcut = createMemo<KeyboardShortcut>(() => {
|
|
const registered = keyboardRegistry.get("session-new")
|
|
if (registered) return registered
|
|
return {
|
|
id: "session-new-display",
|
|
key: "n",
|
|
modifiers: {
|
|
shift: true,
|
|
meta: isMac(),
|
|
ctrl: !isMac(),
|
|
},
|
|
handler: () => {},
|
|
description: t("instanceWelcome.shortcuts.newSession"),
|
|
context: "global",
|
|
}
|
|
})
|
|
const newSessionShortcutString = createMemo(() => (isMac() ? "cmd+shift+n" : "ctrl+shift+n"))
|
|
|
|
createEffect(() => {
|
|
const sessions = parentSessions()
|
|
if (sessions.length === 0) {
|
|
setFocusMode("new-session")
|
|
setSelectedIndex(0)
|
|
} else {
|
|
setFocusMode("sessions")
|
|
setSelectedIndex(0)
|
|
}
|
|
})
|
|
|
|
const openInstanceInfoOverlay = () => {
|
|
if (isDesktopLayout()) return
|
|
setShowInstanceInfoOverlay(true)
|
|
}
|
|
const closeInstanceInfoOverlay = () => setShowInstanceInfoOverlay(false)
|
|
|
|
function scrollToIndex(index: number) {
|
|
const element = document.querySelector(`[data-session-index="${index}"]`)
|
|
if (element) {
|
|
element.scrollIntoView({ block: "nearest", behavior: "auto" })
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
let activeElement: HTMLElement | null = null
|
|
if (typeof document !== "undefined") {
|
|
activeElement = document.activeElement as HTMLElement | null
|
|
}
|
|
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
|
|
const isEditingField =
|
|
activeElement &&
|
|
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) ||
|
|
activeElement.isContentEditable ||
|
|
Boolean(insideModal))
|
|
|
|
if (isEditingField) {
|
|
if (insideModal && e.key === "Escape" && renameTarget()) {
|
|
e.preventDefault()
|
|
closeRenameDialog()
|
|
}
|
|
return
|
|
}
|
|
|
|
if (showInstanceInfoOverlay()) {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault()
|
|
closeInstanceInfoOverlay()
|
|
}
|
|
return
|
|
}
|
|
|
|
const sessions = parentSessions()
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
|
e.preventDefault()
|
|
handleNewSession()
|
|
return
|
|
}
|
|
|
|
if (sessions.length === 0) return
|
|
|
|
const listFocused = focusMode() === "sessions"
|
|
|
|
if (e.key === "ArrowDown") {
|
|
if (!listFocused) {
|
|
setFocusMode("sessions")
|
|
setSelectedIndex(0)
|
|
}
|
|
e.preventDefault()
|
|
const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1)
|
|
setSelectedIndex(newIndex)
|
|
scrollToIndex(newIndex)
|
|
return
|
|
}
|
|
|
|
if (e.key === "ArrowUp") {
|
|
if (!listFocused) {
|
|
setFocusMode("sessions")
|
|
setSelectedIndex(Math.max(parentSessions().length - 1, 0))
|
|
}
|
|
e.preventDefault()
|
|
const newIndex = Math.max(selectedIndex() - 1, 0)
|
|
setSelectedIndex(newIndex)
|
|
scrollToIndex(newIndex)
|
|
return
|
|
}
|
|
|
|
if (!listFocused) {
|
|
return
|
|
}
|
|
|
|
if (e.key === "PageDown") {
|
|
e.preventDefault()
|
|
const pageSize = 5
|
|
const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1)
|
|
setSelectedIndex(newIndex)
|
|
scrollToIndex(newIndex)
|
|
} else if (e.key === "PageUp") {
|
|
e.preventDefault()
|
|
const pageSize = 5
|
|
const newIndex = Math.max(selectedIndex() - pageSize, 0)
|
|
setSelectedIndex(newIndex)
|
|
scrollToIndex(newIndex)
|
|
} else if (e.key === "Home") {
|
|
e.preventDefault()
|
|
setSelectedIndex(0)
|
|
scrollToIndex(0)
|
|
} else if (e.key === "End") {
|
|
e.preventDefault()
|
|
const newIndex = sessions.length - 1
|
|
setSelectedIndex(newIndex)
|
|
scrollToIndex(newIndex)
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault()
|
|
void handleEnterKey()
|
|
} else if (e.key === "Delete" || e.key === "Backspace") {
|
|
e.preventDefault()
|
|
void handleDeleteKey()
|
|
}
|
|
}
|
|
|
|
|
|
async function handleEnterKey() {
|
|
const sessions = parentSessions()
|
|
const index = selectedIndex()
|
|
|
|
if (index < sessions.length) {
|
|
await handleSessionSelect(sessions[index].id)
|
|
}
|
|
}
|
|
|
|
async function handleDeleteKey() {
|
|
const sessions = parentSessions()
|
|
const index = selectedIndex()
|
|
|
|
if (index >= sessions.length) {
|
|
return
|
|
}
|
|
|
|
await handleSessionDelete(sessions[index].id)
|
|
|
|
const updatedSessions = parentSessions()
|
|
if (updatedSessions.length === 0) {
|
|
setFocusMode("new-session")
|
|
setSelectedIndex(0)
|
|
return
|
|
}
|
|
|
|
const nextIndex = Math.min(index, updatedSessions.length - 1)
|
|
setSelectedIndex(nextIndex)
|
|
setFocusMode("sessions")
|
|
scrollToIndex(nextIndex)
|
|
}
|
|
|
|
onMount(() => {
|
|
window.addEventListener("keydown", handleKeyDown)
|
|
|
|
onCleanup(() => {
|
|
window.removeEventListener("keydown", handleKeyDown)
|
|
})
|
|
})
|
|
|
|
onMount(() => {
|
|
const mediaQuery = window.matchMedia("(min-width: 1024px)")
|
|
const handleMediaChange = (matches: boolean) => {
|
|
setIsDesktopLayout(matches)
|
|
if (matches) {
|
|
closeInstanceInfoOverlay()
|
|
}
|
|
}
|
|
|
|
const listener = (event: MediaQueryListEvent) => handleMediaChange(event.matches)
|
|
|
|
if (typeof mediaQuery.addEventListener === "function") {
|
|
mediaQuery.addEventListener("change", listener)
|
|
onCleanup(() => {
|
|
mediaQuery.removeEventListener("change", listener)
|
|
})
|
|
} else {
|
|
mediaQuery.addListener(listener)
|
|
onCleanup(() => {
|
|
mediaQuery.removeListener(listener)
|
|
})
|
|
}
|
|
|
|
handleMediaChange(mediaQuery.matches)
|
|
})
|
|
|
|
function formatRelativeTime(timestamp: number): string {
|
|
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
|
const minutes = Math.floor(seconds / 60)
|
|
const hours = Math.floor(minutes / 60)
|
|
const days = Math.floor(hours / 24)
|
|
|
|
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
|
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
|
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
|
return t("time.relative.justNow")
|
|
}
|
|
|
|
function formatTimestamp(timestamp: number): string {
|
|
return new Date(timestamp).toLocaleString()
|
|
}
|
|
|
|
async function handleSessionSelect(sessionId: string) {
|
|
setActiveParentSession(props.instance.id, sessionId)
|
|
}
|
|
|
|
async function handleSessionDelete(sessionId: string) {
|
|
if (isSessionDeleting(sessionId)) return
|
|
|
|
try {
|
|
await deleteSession(props.instance.id, sessionId)
|
|
} catch (error) {
|
|
log.error("Failed to delete session:", error)
|
|
}
|
|
}
|
|
|
|
function openRenameDialogForSession(sessionId: string, title: string) {
|
|
const label = title && title.trim() ? title : sessionId
|
|
setRenameTarget({ id: sessionId, title: title ?? "", label })
|
|
}
|
|
|
|
function closeRenameDialog() {
|
|
setRenameTarget(null)
|
|
}
|
|
|
|
async function handleRenameSubmit(nextTitle: string) {
|
|
const target = renameTarget()
|
|
if (!target) return
|
|
|
|
setIsRenaming(true)
|
|
try {
|
|
await renameSession(props.instance.id, target.id, nextTitle)
|
|
setRenameTarget(null)
|
|
} catch (error) {
|
|
log.error("Failed to rename session:", error)
|
|
showToastNotification({ message: t("instanceWelcome.toasts.renameError"), variant: "error" })
|
|
} finally {
|
|
setIsRenaming(false)
|
|
}
|
|
}
|
|
|
|
async function handleNewSession() {
|
|
if (isCreating()) return
|
|
|
|
setIsCreating(true)
|
|
|
|
try {
|
|
const session = await createSession(props.instance.id)
|
|
setActiveParentSession(props.instance.id, session.id)
|
|
} catch (error) {
|
|
log.error("Failed to create session:", error)
|
|
} finally {
|
|
setIsCreating(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div class="flex-1 flex flex-col overflow-hidden bg-surface-secondary">
|
|
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto min-w-0">
|
|
<div class="flex-1 flex flex-col gap-4 min-h-0 min-w-0">
|
|
<Show
|
|
when={parentSessions().length > 0}
|
|
fallback={
|
|
<Show
|
|
when={isFetchingSessions()}
|
|
fallback={
|
|
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
|
<div class="panel-empty-state-icon">
|
|
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<p class="panel-empty-state-title">{t("instanceWelcome.empty.title")}</p>
|
|
<p class="panel-empty-state-description">{t("instanceWelcome.empty.description")}</p>
|
|
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
|
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
|
|
{t("instanceWelcome.actions.viewInstanceInfo")}
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
}
|
|
>
|
|
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
|
<div class="panel-empty-state-icon">
|
|
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
|
|
</div>
|
|
<p class="panel-empty-state-title">{t("instanceWelcome.loading.title")}</p>
|
|
<p class="panel-empty-state-description">{t("instanceWelcome.loading.description")}</p>
|
|
</div>
|
|
</Show>
|
|
}
|
|
>
|
|
<div class="panel flex flex-col flex-1 min-h-0">
|
|
<div class="panel-header">
|
|
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
|
|
<div>
|
|
<h2 class="panel-title">{t("instanceWelcome.resume.title")}</h2>
|
|
<p class="panel-subtitle">
|
|
{parentSessions().length === 1
|
|
? t("instanceWelcome.resume.subtitle.one", { count: parentSessions().length })
|
|
: t("instanceWelcome.resume.subtitle.other", { count: parentSessions().length })}
|
|
</p>
|
|
</div>
|
|
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
|
<button
|
|
type="button"
|
|
class="button-tertiary lg:hidden flex-shrink-0"
|
|
onClick={openInstanceInfoOverlay}
|
|
>
|
|
{t("instanceWelcome.actions.viewInstanceInfo")}
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto">
|
|
<For each={parentSessions()}>
|
|
{(session, index) => {
|
|
const isFocused = () => focusMode() === "sessions" && selectedIndex() === index()
|
|
return (
|
|
<div
|
|
class="panel-list-item"
|
|
classList={{
|
|
"panel-list-item-highlight": isFocused(),
|
|
}}
|
|
>
|
|
<div class="flex items-center gap-2 w-full px-1">
|
|
<button
|
|
type="button"
|
|
data-session-index={index()}
|
|
class="panel-list-item-content group flex-1"
|
|
onClick={() => handleSessionSelect(session.id)}
|
|
onMouseEnter={() => {
|
|
setFocusMode("sessions")
|
|
setSelectedIndex(index())
|
|
}}
|
|
>
|
|
<div class="flex items-center justify-between gap-3 w-full">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
|
classList={{
|
|
"text-accent": isFocused(),
|
|
}}
|
|
>
|
|
{session.title || t("instanceWelcome.session.untitled")}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
|
<span>{session.agent}</span>
|
|
<span>•</span>
|
|
<span>{formatRelativeTime(session.time.updated)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<Show when={isFocused()}>
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
<kbd class="kbd flex-shrink-0">↵</kbd>
|
|
<button
|
|
type="button"
|
|
class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
|
title={t("instanceWelcome.actions.renameTitle")}
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
openRenameDialogForSession(session.id, session.title || "")
|
|
}}
|
|
>
|
|
<Pencil class="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
|
title={t("instanceWelcome.actions.deleteTitle")}
|
|
disabled={isSessionDeleting(session.id)}
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
void handleSessionDelete(session.id)
|
|
}}
|
|
>
|
|
<Show
|
|
when={!isSessionDeleting(session.id)}
|
|
fallback={
|
|
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
/>
|
|
</svg>
|
|
}
|
|
>
|
|
<Trash2 class="w-4 h-4" />
|
|
</Show>
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
)
|
|
}}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<div class="panel flex-shrink-0">
|
|
<div class="panel-header">
|
|
<h2 class="panel-title">{t("instanceWelcome.new.title")}</h2>
|
|
<p class="panel-subtitle">{t("instanceWelcome.new.subtitle")}</p>
|
|
</div>
|
|
<div class="panel-body">
|
|
<div class="space-y-3">
|
|
<button
|
|
type="button"
|
|
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
|
onClick={handleNewSession}
|
|
disabled={isCreating()}
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
{isCreating() ? (
|
|
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
)}
|
|
<span>{t("instanceWelcome.new.createButton")}</span>
|
|
</div>
|
|
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="hidden lg:block lg:w-80 flex-shrink-0">
|
|
<div class="sticky top-0 max-h-full overflow-y-auto pr-1">
|
|
<InstanceInfo instance={props.instance} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Show when={!isDesktopLayout() && showInstanceInfoOverlay()}>
|
|
<div
|
|
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
|
|
onClick={closeInstanceInfoOverlay}
|
|
>
|
|
<div class="flex min-h-full items-start justify-center p-4 overflow-y-auto">
|
|
<div
|
|
class="w-full max-w-md space-y-3"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div class="flex justify-end">
|
|
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
|
|
{t("instanceWelcome.overlay.close")}
|
|
</button>
|
|
</div>
|
|
<div class="max-h-[85vh] overflow-y-auto pr-1">
|
|
<InstanceInfo instance={props.instance} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<div class="panel-footer hidden sm:block keyboard-hints">
|
|
|
|
<div class="panel-footer-hints">
|
|
<div class="flex items-center gap-1.5">
|
|
<kbd class="kbd">↑</kbd>
|
|
<kbd class="kbd">↓</kbd>
|
|
<span>{t("instanceWelcome.hints.navigate")}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
<kbd class="kbd">PgUp</kbd>
|
|
<kbd class="kbd">PgDn</kbd>
|
|
<span>{t("instanceWelcome.hints.jump")}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
<kbd class="kbd">Home</kbd>
|
|
<kbd class="kbd">End</kbd>
|
|
<span>{t("instanceWelcome.hints.firstLast")}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
<kbd class="kbd">Enter</kbd>
|
|
<span>{t("instanceWelcome.hints.resume")}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
<kbd class="kbd">Del</kbd>
|
|
<span>{t("instanceWelcome.hints.delete")}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<SessionRenameDialog
|
|
open={Boolean(renameTarget())}
|
|
currentTitle={renameTarget()?.title ?? ""}
|
|
sessionLabel={renameTarget()?.label}
|
|
isSubmitting={isRenaming()}
|
|
onRename={handleRenameSubmit}
|
|
onClose={closeRenameDialog}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default InstanceWelcomeView
|