870 lines
32 KiB
TypeScript
870 lines
32 KiB
TypeScript
import {
|
|
For,
|
|
Show,
|
|
createEffect,
|
|
createMemo,
|
|
createSignal,
|
|
onCleanup,
|
|
onMount,
|
|
type Accessor,
|
|
type Component,
|
|
} from "solid-js"
|
|
import AppBar from "@suid/material/AppBar"
|
|
import Box from "@suid/material/Box"
|
|
import Drawer from "@suid/material/Drawer"
|
|
import IconButton from "@suid/material/IconButton"
|
|
import Toolbar from "@suid/material/Toolbar"
|
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
|
import type { Instance } from "../../types/instance"
|
|
import type { Command } from "../../lib/commands"
|
|
import type { BackgroundProcess } from "../../../../server/src/api-types"
|
|
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
|
|
|
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
|
|
import Kbd from "../kbd"
|
|
import InstanceWelcomeView from "../instance-welcome-view"
|
|
import InfoView from "../info-view"
|
|
import CommandPalette from "../command-palette"
|
|
import PermissionNotificationBanner from "../permission-notification-banner"
|
|
import PermissionApprovalModal from "../permission-approval-modal"
|
|
import SessionView from "../session/session-view"
|
|
import { formatTokenTotal } from "../../lib/formatters"
|
|
import ContextMeter from "../context-meter"
|
|
import { sseManager } from "../../lib/sse-manager"
|
|
import { getLogger } from "../../lib/logger"
|
|
import { serverApi } from "../../lib/api-client"
|
|
import { loadBackgroundProcesses } from "../../stores/background-processes"
|
|
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
|
import { useI18n } from "../../lib/i18n"
|
|
import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances"
|
|
import SessionSidebar from "./shell/SessionSidebar"
|
|
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
|
import RightPanel from "./shell/right-panel/RightPanel"
|
|
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
|
import { getSessionStatus } from "../../stores/session-status"
|
|
import { Maximize2, ShieldAlert } from "lucide-solid"
|
|
|
|
import type { LayoutMode } from "./shell/types"
|
|
import {
|
|
DEFAULT_SESSION_SIDEBAR_WIDTH,
|
|
LEFT_DRAWER_STORAGE_KEY,
|
|
RIGHT_DRAWER_STORAGE_KEY,
|
|
RIGHT_DRAWER_WIDTH,
|
|
clampRightWidth,
|
|
clampWidth,
|
|
} from "./shell/storage"
|
|
import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
|
|
import { useDrawerResize } from "./shell/useDrawerResize"
|
|
import { useSessionCache } from "./shell/useSessionCache"
|
|
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
|
|
|
|
const log = getLogger("session")
|
|
|
|
interface InstanceShellProps {
|
|
instance: Instance
|
|
escapeInDebounce: boolean
|
|
paletteCommands: Accessor<Command[]>
|
|
onCloseSession: (sessionId: string) => Promise<void> | void
|
|
onNewSession: () => Promise<void> | void
|
|
handleSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
|
|
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
|
onExecuteCommand: (command: Command) => void
|
|
tabBarOffset: number
|
|
|
|
// In-memory only: mobile immersive/fullscreen mode.
|
|
mobileFullscreenMode: boolean
|
|
onEnterMobileFullscreen: () => void
|
|
onExitMobileFullscreen: () => void
|
|
}
|
|
|
|
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|
const { t } = useI18n()
|
|
|
|
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
|
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
|
|
typeof window !== "undefined" ? clampRightWidth(window.innerWidth * 0.35) : RIGHT_DRAWER_WIDTH,
|
|
)
|
|
const [rightDrawerWidthInitialized, setRightDrawerWidthInitialized] = createSignal(false)
|
|
const [leftDrawerContentEl, setLeftDrawerContentEl] = createSignal<HTMLElement | null>(null)
|
|
const [rightDrawerContentEl, setRightDrawerContentEl] = createSignal<HTMLElement | null>(null)
|
|
const [leftToggleButtonEl, setLeftToggleButtonEl] = createSignal<HTMLElement | null>(null)
|
|
const [rightToggleButtonEl, setRightToggleButtonEl] = createSignal<HTMLElement | null>(null)
|
|
|
|
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
|
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
|
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
|
|
|
// Worktree selector manages its own dialogs.
|
|
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
|
|
|
const {
|
|
allInstanceSessions,
|
|
sessionThreads,
|
|
activeSessions,
|
|
activeSessionIdForInstance,
|
|
activeSessionForInstance,
|
|
activeSessionDiffs,
|
|
latestTodoState,
|
|
tokenStats,
|
|
backgroundProcessList,
|
|
handleSessionSelect,
|
|
} = useInstanceSessionContext({
|
|
instanceId: () => props.instance.id,
|
|
})
|
|
|
|
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
|
|
|
const tabletQuery = useMediaQuery("(min-width: 768px)")
|
|
|
|
const layoutMode = createMemo<LayoutMode>(() => {
|
|
if (desktopQuery()) return "desktop"
|
|
if (tabletQuery()) return "tablet"
|
|
return "phone"
|
|
})
|
|
|
|
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
|
|
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
|
|
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
|
|
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
|
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
|
|
|
const { setDrawerHost, drawerContainer, measureDrawerHost, floatingTopPx, floatingHeight } = useDrawerHostMeasure(
|
|
() => props.tabBarOffset,
|
|
)
|
|
|
|
const drawerChrome = useDrawerChrome({
|
|
t,
|
|
layoutMode,
|
|
leftPinningSupported,
|
|
rightPinningSupported,
|
|
leftDrawerContentEl,
|
|
rightDrawerContentEl,
|
|
leftToggleButtonEl,
|
|
rightToggleButtonEl,
|
|
measureDrawerHost,
|
|
})
|
|
|
|
const {
|
|
leftPinned,
|
|
leftOpen,
|
|
rightPinned,
|
|
rightOpen,
|
|
setLeftOpen,
|
|
setRightOpen,
|
|
leftDrawerState,
|
|
rightDrawerState,
|
|
pinLeft: pinLeftDrawer,
|
|
unpinLeft: unpinLeftDrawer,
|
|
pinRight: pinRightDrawer,
|
|
unpinRight: unpinRightDrawer,
|
|
closeLeft: closeLeftDrawer,
|
|
closeRight: closeRightDrawer,
|
|
leftAppBarButtonLabel,
|
|
rightAppBarButtonLabel,
|
|
leftAppBarButtonIcon,
|
|
rightAppBarButtonIcon,
|
|
handleLeftAppBarButtonClick,
|
|
handleRightAppBarButtonClick,
|
|
} = drawerChrome
|
|
|
|
createEffect(() => {
|
|
const instanceId = props.instance.id
|
|
loadBackgroundProcesses(instanceId).catch((error) => {
|
|
log.warn("Failed to load background processes", error)
|
|
})
|
|
})
|
|
|
|
onMount(() => {
|
|
if (typeof window === "undefined") return
|
|
|
|
const savedLeft = window.localStorage.getItem(LEFT_DRAWER_STORAGE_KEY)
|
|
if (savedLeft) {
|
|
const parsed = Number.parseInt(savedLeft, 10)
|
|
if (Number.isFinite(parsed)) {
|
|
setSessionSidebarWidth(clampWidth(parsed))
|
|
}
|
|
}
|
|
|
|
let didLoadRightWidth = false
|
|
const savedRight = window.localStorage.getItem(RIGHT_DRAWER_STORAGE_KEY)
|
|
if (savedRight) {
|
|
const parsed = Number.parseInt(savedRight, 10)
|
|
if (Number.isFinite(parsed)) {
|
|
setRightDrawerWidth(clampRightWidth(parsed))
|
|
didLoadRightWidth = true
|
|
}
|
|
}
|
|
|
|
if (!didLoadRightWidth) {
|
|
setRightDrawerWidth(clampRightWidth(window.innerWidth * 0.35))
|
|
}
|
|
|
|
setRightDrawerWidthInitialized(true)
|
|
|
|
const handleResize = () => {
|
|
const width = clampWidth(window.innerWidth * 0.3)
|
|
setSessionSidebarWidth((current) => clampWidth(current || width))
|
|
const fallbackRight = window.innerWidth * 0.35
|
|
setRightDrawerWidth((current) => clampRightWidth(current || fallbackRight))
|
|
measureDrawerHost()
|
|
}
|
|
|
|
handleResize()
|
|
window.addEventListener("resize", handleResize)
|
|
onCleanup(() => window.removeEventListener("resize", handleResize))
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (typeof window === "undefined") return
|
|
window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString())
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (typeof window === "undefined") return
|
|
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
|
|
})
|
|
|
|
const connectionStatus = () => sseManager.getStatus(props.instance.id)
|
|
const connectionStatusClass = () => {
|
|
const status = connectionStatus()
|
|
if (status === "connecting") return "connecting"
|
|
if (status === "connected") return "connected"
|
|
return "disconnected"
|
|
}
|
|
|
|
const connectionStatusLabel = () => {
|
|
const status = connectionStatus()
|
|
if (status === "connected") return t("instanceShell.connection.connected")
|
|
if (status === "connecting") return t("instanceShell.connection.connecting")
|
|
if (status === "error" || status === "disconnected") return t("instanceShell.connection.disconnected")
|
|
return t("instanceShell.connection.unknown")
|
|
}
|
|
|
|
const hasPendingRequests = createMemo(() => {
|
|
const permissions = getPermissionQueueLength(props.instance.id)
|
|
const questions = getQuestionQueueLength(props.instance.id)
|
|
return permissions + questions > 0
|
|
})
|
|
|
|
const activeSessionStatusPill = createMemo(() => {
|
|
const activeSessionId = activeSessionIdForInstance()
|
|
if (!activeSessionId || activeSessionId === "info") return null
|
|
|
|
const activeSession = activeSessionForInstance()
|
|
const needsPermission = Boolean(activeSession?.pendingPermission)
|
|
const needsQuestion = Boolean(activeSession?.pendingQuestion)
|
|
const needsInput = needsPermission || needsQuestion
|
|
|
|
if (needsInput) {
|
|
return {
|
|
className: "session-permission",
|
|
text: needsPermission
|
|
? t("sessionList.status.needsPermission")
|
|
: t("sessionList.status.needsInput"),
|
|
showAlertIcon: true,
|
|
}
|
|
}
|
|
|
|
const status = getSessionStatus(props.instance.id, activeSessionId)
|
|
const text =
|
|
status === "working"
|
|
? t("sessionList.status.working")
|
|
: status === "compacting"
|
|
? t("sessionList.status.compacting")
|
|
: t("sessionList.status.idle")
|
|
|
|
return {
|
|
className: `session-${status}`,
|
|
text,
|
|
showAlertIcon: false,
|
|
}
|
|
})
|
|
|
|
const renderActiveSessionStatusPill = () => {
|
|
const pill = activeSessionStatusPill()
|
|
if (!pill) return null
|
|
return (
|
|
<span class={`status-indicator session-status session-status-list ${pill.className}`}>
|
|
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
|
{pill.text}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const handleCommandPaletteClick = () => {
|
|
showCommandPalette(props.instance.id)
|
|
}
|
|
|
|
const openBackgroundOutput = (process: BackgroundProcess) => {
|
|
setSelectedBackgroundProcess(process)
|
|
setShowBackgroundOutput(true)
|
|
}
|
|
|
|
const closeBackgroundOutput = () => {
|
|
setShowBackgroundOutput(false)
|
|
setSelectedBackgroundProcess(null)
|
|
}
|
|
|
|
const stopBackgroundProcess = async (processId: string) => {
|
|
try {
|
|
await serverApi.stopBackgroundProcess(props.instance.id, processId)
|
|
} catch (error) {
|
|
log.warn("Failed to stop background process", error)
|
|
}
|
|
}
|
|
|
|
const terminateBackgroundProcess = async (processId: string) => {
|
|
try {
|
|
await serverApi.terminateBackgroundProcess(props.instance.id, processId)
|
|
} catch (error) {
|
|
log.warn("Failed to terminate background process", error)
|
|
}
|
|
}
|
|
|
|
const instancePaletteCommands = createMemo(() => props.paletteCommands())
|
|
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
|
|
|
const keyboardShortcuts = createMemo(() =>
|
|
[keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter(
|
|
(shortcut): shortcut is KeyboardShortcut => Boolean(shortcut),
|
|
),
|
|
)
|
|
|
|
useSessionSidebarRequests({
|
|
instanceId: () => props.instance.id,
|
|
sidebarContentEl: leftDrawerContentEl,
|
|
leftPinned,
|
|
leftOpen,
|
|
setLeftOpen,
|
|
measureDrawerHost,
|
|
})
|
|
|
|
const { cachedSessionIds } = useSessionCache({
|
|
instanceId: () => props.instance.id,
|
|
instanceSessions: allInstanceSessions,
|
|
activeSessionId: activeSessionIdForInstance,
|
|
})
|
|
|
|
const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen())
|
|
|
|
const { handleDrawerResizeMouseDown, handleDrawerResizeTouchStart } = useDrawerResize({
|
|
sessionSidebarWidth,
|
|
rightDrawerWidth,
|
|
setSessionSidebarWidth,
|
|
setRightDrawerWidth,
|
|
clampLeft: clampWidth,
|
|
clampRight: clampRightWidth,
|
|
measureDrawerHost,
|
|
})
|
|
|
|
|
|
const renderLeftPanel = () => {
|
|
if (leftPinned()) {
|
|
return (
|
|
<Box
|
|
class="session-sidebar-container"
|
|
sx={{
|
|
width: `${sessionSidebarWidth()}px`,
|
|
flexShrink: 0,
|
|
borderRight: "1px solid var(--border-base)",
|
|
backgroundColor: "var(--surface-secondary)",
|
|
height: "100%",
|
|
minHeight: 0,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<div
|
|
class="session-resize-handle session-resize-handle--left"
|
|
onMouseDown={handleDrawerResizeMouseDown("left")}
|
|
onTouchStart={handleDrawerResizeTouchStart("left")}
|
|
role="presentation"
|
|
aria-hidden="true"
|
|
/>
|
|
<SessionSidebar
|
|
t={t}
|
|
instanceId={props.instance.id}
|
|
threads={sessionThreads}
|
|
activeSessionId={activeSessionIdForInstance}
|
|
activeSession={activeSessionForInstance}
|
|
showSearch={showSessionSearch}
|
|
onToggleSearch={() => setShowSessionSearch((current) => !current)}
|
|
keyboardShortcuts={keyboardShortcuts}
|
|
isPhoneLayout={isPhoneLayout}
|
|
drawerState={leftDrawerState}
|
|
leftPinned={leftPinned}
|
|
onSelectSession={handleSessionSelect}
|
|
onNewSession={props.onNewSession}
|
|
onSidebarAgentChange={props.handleSidebarAgentChange}
|
|
onSidebarModelChange={props.handleSidebarModelChange}
|
|
onPinLeftDrawer={pinLeftDrawer}
|
|
onUnpinLeftDrawer={unpinLeftDrawer}
|
|
onCloseLeftDrawer={closeLeftDrawer}
|
|
setContentEl={setLeftDrawerContentEl}
|
|
/>
|
|
</Box>
|
|
)
|
|
}
|
|
const container = drawerContainer()
|
|
const modalProps = container ? { container: container as Element } : undefined
|
|
return (
|
|
<Drawer
|
|
anchor="left"
|
|
variant="temporary"
|
|
open={leftOpen()}
|
|
onClose={closeLeftDrawer}
|
|
ModalProps={modalProps}
|
|
sx={{
|
|
"& .MuiDrawer-paper": {
|
|
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
|
boxSizing: "border-box",
|
|
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
|
backgroundColor: "var(--surface-secondary)",
|
|
backgroundImage: "none",
|
|
color: "var(--text-primary)",
|
|
boxShadow: "none",
|
|
borderRadius: 0,
|
|
top: floatingTopPx(),
|
|
height: floatingHeight(),
|
|
},
|
|
|
|
"& .MuiBackdrop-root": {
|
|
backgroundColor: "transparent",
|
|
},
|
|
}}
|
|
>
|
|
<Show when={!isPhoneLayout()}>
|
|
<div
|
|
class="session-resize-handle session-resize-handle--left"
|
|
onMouseDown={handleDrawerResizeMouseDown("left")}
|
|
onTouchStart={handleDrawerResizeTouchStart("left")}
|
|
role="presentation"
|
|
aria-hidden="true"
|
|
/>
|
|
</Show>
|
|
<SessionSidebar
|
|
t={t}
|
|
instanceId={props.instance.id}
|
|
threads={sessionThreads}
|
|
activeSessionId={activeSessionIdForInstance}
|
|
activeSession={activeSessionForInstance}
|
|
showSearch={showSessionSearch}
|
|
onToggleSearch={() => setShowSessionSearch((current) => !current)}
|
|
keyboardShortcuts={keyboardShortcuts}
|
|
isPhoneLayout={isPhoneLayout}
|
|
drawerState={leftDrawerState}
|
|
leftPinned={leftPinned}
|
|
onSelectSession={handleSessionSelect}
|
|
onNewSession={props.onNewSession}
|
|
onSidebarAgentChange={props.handleSidebarAgentChange}
|
|
onSidebarModelChange={props.handleSidebarModelChange}
|
|
onPinLeftDrawer={pinLeftDrawer}
|
|
onUnpinLeftDrawer={unpinLeftDrawer}
|
|
onCloseLeftDrawer={closeLeftDrawer}
|
|
setContentEl={setLeftDrawerContentEl}
|
|
/>
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
|
|
const renderRightPanel = () => {
|
|
if (rightPinned()) {
|
|
return (
|
|
<Box
|
|
class="session-right-panel"
|
|
sx={{
|
|
width: `${rightDrawerWidth()}px`,
|
|
flexShrink: 0,
|
|
borderLeft: "1px solid var(--border-base)",
|
|
backgroundColor: "var(--surface-secondary)",
|
|
height: "100%",
|
|
minHeight: 0,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<div
|
|
class="session-resize-handle session-resize-handle--right"
|
|
onMouseDown={handleDrawerResizeMouseDown("right")}
|
|
onTouchStart={handleDrawerResizeTouchStart("right")}
|
|
role="presentation"
|
|
aria-hidden="true"
|
|
/>
|
|
<RightPanel
|
|
t={t}
|
|
instanceId={props.instance.id}
|
|
instance={props.instance}
|
|
activeSessionId={activeSessionIdForInstance}
|
|
activeSession={activeSessionForInstance}
|
|
activeSessionDiffs={activeSessionDiffs}
|
|
latestTodoState={latestTodoState}
|
|
backgroundProcessList={backgroundProcessList}
|
|
onOpenBackgroundOutput={openBackgroundOutput}
|
|
onStopBackgroundProcess={stopBackgroundProcess}
|
|
onTerminateBackgroundProcess={terminateBackgroundProcess}
|
|
isPhoneLayout={isPhoneLayout}
|
|
rightDrawerWidth={rightDrawerWidth}
|
|
rightDrawerWidthInitialized={rightDrawerWidthInitialized}
|
|
rightDrawerState={rightDrawerState}
|
|
rightPinned={rightPinned}
|
|
onCloseRightDrawer={closeRightDrawer}
|
|
onPinRightDrawer={pinRightDrawer}
|
|
onUnpinRightDrawer={unpinRightDrawer}
|
|
setContentEl={setRightDrawerContentEl}
|
|
/>
|
|
</Box>
|
|
)
|
|
}
|
|
const container = drawerContainer()
|
|
const modalProps = container ? { container: container as Element } : undefined
|
|
return (
|
|
<Drawer
|
|
anchor="right"
|
|
variant="temporary"
|
|
open={rightOpen()}
|
|
onClose={closeRightDrawer}
|
|
ModalProps={modalProps}
|
|
sx={{
|
|
"& .MuiDrawer-paper": {
|
|
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
|
boxSizing: "border-box",
|
|
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
|
backgroundColor: "var(--surface-secondary)",
|
|
backgroundImage: "none",
|
|
color: "var(--text-primary)",
|
|
boxShadow: "none",
|
|
borderRadius: 0,
|
|
top: floatingTopPx(),
|
|
height: floatingHeight(),
|
|
},
|
|
"& .MuiBackdrop-root": {
|
|
backgroundColor: "transparent",
|
|
},
|
|
}}
|
|
>
|
|
<Show when={!isPhoneLayout()}>
|
|
<div
|
|
class="session-resize-handle session-resize-handle--right"
|
|
onMouseDown={handleDrawerResizeMouseDown("right")}
|
|
onTouchStart={handleDrawerResizeTouchStart("right")}
|
|
role="presentation"
|
|
aria-hidden="true"
|
|
/>
|
|
</Show>
|
|
<RightPanel
|
|
t={t}
|
|
instanceId={props.instance.id}
|
|
instance={props.instance}
|
|
activeSessionId={activeSessionIdForInstance}
|
|
activeSession={activeSessionForInstance}
|
|
activeSessionDiffs={activeSessionDiffs}
|
|
latestTodoState={latestTodoState}
|
|
backgroundProcessList={backgroundProcessList}
|
|
onOpenBackgroundOutput={openBackgroundOutput}
|
|
onStopBackgroundProcess={stopBackgroundProcess}
|
|
onTerminateBackgroundProcess={terminateBackgroundProcess}
|
|
isPhoneLayout={isPhoneLayout}
|
|
rightDrawerWidth={rightDrawerWidth}
|
|
rightDrawerWidthInitialized={rightDrawerWidthInitialized}
|
|
rightDrawerState={rightDrawerState}
|
|
rightPinned={rightPinned}
|
|
onCloseRightDrawer={closeRightDrawer}
|
|
onPinRightDrawer={pinRightDrawer}
|
|
onUnpinRightDrawer={unpinRightDrawer}
|
|
setContentEl={setRightDrawerContentEl}
|
|
/>
|
|
</Drawer>
|
|
|
|
)
|
|
}
|
|
|
|
const hasSessions = createMemo(() => activeSessions().size > 0)
|
|
|
|
const showingInfoView = createMemo(() => activeSessionIdForInstance() === "info")
|
|
|
|
const sessionLayout = (
|
|
<div
|
|
class="session-shell-panels flex flex-1 min-h-0 overflow-x-hidden"
|
|
ref={(element) => {
|
|
setDrawerHost(element)
|
|
measureDrawerHost()
|
|
}}
|
|
>
|
|
{renderLeftPanel()}
|
|
|
|
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
|
|
<Show when={!mobileFullscreen()}>
|
|
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
|
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
|
<Show
|
|
when={!isPhoneLayout()}
|
|
fallback={
|
|
<div class="flex flex-col w-full gap-1.5">
|
|
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
|
<Show when={leftDrawerState() === "floating-closed"}>
|
|
<IconButton
|
|
ref={setLeftToggleButtonEl}
|
|
color="inherit"
|
|
onClick={handleLeftAppBarButtonClick}
|
|
aria-label={leftAppBarButtonLabel()}
|
|
size="small"
|
|
aria-expanded={leftDrawerState() !== "floating-closed"}
|
|
>
|
|
{leftAppBarButtonIcon()}
|
|
</IconButton>
|
|
</Show>
|
|
|
|
<div class="flex-1 flex items-center justify-center min-w-0">
|
|
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
|
<PermissionNotificationBanner
|
|
instanceId={props.instance.id}
|
|
onClick={() => setPermissionModalOpen(true)}
|
|
/>
|
|
</Show>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-center gap-1">
|
|
<button
|
|
type="button"
|
|
class="connection-status-button command-palette-button"
|
|
onClick={handleCommandPaletteClick}
|
|
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
|
style={{ flex: "0 0 auto", width: "auto" }}
|
|
>
|
|
{t("instanceShell.commandPalette.button")}
|
|
</button>
|
|
<span class="connection-status-shortcut-hint kbd-hint">
|
|
<Kbd shortcut="cmd+shift+p" />
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex-1 flex items-center justify-center min-w-0">
|
|
<span
|
|
class={`status-indicator ${connectionStatusClass()}`}
|
|
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
|
|
>
|
|
<span class="status-dot" />
|
|
</span>
|
|
</div>
|
|
|
|
<Show when={!props.mobileFullscreenMode}>
|
|
<IconButton
|
|
color="inherit"
|
|
onClick={props.onEnterMobileFullscreen}
|
|
aria-label={t("instanceShell.fullscreen.enter")}
|
|
title={t("instanceShell.fullscreen.enter")}
|
|
size="small"
|
|
>
|
|
<Maximize2 class="w-5 h-5" aria-hidden="true" />
|
|
</IconButton>
|
|
</Show>
|
|
|
|
<Show when={rightDrawerState() === "floating-closed"}>
|
|
<IconButton
|
|
ref={setRightToggleButtonEl}
|
|
color="inherit"
|
|
onClick={handleRightAppBarButtonClick}
|
|
aria-label={rightAppBarButtonLabel()}
|
|
size="small"
|
|
aria-expanded={rightDrawerState() !== "floating-closed"}
|
|
>
|
|
{rightAppBarButtonIcon()}
|
|
</IconButton>
|
|
</Show>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
|
<ContextMeter
|
|
usedTokens={tokenStats().used}
|
|
availableTokens={tokenStats().avail}
|
|
formatTokens={formatTokenTotal}
|
|
usedLabel={t("instanceShell.metrics.usedLabel")}
|
|
availableLabel={t("instanceShell.metrics.availableLabel")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<div class="session-toolbar-left flex-1 flex items-center gap-3 min-w-0">
|
|
<Show when={leftDrawerState() === "floating-closed"}>
|
|
<IconButton
|
|
ref={setLeftToggleButtonEl}
|
|
color="inherit"
|
|
onClick={handleLeftAppBarButtonClick}
|
|
aria-label={leftAppBarButtonLabel()}
|
|
size="small"
|
|
aria-expanded={leftDrawerState() !== "floating-closed"}
|
|
>
|
|
{leftAppBarButtonIcon()}
|
|
</IconButton>
|
|
</Show>
|
|
|
|
<Show when={!showingInfoView()}>
|
|
<ContextMeter
|
|
usedTokens={tokenStats().used}
|
|
availableTokens={tokenStats().avail}
|
|
formatTokens={formatTokenTotal}
|
|
usedLabel={t("instanceShell.metrics.usedLabel")}
|
|
availableLabel={t("instanceShell.metrics.availableLabel")}
|
|
/>
|
|
</Show>
|
|
|
|
<div class="ml-auto flex items-center session-header-hints">
|
|
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
|
<PermissionNotificationBanner
|
|
instanceId={props.instance.id}
|
|
onClick={() => setPermissionModalOpen(true)}
|
|
/>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
|
|
<button
|
|
type="button"
|
|
class="connection-status-button command-palette-button"
|
|
onClick={handleCommandPaletteClick}
|
|
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
|
style={{ flex: "0 0 auto", width: "auto" }}
|
|
>
|
|
{t("instanceShell.commandPalette.button")}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
|
<span class="connection-status-shortcut-hint kbd-hint">
|
|
<Kbd shortcut="cmd+shift+p" />
|
|
</span>
|
|
|
|
<div class="ml-auto flex items-center gap-3">
|
|
<div class="connection-status-meta flex items-center gap-3">
|
|
<Show when={connectionStatus() === "connected"}>
|
|
<span class="status-indicator connected">
|
|
<span class="status-dot" />
|
|
<span class="status-text">{t("instanceShell.connection.connected")}</span>
|
|
</span>
|
|
</Show>
|
|
<Show when={connectionStatus() === "connecting"}>
|
|
<span class="status-indicator connecting">
|
|
<span class="status-dot" />
|
|
<span class="status-text">{t("instanceShell.connection.connecting")}</span>
|
|
</span>
|
|
</Show>
|
|
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
|
<span class="status-indicator disconnected">
|
|
<span class="status-dot" />
|
|
<span class="status-text">{t("instanceShell.connection.disconnected")}</span>
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
<Show when={rightDrawerState() === "floating-closed"}>
|
|
<IconButton
|
|
ref={setRightToggleButtonEl}
|
|
color="inherit"
|
|
onClick={handleRightAppBarButtonClick}
|
|
aria-label={rightAppBarButtonLabel()}
|
|
size="small"
|
|
aria-expanded={rightDrawerState() !== "floating-closed"}
|
|
>
|
|
{rightAppBarButtonIcon()}
|
|
</IconButton>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</Toolbar>
|
|
</AppBar>
|
|
</Show>
|
|
|
|
<Box
|
|
component="main"
|
|
sx={{ flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column", overflowX: "hidden" }}
|
|
class="content-area"
|
|
>
|
|
<Show
|
|
when={showingInfoView()}
|
|
fallback={
|
|
<Show
|
|
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
|
|
fallback={
|
|
<div class="flex items-center justify-center h-full">
|
|
<div class="text-center text-gray-500 dark:text-gray-400">
|
|
<p class="mb-2">{t("instanceShell.empty.title")}</p>
|
|
<p class="text-sm">{t("instanceShell.empty.description")}</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<For each={cachedSessionIds()}>
|
|
{(sessionId) => {
|
|
const isActive = () => activeSessionIdForInstance() === sessionId
|
|
return (
|
|
<div
|
|
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
|
style={{ display: isActive() ? "flex" : "none" }}
|
|
data-session-id={sessionId}
|
|
aria-hidden={!isActive()}
|
|
>
|
|
<SessionView
|
|
sessionId={sessionId}
|
|
activeSessions={activeSessions()}
|
|
instanceId={props.instance.id}
|
|
instanceFolder={props.instance.folder}
|
|
escapeInDebounce={props.escapeInDebounce}
|
|
isPhoneLayout={isPhoneLayout()}
|
|
compactPromptLayout={compactPromptLayout()}
|
|
showSidebarToggle={showEmbeddedSidebarToggle()}
|
|
onSidebarToggle={() => setLeftOpen(true)}
|
|
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
|
isActive={isActive()}
|
|
/>
|
|
</div>
|
|
)
|
|
}}
|
|
</For>
|
|
</Show>
|
|
}
|
|
>
|
|
<div class="info-view-pane flex flex-col flex-1 min-h-0 overflow-y-auto">
|
|
<InfoView instanceId={props.instance.id} />
|
|
</div>
|
|
</Show>
|
|
</Box>
|
|
</Box>
|
|
|
|
{renderRightPanel()}
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<div class="instance-shell2 flex flex-col flex-1 min-h-0">
|
|
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
|
{sessionLayout}
|
|
</Show>
|
|
</div>
|
|
|
|
<CommandPalette
|
|
open={paletteOpen()}
|
|
onClose={() => hideCommandPalette(props.instance.id)}
|
|
commands={instancePaletteCommands()}
|
|
onExecute={props.onExecuteCommand}
|
|
/>
|
|
|
|
<BackgroundProcessOutputDialog
|
|
open={showBackgroundOutput()}
|
|
instanceId={props.instance.id}
|
|
process={selectedBackgroundProcess()}
|
|
onClose={closeBackgroundOutput}
|
|
/>
|
|
|
|
<PermissionApprovalModal
|
|
instanceId={props.instance.id}
|
|
isOpen={permissionModalOpen()}
|
|
onClose={() => setPermissionModalOpen(false)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default InstanceShell2
|