import { A, useNavigate, useParams } from "@solidjs/router" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { base64Encode } from "@opencode-ai/util/encode" import { Avatar } from "@opencode-ai/ui/avatar" import { HoverCard } from "@opencode-ai/ui/hover-card" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { MessageNav } from "@opencode-ai/ui/message-nav" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/util/path" import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client" import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js" import { agentColor } from "@/utils/agent" import { hasProjectPermissions } from "./helpers" import { sessionPermissionRequest } from "../session/composer/session-request-tree" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const globalSync = useGlobalSync() const notification = useNotification() const permission = usePermission() const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])]) const unseenCount = createMemo(() => dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), ) const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory))) const hasPermissions = createMemo(() => dirs().some((directory) => { const [store] = globalSync.child(directory, { bootstrap: false }) return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory)) }), ) const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) return (
) } export type SessionItemProps = { session: Session slug: string mobile?: boolean dense?: boolean popover?: boolean children: Map sidebarExpanded: Accessor sidebarHovering: Accessor nav: Accessor hoverSession: Accessor setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise } const SessionRow = (props: { session: Session slug: string mobile?: boolean dense?: boolean tint: Accessor isWorking: Accessor hasPermissions: Accessor hasError: Accessor unseenCount: Accessor setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void sidebarOpened: Accessor prefetchSession: (session: Session, priority?: "high" | "low") => void scheduleHoverPrefetch: () => void cancelHoverPrefetch: () => void }): JSX.Element => ( props.prefetchSession(props.session, "high")} onClick={() => { props.setHoverSession(undefined) if (props.sidebarOpened()) return props.clearHoverProjectSoon() }} >
}>
0}>
{props.session.title}
) const SessionHoverPreview = (props: { mobile?: boolean nav: Accessor hoverSession: Accessor session: Session sidebarHovering: Accessor hoverReady: Accessor hoverMessages: Accessor language: ReturnType isActive: Accessor slug: string setHoverSession: (id: string | undefined) => void messageLabel: (message: Message) => string | undefined onMessageSelect: (message: Message) => void trigger: JSX.Element }): JSX.Element => ( props.setHoverSession(open ? props.session.id : undefined)} > {props.language.t("session.messages.loading")}
} >
) export const SessionItem = (props: SessionItemProps): JSX.Element => { const params = useParams() const navigate = useNavigate() const layout = useLayout() const language = useLanguage() const notification = useNotification() const permission = usePermission() const globalSync = useGlobalSync() const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) const [sessionStore] = globalSync.child(props.session.directory) const hasPermissions = createMemo(() => { return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => { return !permission.autoResponds(item, props.session.directory) }) }) const isWorking = createMemo(() => { if (hasPermissions()) return false const status = sessionStore.session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) const tint = createMemo(() => { const messages = sessionStore.message[props.session.id] if (!messages) return undefined let user: Message | undefined for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (message.role !== "user") continue user = message break } if (!user?.agent) return undefined const agent = sessionStore.agent.find((a) => a.name === user.agent) return agentColor(user.agent, agent?.color) }) const hoverMessages = createMemo(() => sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"), ) const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) const hoverPrefetch = { current: undefined as ReturnType | undefined } const cancelHoverPrefetch = () => { if (hoverPrefetch.current === undefined) return clearTimeout(hoverPrefetch.current) hoverPrefetch.current = undefined } const scheduleHoverPrefetch = () => { if (hoverPrefetch.current !== undefined) return hoverPrefetch.current = setTimeout(() => { hoverPrefetch.current = undefined props.prefetchSession(props.session) }, 200) } onCleanup(cancelHoverPrefetch) const messageLabel = (message: Message) => { const parts = sessionStore.part[message.id] ?? [] const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) return text?.text } const item = ( ) return (
{item} } > { if (!isActive()) { layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) navigate(`${props.slug}/session/${props.session.id}`) return } window.history.replaceState(null, "", `#message-${message.id}`) window.dispatchEvent(new HashChangeEvent("hashchange")) }} trigger={item} />
{ event.preventDefault() event.stopPropagation() void props.archiveSession(props.session) }} />
) } export const NewSessionItem = (props: { slug: string mobile?: boolean dense?: boolean sidebarExpanded: Accessor clearHoverProjectSoon: () => void setHoverSession: (id: string | undefined) => void }): JSX.Element => { const layout = useLayout() const language = useLanguage() const label = language.t("command.session.new") const tooltip = () => props.mobile || !props.sidebarExpanded() const item = ( { props.setHoverSession(undefined) if (layout.sidebar.opened()) return props.clearHoverProjectSoon() }} >
{label}
) return (
{item} } > {item}
) } export const SessionSkeleton = (props: { count?: number }): JSX.Element => { const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) return (
{() =>
}
) }