mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 04:38:31 +00:00
feat(app): terminal improvements (#34747)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
parent
78bca5c707
commit
7cf8d1ca6d
10 changed files with 810 additions and 25 deletions
|
|
@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression"
|
|||
const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
|
||||
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
|
|
@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
|
|||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(panel).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
provider: unknown
|
||||
|
|
@ -55,6 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
|
|
@ -65,6 +66,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/file/content" && config.fileContent)
|
||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/api/reference")
|
||||
return json(route, {
|
||||
location: { directory: config.directory, project: { id: (config.project as { id?: string }).id, directory: config.directory } },
|
||||
data: [],
|
||||
})
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
|
@ -75,6 +81,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
return json(route, session ?? {})
|
||||
}
|
||||
|
||||
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
||||
if (projectMatch) return json(route, config.project)
|
||||
|
||||
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (messageMatch) {
|
||||
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
import type { JSX } from "solid-js"
|
||||
import { Show, createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
|
||||
export function SortableTerminalTabV2(props: {
|
||||
terminal: LocalPTY
|
||||
index: () => number
|
||||
newLayout: boolean
|
||||
onClose?: () => void
|
||||
}): JSX.Element {
|
||||
const terminal = useTerminal()
|
||||
const language = useLanguage()
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.terminal.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
editing: false,
|
||||
title: props.terminal.title,
|
||||
menuOpen: false,
|
||||
menuPosition: { x: 0, y: 0 },
|
||||
blurEnabled: false,
|
||||
})
|
||||
let input: HTMLInputElement | undefined
|
||||
let blurFrame: number | undefined
|
||||
let editRequested = false
|
||||
|
||||
const isDefaultTitle = () => {
|
||||
const number = props.terminal.titleNumber
|
||||
if (!Number.isFinite(number) || number <= 0) return false
|
||||
return isDefaultTerminalTitle(props.terminal.title, number)
|
||||
}
|
||||
|
||||
const label = () => {
|
||||
language.locale()
|
||||
if (props.terminal.title && !isDefaultTitle()) return props.terminal.title
|
||||
|
||||
const number = props.terminal.titleNumber
|
||||
if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
|
||||
if (props.terminal.title) return props.terminal.title
|
||||
return language.t("terminal.title")
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
const count = terminal.all().length
|
||||
void terminal.close(props.terminal.id)
|
||||
if (count === 1) {
|
||||
props.onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
if (store.editing) return
|
||||
terminal.open(props.terminal.id)
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
focusTerminalById(props.terminal.id)
|
||||
}
|
||||
|
||||
const edit = (e?: Event) => {
|
||||
if (e) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
setStore("blurEnabled", false)
|
||||
setStore("title", props.terminal.title)
|
||||
setStore("editing", true)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
if (!store.blurEnabled) return
|
||||
|
||||
const value = store.title.trim()
|
||||
if (value && value !== props.terminal.title) {
|
||||
terminal.update({ id: props.terminal.id, title: value })
|
||||
}
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
save()
|
||||
return
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
}
|
||||
}
|
||||
|
||||
const menu = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setStore("menuPosition", { x: e.clientX, y: e.clientY })
|
||||
setStore("menuOpen", true)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.editing) return
|
||||
if (!input) return
|
||||
input.focus()
|
||||
input.select()
|
||||
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
|
||||
blurFrame = requestAnimationFrame(() => {
|
||||
blurFrame = undefined
|
||||
setStore("blurEnabled", true)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (blurFrame === undefined) return
|
||||
cancelAnimationFrame(blurFrame)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
class="outline-none focus:outline-none focus-visible:outline-none"
|
||||
classList={{
|
||||
"h-full flex items-center": props.newLayout,
|
||||
"h-full": !props.newLayout,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={props.newLayout}
|
||||
fallback={
|
||||
<div class="relative h-full">
|
||||
<Tabs.Trigger
|
||||
value={props.terminal.id}
|
||||
onClick={focus}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onContextMenu={menu}
|
||||
class="!shadow-none"
|
||||
classes={{
|
||||
button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0",
|
||||
}}
|
||||
closeButton={
|
||||
<IconButton
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
close()
|
||||
}}
|
||||
aria-label={language.t("terminal.close")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span onDblClick={edit} classList={{ invisible: store.editing }}>
|
||||
{label()}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
<Show when={store.editing}>
|
||||
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
|
||||
<input
|
||||
ref={input}
|
||||
type="text"
|
||||
value={store.title}
|
||||
onInput={(e) => setStore("title", e.currentTarget.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={keydown}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
class="bg-transparent border-none outline-none text-sm min-w-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
class="fixed"
|
||||
style={{
|
||||
left: `${store.menuPosition.x}px`,
|
||||
top: `${store.menuPosition.y}px`,
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!editRequested) return
|
||||
e.preventDefault()
|
||||
editRequested = false
|
||||
requestAnimationFrame(() => edit())
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
|
||||
<Icon name="edit" class="w-4 h-4 mr-2" />
|
||||
{language.t("common.rename")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={close}>
|
||||
<Icon name="close" class="w-4 h-4 mr-2" />
|
||||
{language.t("common.close")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MenuV2.Context>
|
||||
<MenuV2.Context.Trigger class="relative" as="div">
|
||||
<Tabs.Trigger
|
||||
value={props.terminal.id}
|
||||
onClick={focus}
|
||||
closeButton={
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
close()
|
||||
}}
|
||||
aria-label={language.t("terminal.close")}
|
||||
/>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={close}
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
data-slot="terminal-tab-title"
|
||||
onDblClick={edit}
|
||||
classList={{ invisible: store.editing }}
|
||||
>
|
||||
{label()}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
<Show when={store.editing}>
|
||||
<div class="absolute inset-0 flex items-center bg-v2-background-bg-layer-01 z-10 pointer-events-auto rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-muted)] px-2">
|
||||
<input
|
||||
ref={input}
|
||||
type="text"
|
||||
value={store.title}
|
||||
onInput={(e) => setStore("title", e.currentTarget.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={keydown}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</MenuV2.Context.Trigger>
|
||||
<MenuV2.Context.Portal>
|
||||
<MenuV2.Context.Content
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!editRequested) return
|
||||
e.preventDefault()
|
||||
editRequested = false
|
||||
requestAnimationFrame(() => edit())
|
||||
}}
|
||||
>
|
||||
<MenuV2.Item onSelect={() => (editRequested = true)}>{language.t("common.rename")}</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={close}>{language.t("common.close")}</MenuV2.Item>
|
||||
</MenuV2.Context.Content>
|
||||
</MenuV2.Context.Portal>
|
||||
</MenuV2.Context>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,8 +17,6 @@ import { createTabPromptState } from "@/context/prompt"
|
|||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||
|
||||
const sortableTransition = { duration: 0 }
|
||||
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
|
|
|
|||
|
|
@ -71,11 +71,13 @@ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/
|
|||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
|
||||
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
|
||||
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
|
||||
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
|
||||
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
|
|
@ -427,6 +429,12 @@ export default function Page() {
|
|||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const size = createSizing()
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
|
||||
const terminalOpen = createMemo(() => view().terminal.opened())
|
||||
const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen())
|
||||
const desktopInlineTerminalOnlyOpen = createMemo(
|
||||
() => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(),
|
||||
)
|
||||
const desktopFileTreeOpen = createMemo(
|
||||
() =>
|
||||
isDesktop() &&
|
||||
|
|
@ -435,13 +443,23 @@ export default function Page() {
|
|||
opened: layout.fileTree.opened(),
|
||||
}),
|
||||
)
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
const desktopSessionResizeOpen = createMemo(
|
||||
() => (newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen()),
|
||||
)
|
||||
const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen())
|
||||
const sessionPanelWidth = createMemo(() => {
|
||||
if (!desktopSidePanelOpen()) return "100%"
|
||||
if (desktopReviewOpen()) return `${layout.session.width()}px`
|
||||
if (desktopSessionResizeOpen()) return `${layout.session.width()}px`
|
||||
return `calc(100% - ${layout.fileTree.width()}px)`
|
||||
})
|
||||
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
|
||||
const desktopV2PanelLayout = createMemo(() =>
|
||||
sessionPanelLayout({
|
||||
review: desktopV2ReviewOpen(),
|
||||
terminal: desktopTerminalOpen(),
|
||||
files: desktopFileTreeOpen(),
|
||||
}),
|
||||
)
|
||||
|
||||
function normalizeTab(tab: string) {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
|
|
@ -2093,7 +2111,7 @@ export default function Page() {
|
|||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!size.active() && !ui.reviewSnap,
|
||||
!size.active() && !ui.reviewSnap && !desktopInlineTerminalOnlyOpen(),
|
||||
}}
|
||||
style={{
|
||||
width: sessionPanelWidth(),
|
||||
|
|
@ -2113,7 +2131,7 @@ export default function Page() {
|
|||
</SessionPanelFrame>
|
||||
)}
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
<Show when={desktopSessionResizeOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
classList={{
|
||||
|
|
@ -2132,22 +2150,72 @@ export default function Page() {
|
|||
</Show>
|
||||
</div>
|
||||
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={() => (newSessionDesign() ? reviewPanelV2() : reviewPanel())}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
/>
|
||||
<Show when={!newSessionDesign()}>
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={newSessionDesign()}>
|
||||
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
|
||||
<div class="min-w-0 h-full flex flex-1 flex-col">
|
||||
<Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanelV2}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
stacked={desktopV2PanelLayout().stacked}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={desktopV2PanelLayout().stacked}>
|
||||
<div class="relative h-2 shrink-0" onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={layout.terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
size.touch()
|
||||
layout.terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => view().terminal.close()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={terminalOpen()}>
|
||||
<div class="min-h-0 flex-1">
|
||||
<TerminalPanelV2 stacked={desktopV2PanelLayout().stacked} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<TerminalPanel />
|
||||
<Show when={!newSessionDesign()}>
|
||||
<TerminalPanel />
|
||||
</Show>
|
||||
</SessionRouteFrame>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
19
packages/app/src/pages/session/session-panel-layout.test.ts
Normal file
19
packages/app/src/pages/session/session-panel-layout.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { sessionPanelLayout } from "./session-panel-layout"
|
||||
|
||||
describe("sessionPanelLayout", () => {
|
||||
test("keeps one V2 owner while changing panel geometry", () => {
|
||||
expect(sessionPanelLayout({ review: false, terminal: false, files: false })).toEqual({
|
||||
visible: false,
|
||||
stacked: false,
|
||||
})
|
||||
expect(sessionPanelLayout({ review: false, terminal: true, files: false })).toEqual({
|
||||
visible: true,
|
||||
stacked: false,
|
||||
})
|
||||
expect(sessionPanelLayout({ review: true, terminal: true, files: false })).toEqual({
|
||||
visible: true,
|
||||
stacked: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
6
packages/app/src/pages/session/session-panel-layout.ts
Normal file
6
packages/app/src/pages/session/session-panel-layout.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function sessionPanelLayout(input: { review: boolean; terminal: boolean; files: boolean }) {
|
||||
return {
|
||||
visible: input.review || input.terminal || input.files,
|
||||
stacked: input.review && input.terminal,
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ export function SessionSidePanel(props: {
|
|||
focusReviewDiff: (path: string) => void
|
||||
reviewSnap: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
|
|
@ -219,8 +220,10 @@ export function SessionSidePanel(props: {
|
|||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
aria-hidden={!open()}
|
||||
inert={!open()}
|
||||
class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
|
||||
class="relative min-w-0 flex overflow-hidden bg-background-base"
|
||||
classList={{
|
||||
"h-full shrink-0": !props.stacked,
|
||||
"min-h-0 flex-1": props.stacked,
|
||||
"pointer-events-none": !open(),
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active() && !props.reviewSnap,
|
||||
|
|
|
|||
364
packages/app/src/pages/session/terminal-panel-v2.tsx
Normal file
364
packages/app/src/pages/session/terminal-panel-v2.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
|
||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
|
||||
import { SortableTerminalTabV2 } from "@/components/session/session-sortable-terminal-tab-v2"
|
||||
import { Terminal } from "@/components/terminal"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
const delays = [120, 240]
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const settings = useSettings()
|
||||
const { workspaceKey, view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const newLayout = createMemo(() => settings.general.newLayoutDesigns())
|
||||
const opened = createMemo(() => view().terminal.opened())
|
||||
const size = createSizing()
|
||||
const height = createMemo(() => layout.terminal.height())
|
||||
const close = () => view().terminal.close()
|
||||
let root: HTMLDivElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
recovered: {} as Record<string, boolean>,
|
||||
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
|
||||
})
|
||||
|
||||
const max = () => store.view * 0.6
|
||||
const pane = () => Math.min(height(), max())
|
||||
const stacked = createMemo(() => isDesktop() && props.stacked)
|
||||
const panelHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px"))
|
||||
const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
|
||||
const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
|
||||
const port = window.visualViewport
|
||||
|
||||
sync()
|
||||
makeEventListener(window, "resize", sync)
|
||||
if (port) makeEventListener(port, "resize", sync)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!opened()) {
|
||||
setStore("autoCreated", false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
|
||||
terminal.new()
|
||||
setStore("autoCreated", true)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => terminal.all().length,
|
||||
(count, prevCount) => {
|
||||
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
|
||||
if (!opened()) return
|
||||
close()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const focus = (id: string) => {
|
||||
focusTerminalById(id)
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
})
|
||||
|
||||
const timers = delays.map((ms) =>
|
||||
window.setTimeout(() => {
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
}, ms),
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
for (const timer of timers) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [opened(), terminal.active()] as const,
|
||||
([next, id]) => {
|
||||
if (!next || !id) return
|
||||
const stop = focus(id)
|
||||
onCleanup(stop)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (opened()) return
|
||||
const active = document.activeElement
|
||||
if (!(active instanceof HTMLElement)) return
|
||||
if (!root?.contains(active)) return
|
||||
active.blur()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const dir = sdk().directory
|
||||
if (!dir) return
|
||||
if (!terminal.ready()) return
|
||||
language.locale()
|
||||
|
||||
setTerminalHandoff(
|
||||
workspaceKey(),
|
||||
terminal.all().map((pty) =>
|
||||
terminalTabLabel({
|
||||
title: pty.title,
|
||||
titleNumber: pty.titleNumber,
|
||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const handoff = createMemo(() => {
|
||||
const dir = sdk().directory
|
||||
if (!dir) return []
|
||||
return getTerminalHandoff(workspaceKey()) ?? []
|
||||
})
|
||||
|
||||
const all = terminal.all
|
||||
const ids = createMemo(() => all().map((pty) => pty.id))
|
||||
|
||||
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
|
||||
if (store.recovered[key]) return
|
||||
setStore("recovered", key, true)
|
||||
void clone(id)
|
||||
}
|
||||
|
||||
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
|
||||
return String(pty.titleNumber || pty.title || pty.id)
|
||||
}
|
||||
|
||||
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
|
||||
setStore("recovered", key, false)
|
||||
trim(id)
|
||||
}
|
||||
|
||||
const handleTerminalDragEnd = () => {
|
||||
const activeId = terminal.active()
|
||||
if (!activeId) return
|
||||
requestAnimationFrame(() => {
|
||||
if (terminal.active() !== activeId) return
|
||||
focusTerminalById(activeId)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={root}
|
||||
id="terminal-panel"
|
||||
role="region"
|
||||
aria-label={language.t("terminal.title")}
|
||||
aria-hidden={!opened()}
|
||||
inert={!opened()}
|
||||
class="relative shrink-0 overflow-hidden bg-background-stronger"
|
||||
classList={{
|
||||
"w-full": !isDesktop() || stacked(),
|
||||
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
|
||||
"w-0 h-full pointer-events-none": isDesktop() && !opened(),
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": isDesktop() && newLayout(),
|
||||
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
||||
!isDesktop() && !size.active(),
|
||||
}}
|
||||
style={{ height: panelHeight() }}
|
||||
>
|
||||
<div classList={{ "md:hidden": !stacked(), hidden: stacked() }} onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
classList={{
|
||||
"-top-1": newLayout(),
|
||||
}}
|
||||
direction="vertical"
|
||||
size={pane()}
|
||||
min={100}
|
||||
max={max()}
|
||||
collapseThreshold={50}
|
||||
onResize={(next) => {
|
||||
size.touch()
|
||||
layout.terminal.resize(next)
|
||||
}}
|
||||
onCollapse={close}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
classList={{
|
||||
"border-t border-border-weak-base": opened() && !isDesktop(),
|
||||
"border-t border-border-weaker-base": opened() && stacked() && !newLayout(),
|
||||
"border-l border-border-weaker-base": opened() && isDesktop() && !newLayout(),
|
||||
"pointer-events-none": !opened(),
|
||||
}}
|
||||
style={{ height: contentHeight() }}
|
||||
>
|
||||
<Show
|
||||
when={terminal.ready()}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="flex-1" />
|
||||
<div class="text-text-weak pr-2">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) =>
|
||||
event.target instanceof Element &&
|
||||
!!event.target.closest('[data-slot="tabs-trigger-close-button"], input, [contenteditable="true"]'),
|
||||
}),
|
||||
]}
|
||||
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => tabList ?? null })]}
|
||||
plugins={(defaults) => [
|
||||
...defaults.filter((plugin) => plugin !== Accessibility),
|
||||
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
|
||||
Feedback.configure({ dropAnimation: null }),
|
||||
]}
|
||||
onDragEnd={(event) => {
|
||||
const source = event.operation.source
|
||||
if (!event.canceled && isSortable(source) && source.initialIndex !== source.index) {
|
||||
terminal.move(source.id.toString(), source.index)
|
||||
}
|
||||
handleTerminalDragEnd()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant={newLayout() ? "normal" : "alt"}
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"}
|
||||
>
|
||||
<Tabs.List ref={tabList} class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}>
|
||||
<For each={all()}>
|
||||
{(pty, index) => (
|
||||
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
|
||||
)}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Show
|
||||
when={newLayout()}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.new")}
|
||||
keybind={command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipV2
|
||||
value={
|
||||
<>
|
||||
{language.t("command.terminal.new")}
|
||||
<Show when={newTerminalKeybind().length > 0}>
|
||||
<KeybindV2 keys={newTerminalKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={opened() && terminal.active()} keyed>
|
||||
{(id) => {
|
||||
const ops = terminal.bind()
|
||||
return (
|
||||
<Show when={all().find((pty) => pty.id === id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={opened()}
|
||||
class="!px-[14px]"
|
||||
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||
onCleanup={ops.update}
|
||||
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
@ -135,7 +135,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
#review-panel &[data-variant="normal"][data-orientation="horizontal"] {
|
||||
#review-panel &[data-variant="normal"][data-orientation="horizontal"],
|
||||
#terminal-panel &[data-variant="normal"][data-orientation="horizontal"] {
|
||||
background-color: var(--background-stronger);
|
||||
|
||||
[data-slot="tabs-list"] {
|
||||
|
|
@ -267,6 +268,51 @@
|
|||
}
|
||||
}
|
||||
|
||||
#terminal-panel &[data-variant="normal"][data-orientation="horizontal"] {
|
||||
[data-slot="tabs-list"] {
|
||||
height: 52px;
|
||||
padding-right: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="tabs-trigger-wrapper"] {
|
||||
height: 28px;
|
||||
padding-inline: 8px;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="tabs-trigger"] {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
&:has([data-slot="tabs-trigger-close-button"]) {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
&:has([data-selected]) {
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="terminal-tab-title"] {
|
||||
font-family: Inter, sans-serif;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="alt"] {
|
||||
[data-slot="tabs-list"] {
|
||||
padding-left: 24px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue