Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,6 @@ ci:
- ".github/workflows/**"
- ".github/**"

task:
- changed-files:
- any-glob-to-any-file:
- ".github/workflows/**"

platform:
- changed-files:
- any-glob-to-any-file:
Expand Down
32 changes: 32 additions & 0 deletions packages/app/src/pages/session/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/utils/same"
import type { RightPanelTab } from "@/pages/session/right-panel-tabs"

const emptyTabs: string[] = []

Expand Down Expand Up @@ -192,3 +193,34 @@
}

export type Sizing = ReturnType<typeof createSizing>

/** Converts right-panel state into the CSS width applied to the shell. */
export function formatRightPanelWidth(open: boolean, width: number): string {
return open ? `${width}px` : "0px"
}

/** Creates a resize callback that marks user sizing before delegating width storage to layout state. */
export function makeRightPanelResizeHandler(
size: { touch: () => void },
layout: { rightPanel: { resize: (width: number) => void } },
): (width: number) => void {
return (width) => {
size.touch()
layout.rightPanel.resize(width)
}
}

/** Returns whether the Review inner tab row should expose the file-open shortcut. */
export function shouldShowReviewFileOpenButton(activeTab: string | undefined, hasSecondaryTabs: boolean): boolean {
return hasSecondaryTabs || activeTab !== "review"
}

/** Returns shell tabs that can be reordered by the user. Status is pinned. */
export function sortableShellTabIds(tabs: readonly RightPanelTab[]): RightPanelTab[] {
return tabs.filter((tab) => tab !== "status")
}

/** Names the file-opening transition that must activate Review before showing file-specific content. */
export function openReviewShellTab(sidePanel: { openTab: (tab: "review") => void }) {
sidePanel.openTab("review")
}
180 changes: 180 additions & 0 deletions packages/app/src/pages/session/right-panel-review-body.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { For, Show, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"

import { FileVisual, SortableTab } from "@/components/session"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { getTabReorderIndex, shouldShowReviewFileOpenButton } from "@/pages/session/helpers"

/**
* Review panel inner body: nested file-review Tabs, DragDropProvider for file tab
* reordering, and per-tab content switching (review / empty / file-tab-content).
*/
export function RightPanelReviewBody(props: {
canReview: () => boolean
hasReview: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
activeTab: () => string | undefined
activeFileTab: () => string | undefined
openedTabs: () => string[]
showSecondaryReviewTabs: () => boolean
openTab: (tab: string) => void
openFilePicker: (onOpenFile?: () => void) => void
showAllFiles: () => void
tabs: {
all: () => string[]
close: (tab: string) => void
move: (tab: string, index: number) => void
}
pathFromTab: (tab: string) => string | undefined
reviewTab: () => boolean
}) {
const language = useLanguage()
const command = useCommand()
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})

const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}

const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (!draggable || !droppable) return

const currentTabs = props.tabs.all()
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
if (toIndex === undefined) return
props.tabs.move(draggable.id.toString(), toIndex)
}

const handleDragEnd = () => {
setStore("activeDraggable", undefined)
}

return (
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-bg-base">
<div class="size-full min-w-0 h-full bg-bg-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={props.activeTab()} onChange={props.openTab}>
<div class="sticky top-0 shrink-0 flex">
<Show
when={props.showSecondaryReviewTabs()}
fallback={
<Show when={shouldShowReviewFileOpenButton(props.activeTab(), false)}>
<div class="w-full bg-bg-base flex items-center justify-end px-3 py-1.5">
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => props.openFilePicker(props.showAllFiles)}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</div>
</Show>
}
>
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el })
onCleanup(stop)
}}
>
<Show when={props.reviewTab() && props.canReview()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={props.openedTabs()}>
<For each={props.openedTabs()}>
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs.close} />}
</For>
</SortableProvider>
<div class="bg-bg-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => props.openFilePicker(props.showAllFiles)}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</Show>
</div>

<Show when={props.reviewTab() && props.canReview()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>

<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center">
<div class="text-body text-fg-weak max-w-56">{language.t("session.files.selectToOpen")}</div>
</div>
</div>
</Show>
</Tabs.Content>

<Show when={props.activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
Comment thread
Astro-Han marked this conversation as resolved.
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = props.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
Comment thread
Astro-Han marked this conversation as resolved.
</DragOverlay>
</DragDropProvider>
</div>
</div>
)
}
146 changes: 146 additions & 0 deletions packages/app/src/pages/session/right-panel-tab-strip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { For, Match, Show, Switch } from "solid-js"
import { Portal } from "solid-js/web"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { SortableProvider } from "@thisbeyond/solid-dnd"

import { SessionContextUsage } from "@/components/session-context-usage"
import { ShellTab, SortableShellTab } from "@/components/session"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { sortableShellTabIds } from "@/pages/session/helpers"
import type { RightPanelShellIconName, RightPanelTab, ShellTabIcon } from "@/pages/session/right-panel-tabs"

interface ShellTabDef {
value: RightPanelTab
label: string
icon: ShellTabIcon
closable: boolean
}

/** Maps right-panel tab names to their shell icon components. */
function RightPanelShellIcon(props: { icon: ShellTabIcon; active?: boolean }) {
return (
<Switch>
<Match when={props.icon.kind === "indicator"}>
<SessionContextUsage variant="indicator" />
</Match>
<Match when={props.icon.kind === "icon" && props.icon.name === "status"}>
<Icon name="status" class="text-fg-weaker" />
</Match>
<Match when={props.icon.kind === "icon" && props.icon.name === "folder"}>
<Icon name="folder" class="text-fg-weaker" />
</Match>
<Match when={props.icon.kind === "icon" && props.icon.name === "review"}>
<Icon name={props.active ? "review-active" : "review"} class="text-fg-weaker" />
</Match>
<Match when={props.icon.kind === "icon" && props.icon.name === "terminal"}>
<Icon name={props.active ? "terminal-active" : "terminal"} class="text-fg-weaker" />
</Match>
</Switch>
)
}

/**
* Portalled tab strip rendered into the titlebar chrome. Owns the sortable
* shell-tab chips, the `+` dropdown for adding tabs, and the spacer that
* pushes the add button to the right edge.
*/
export function RightPanelTabStrip(props: {
tabsPortalMount: () => HTMLElement | undefined
shellTabs: () => ShellTabDef[]
activeTab: () => string | undefined
openShellTabs: () => RightPanelTab[]
closeTab: (tab: RightPanelTab) => void
openTab: (tab: RightPanelTab) => void
closableMissingTabs: () => {
value: RightPanelTab
label: string
iconName: RightPanelShellIconName
keybind?: string
}[]
openFilePicker: (onOpenFile?: () => void) => void
showAllFiles: () => void
}) {
const language = useLanguage()
const command = useCommand()
return (
<Show when={props.tabsPortalMount()}>
{(mount) => (
<Portal mount={mount()}>
{/* Tabs.List portals into <Titlebar>'s `pawwork-titlebar-tabs` slot so the
tabs visually sit on the window chrome and the panel's body border-left
meets the titlebar separator with no gap. Portal keeps Tabs/Sortable/DnD
contexts intact via the virtual tree. The slot owns the titlebar height
(--shell-titlebar-height, 44px on desktop) and centers this list
vertically; no border-b because the titlebar slot owns the bottom-edge
alignment with the panel body below. */}
<Tabs.List class="h-full shrink-0 px-1 py-0 items-center">
<SortableProvider ids={sortableShellTabIds(props.openShellTabs())}>
<For each={props.shellTabs()}>
{(tab) => (
<Show
when={tab.value !== "status"}
fallback={
<ShellTab
value={tab.value}
label={tab.label}
closable={tab.closable}
onClose={props.closeTab}
icon={<RightPanelShellIcon icon={tab.icon} active={props.activeTab() === tab.value} />}
/>
}
>
<SortableShellTab
value={tab.value}
label={tab.label}
closable={tab.closable}
onClose={props.closeTab}
icon={<RightPanelShellIcon icon={tab.icon} active={props.activeTab() === tab.value} />}
/>
</Show>
)}
</For>
</SortableProvider>
{/* Spacer pushes the `+` button to the rail's right edge so
the chip strip reads left-justified and `+` lives at the
end of the rail (matching docs/design/ui_kits/desktop/RightPanel.jsx). */}
<div class="flex-1" />
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
as={IconButton}
icon="plus-small"
variant="ghost"
class="shrink-0"
aria-label={language.t("session.panel.addTab")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => props.openFilePicker(props.showAllFiles)}>
<Icon name="open-file" />
<DropdownMenu.ItemLabel>{language.t("command.file.open")}</DropdownMenu.ItemLabel>
<span class="ml-auto text-body text-fg-weaker">{command.keybind("file.open")}</span>
</DropdownMenu.Item>
<Show when={props.closableMissingTabs().length > 0}>
<DropdownMenu.Separator />
<For each={props.closableMissingTabs()}>
{(tab) => (
<DropdownMenu.Item onSelect={() => props.openTab(tab.value)}>
<Icon name={tab.iconName} />
<DropdownMenu.ItemLabel>{tab.label}</DropdownMenu.ItemLabel>
{tab.keybind && <span class="ml-auto text-body text-fg-weaker">{tab.keybind}</span>}
</DropdownMenu.Item>
)}
</For>
</Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Tabs.List>
</Portal>
)}
</Show>
)
}
Loading
Loading