## Summary - add a per-session Yolo mode toggle for permission prompts and persist its state - move the control into the Status tab with clearer copy, an info tooltip, and a visible header badge when it is enabled - auto-accept queued permissions for any yolo-enabled session in the instance, not only the currently focused session ## Why - keeps this risky mode explicit and easy to audit from the session status area - matches the expected multi-session desktop behavior when several sessions stay active in parallel ## Testing - npm run typecheck --workspace @codenomad/ui - npm run build --workspace @codenomad/ui Closes #18
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
import { For, Show, type Accessor, type Component } from "solid-js"
|
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
|
import { Accordion } from "@kobalte/core"
|
|
import { Tooltip } from "@kobalte/core/tooltip"
|
|
import Switch from "@suid/material/Switch"
|
|
|
|
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
|
|
|
import type { Instance } from "../../../../../types/instance"
|
|
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
|
import type { Session } from "../../../../../types/session"
|
|
|
|
import ContextUsagePanel from "../../../../session/context-usage-panel"
|
|
import { TodoListView } from "../../../../tool-call/renderers/todo"
|
|
import InstanceServiceStatus from "../../../../instance-service-status"
|
|
import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept"
|
|
|
|
interface StatusTabProps {
|
|
t: (key: string, vars?: Record<string, any>) => string
|
|
|
|
instanceId: string
|
|
instance: Instance
|
|
|
|
activeSessionId: Accessor<string | null>
|
|
activeSession: Accessor<Session | null>
|
|
activeSessionDiffs: Accessor<any[] | undefined>
|
|
|
|
latestTodoState: Accessor<ToolState | null>
|
|
|
|
backgroundProcessList: Accessor<BackgroundProcess[]>
|
|
onOpenBackgroundOutput: (process: BackgroundProcess) => void
|
|
onStopBackgroundProcess: (processId: string) => Promise<void> | void
|
|
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
|
|
|
|
expandedItems: Accessor<string[]>
|
|
onExpandedItemsChange: (values: string[]) => void
|
|
|
|
onOpenChangesTab: (file?: string) => void
|
|
}
|
|
|
|
const StatusTab: Component<StatusTabProps> = (props) => {
|
|
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
|
|
|
const renderYoloModeSection = () => {
|
|
const session = props.activeSession()
|
|
if (!session) {
|
|
return (
|
|
<div class="right-panel-empty right-panel-empty--left">
|
|
<span class="text-xs">{props.t("instanceShell.yoloMode.noSessionSelected")}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div class="rounded-md border border-base bg-surface-secondary px-3 py-2">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-medium text-primary">{props.t("instanceShell.yoloMode.title")}</div>
|
|
<p class="mt-1 text-xs text-secondary">{props.t("instanceShell.yoloMode.description")}</p>
|
|
</div>
|
|
<Switch
|
|
checked={isPermissionAutoAcceptEnabled(props.instanceId, session.id)}
|
|
color="warning"
|
|
size="small"
|
|
inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }}
|
|
onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderStatusSessionChanges = () => {
|
|
const sessionId = props.activeSessionId()
|
|
if (!sessionId || sessionId === "info") {
|
|
return (
|
|
<div class="right-panel-empty right-panel-empty--left">
|
|
<span class="text-xs">{props.t("instanceShell.sessionChanges.noSessionSelected")}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const diffs = props.activeSessionDiffs()
|
|
if (diffs === undefined) {
|
|
return (
|
|
<div class="right-panel-empty right-panel-empty--left">
|
|
<span class="text-xs">{props.t("instanceShell.sessionChanges.loading")}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!Array.isArray(diffs) || diffs.length === 0) {
|
|
return (
|
|
<div class="right-panel-empty right-panel-empty--left">
|
|
<span class="text-xs">{props.t("instanceShell.sessionChanges.empty")}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
|
|
const totals = sorted.reduce(
|
|
(acc, item) => {
|
|
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
|
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
|
return acc
|
|
},
|
|
{ additions: 0, deletions: 0 },
|
|
)
|
|
|
|
return (
|
|
<div class="flex flex-col gap-3 min-h-0">
|
|
<div class="flex items-center justify-between gap-2 text-[11px] text-secondary">
|
|
<span>{props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })}</span>
|
|
<span class="flex items-center gap-2">
|
|
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${totals.additions}`}</span>
|
|
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${totals.deletions}`}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="rounded-md border border-base bg-surface-secondary p-2 max-h-[40vh] overflow-y-auto">
|
|
<div class="flex flex-col">
|
|
<For each={sorted}>
|
|
{(item) => (
|
|
<button
|
|
type="button"
|
|
class="border-b border-base last:border-b-0 text-left hover:bg-surface-muted rounded-sm"
|
|
onClick={() => props.onOpenChangesTab(item.file)}
|
|
title={props.t("instanceShell.sessionChanges.actions.show")}
|
|
>
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div
|
|
class="text-xs font-mono text-primary min-w-0 flex-1 overflow-hidden whitespace-nowrap"
|
|
title={item.file}
|
|
style="text-overflow: ellipsis; direction: rtl; text-align: left; unicode-bidi: plaintext;"
|
|
>
|
|
{item.file}
|
|
</div>
|
|
<div class="flex items-center gap-2 text-[11px] flex-shrink-0">
|
|
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${item.additions}`}</span>
|
|
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${item.deletions}`}</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderPlanSectionContent = () => {
|
|
const sessionId = props.activeSessionId()
|
|
if (!sessionId || sessionId === "info") {
|
|
return (
|
|
<div class="right-panel-empty right-panel-empty--left">
|
|
<span class="text-xs">{props.t("instanceShell.plan.noSessionSelected")}</span>
|
|
</div>
|
|
)
|
|
}
|
|
const todoState = props.latestTodoState()
|
|
if (!todoState) {
|
|
return (
|
|
<div class="right-panel-empty right-panel-empty--left">
|
|
<span class="text-xs">{props.t("instanceShell.plan.empty")}</span>
|
|
</div>
|
|
)
|
|
}
|
|
return <TodoListView state={todoState} emptyLabel={props.t("instanceShell.plan.empty")} showStatusLabel={false} />
|
|
}
|
|
|
|
const renderBackgroundProcesses = () => {
|
|
const processes = props.backgroundProcessList()
|
|
if (processes.length === 0) {
|
|
return (
|
|
<div class="right-panel-empty right-panel-empty--left">
|
|
<span class="text-xs">{props.t("instanceShell.backgroundProcesses.empty")}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div class="flex flex-col gap-2">
|
|
<For each={processes}>
|
|
{(process) => (
|
|
<div class="status-process-card">
|
|
<div class="status-process-header">
|
|
<span class="status-process-title">{process.title}</span>
|
|
<div class="status-process-meta">
|
|
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
|
<Show when={typeof process.outputSizeBytes === "number"}>
|
|
<span>
|
|
{props.t("instanceShell.backgroundProcesses.output", {
|
|
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
|
|
})}
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
<div class="status-process-actions">
|
|
<button
|
|
type="button"
|
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
|
onClick={() => props.onOpenBackgroundOutput(process)}
|
|
aria-label={props.t("instanceShell.backgroundProcesses.actions.output")}
|
|
title={props.t("instanceShell.backgroundProcesses.actions.output")}
|
|
>
|
|
<TerminalSquare class="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
|
disabled={process.status !== "running"}
|
|
onClick={() => props.onStopBackgroundProcess(process.id)}
|
|
aria-label={props.t("instanceShell.backgroundProcesses.actions.stop")}
|
|
title={props.t("instanceShell.backgroundProcesses.actions.stop")}
|
|
>
|
|
<XOctagon class="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
|
onClick={() => props.onTerminateBackgroundProcess(process.id)}
|
|
aria-label={props.t("instanceShell.backgroundProcesses.actions.terminate")}
|
|
title={props.t("instanceShell.backgroundProcesses.actions.terminate")}
|
|
>
|
|
<Trash2 class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const statusSections = [
|
|
{
|
|
id: "yolo-mode",
|
|
labelKey: "instanceShell.rightPanel.sections.yoloMode",
|
|
tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip",
|
|
render: renderYoloModeSection,
|
|
},
|
|
{
|
|
id: "session-changes",
|
|
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
|
tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip",
|
|
render: renderStatusSessionChanges,
|
|
},
|
|
{
|
|
id: "plan",
|
|
labelKey: "instanceShell.rightPanel.sections.plan",
|
|
tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip",
|
|
render: renderPlanSectionContent,
|
|
},
|
|
{
|
|
id: "background-processes",
|
|
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
|
tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip",
|
|
render: renderBackgroundProcesses,
|
|
},
|
|
{
|
|
id: "mcp",
|
|
labelKey: "instanceShell.rightPanel.sections.mcp",
|
|
tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip",
|
|
render: () => (
|
|
<InstanceServiceStatus
|
|
initialInstance={props.instance}
|
|
sections={["mcp"]}
|
|
showSectionHeadings={false}
|
|
class="space-y-2"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
id: "lsp",
|
|
labelKey: "instanceShell.rightPanel.sections.lsp",
|
|
tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip",
|
|
render: () => (
|
|
<InstanceServiceStatus
|
|
initialInstance={props.instance}
|
|
sections={["lsp"]}
|
|
showSectionHeadings={false}
|
|
class="space-y-2"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
id: "plugins",
|
|
labelKey: "instanceShell.rightPanel.sections.plugins",
|
|
tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip",
|
|
render: () => (
|
|
<InstanceServiceStatus
|
|
initialInstance={props.instance}
|
|
sections={["plugins"]}
|
|
showSectionHeadings={false}
|
|
class="space-y-2"
|
|
/>
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div class="status-tab-container">
|
|
<Show when={props.activeSession()}>
|
|
{(activeSession) => (
|
|
<ContextUsagePanel instanceId={props.instanceId} sessionId={activeSession().id} class="status-tab-context-panel" />
|
|
)}
|
|
</Show>
|
|
|
|
<Accordion.Root
|
|
class="right-panel-accordion"
|
|
collapsible
|
|
multiple
|
|
value={props.expandedItems()}
|
|
onChange={props.onExpandedItemsChange}
|
|
>
|
|
<For each={statusSections}>
|
|
{(section) => (
|
|
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
|
<Accordion.Header class="right-panel-accordion-header-row">
|
|
<Accordion.Trigger class="right-panel-accordion-trigger">
|
|
<span class="section-left">
|
|
<span class="section-label">{props.t(section.labelKey)}</span>
|
|
</span>
|
|
<ChevronDown
|
|
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
|
/>
|
|
</Accordion.Trigger>
|
|
<Tooltip openDelay={200} gutter={4} placement="top">
|
|
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(section.tooltipKey)}>
|
|
<Info class="section-info-icon" />
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Portal>
|
|
<Tooltip.Content class="section-info-tooltip">{props.t(section.tooltipKey)}</Tooltip.Content>
|
|
</Tooltip.Portal>
|
|
</Tooltip>
|
|
</Accordion.Header>
|
|
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
|
</Accordion.Item>
|
|
)}
|
|
</For>
|
|
</Accordion.Root>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default StatusTab
|