fix(app): toggle file tree and review panel better ux (#12481)
This commit is contained in:
@@ -544,11 +544,7 @@ export function SessionHeader() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="group/file-tree-toggle size-6 p-0"
|
class="group/file-tree-toggle size-6 p-0"
|
||||||
onClick={() => {
|
onClick={() => layout.fileTree.toggle()}
|
||||||
const opening = !layout.fileTree.opened()
|
|
||||||
if (opening && !view().reviewPanel.opened()) view().reviewPanel.open()
|
|
||||||
layout.fileTree.toggle()
|
|
||||||
}}
|
|
||||||
aria-label={language.t("command.fileTree.toggle")}
|
aria-label={language.t("command.fileTree.toggle")}
|
||||||
aria-expanded={layout.fileTree.opened()}
|
aria-expanded={layout.fileTree.opened()}
|
||||||
aria-controls="file-tree-panel"
|
aria-controls="file-tree-panel"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function groupFor(id: string): KeybindGroup {
|
|||||||
if (id === PALETTE_ID) return "General"
|
if (id === PALETTE_ID) return "General"
|
||||||
if (id.startsWith("terminal.")) return "Terminal"
|
if (id.startsWith("terminal.")) return "Terminal"
|
||||||
if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
|
if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
|
||||||
if (id.startsWith("file.")) return "Navigation"
|
if (id.startsWith("file.") || id.startsWith("fileTree.")) return "Navigation"
|
||||||
if (id.startsWith("prompt.")) return "Prompt"
|
if (id.startsWith("prompt.")) return "Prompt"
|
||||||
if (
|
if (
|
||||||
id.startsWith("session.") ||
|
id.startsWith("session.") ||
|
||||||
|
|||||||
@@ -233,7 +233,15 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
const centered = createMemo(() => isDesktop() && !view().reviewPanel.opened())
|
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||||
|
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||||
|
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||||
|
const sessionPanelWidth = createMemo(() => {
|
||||||
|
if (!desktopSidePanelOpen()) return "100%"
|
||||||
|
if (desktopReviewOpen()) return `${layout.session.width()}px`
|
||||||
|
return `calc(100% - ${layout.fileTree.width()}px)`
|
||||||
|
})
|
||||||
|
const centered = createMemo(() => isDesktop() && !desktopSidePanelOpen())
|
||||||
|
|
||||||
function normalizeTab(tab: string) {
|
function normalizeTab(tab: string) {
|
||||||
if (!tab.startsWith("file://")) return tab
|
if (!tab.startsWith("file://")) return tab
|
||||||
@@ -252,12 +260,18 @@ export default function Page() {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openReviewPanel = () => {
|
||||||
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
|
}
|
||||||
|
|
||||||
const openTab = (value: string) => {
|
const openTab = (value: string) => {
|
||||||
const next = normalizeTab(value)
|
const next = normalizeTab(value)
|
||||||
tabs().open(next)
|
tabs().open(next)
|
||||||
|
|
||||||
const path = file.pathFromTab(next)
|
const path = file.pathFromTab(next)
|
||||||
if (path) file.load(path)
|
if (!path) return
|
||||||
|
file.load(path)
|
||||||
|
openReviewPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -1085,6 +1099,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const focusReviewDiff = (path: string) => {
|
const focusReviewDiff = (path: string) => {
|
||||||
|
openReviewPanel()
|
||||||
const current = view().review.open() ?? []
|
const current = view().review.open() ?? []
|
||||||
if (!current.includes(path)) view().review.setOpen([...current, path])
|
if (!current.includes(path)) view().review.setOpen([...current, path])
|
||||||
setTree({ activeDiff: path, pendingDiff: path })
|
setTree({ activeDiff: path, pendingDiff: path })
|
||||||
@@ -1203,7 +1218,7 @@ export default function Page() {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
const wants = isDesktop()
|
const wants = isDesktop()
|
||||||
? view().reviewPanel.opened() && (layout.fileTree.opened() || activeTab() === "review")
|
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||||
: store.mobileTab === "changes"
|
: store.mobileTab === "changes"
|
||||||
if (!wants) return
|
if (!wants) return
|
||||||
if (sync.data.session_diff[id] !== undefined) return
|
if (sync.data.session_diff[id] !== undefined) return
|
||||||
@@ -1216,7 +1231,6 @@ export default function Page() {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const dir = sdk.directory
|
const dir = sdk.directory
|
||||||
if (!isDesktop()) return
|
if (!isDesktop()) return
|
||||||
if (!view().reviewPanel.opened()) return
|
|
||||||
if (!layout.fileTree.opened()) return
|
if (!layout.fileTree.opened()) return
|
||||||
if (sync.status === "loading") return
|
if (sync.status === "loading") return
|
||||||
|
|
||||||
@@ -1533,10 +1547,10 @@ export default function Page() {
|
|||||||
classList={{
|
classList={{
|
||||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
||||||
"flex-1 pt-2 md:pt-3": true,
|
"flex-1 pt-2 md:pt-3": true,
|
||||||
"md:flex-none": view().reviewPanel.opened(),
|
"md:flex-none": desktopSidePanelOpen(),
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: isDesktop() && view().reviewPanel.opened() ? `${layout.session.width()}px` : "100%",
|
width: sessionPanelWidth(),
|
||||||
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
|
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1663,7 +1677,7 @@ export default function Page() {
|
|||||||
setPromptDockRef={(el) => (promptDock = el)}
|
setPromptDockRef={(el) => (promptDock = el)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Show when={isDesktop() && view().reviewPanel.opened()}>
|
<Show when={desktopReviewOpen()}>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
size={layout.session.width()}
|
size={layout.session.width()}
|
||||||
@@ -1675,7 +1689,8 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionSidePanel
|
<SessionSidePanel
|
||||||
open={isDesktop() && view().reviewPanel.opened()}
|
open={desktopSidePanelOpen()}
|
||||||
|
reviewOpen={desktopReviewOpen()}
|
||||||
language={language}
|
language={language}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
command={command}
|
command={command}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useSync } from "@/context/sync"
|
|||||||
|
|
||||||
export function SessionSidePanel(props: {
|
export function SessionSidePanel(props: {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
reviewOpen: boolean
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
layout: ReturnType<typeof useLayout>
|
layout: ReturnType<typeof useLayout>
|
||||||
command: ReturnType<typeof useCommand>
|
command: ReturnType<typeof useCommand>
|
||||||
@@ -72,157 +73,164 @@ export function SessionSidePanel(props: {
|
|||||||
<aside
|
<aside
|
||||||
id="review-panel"
|
id="review-panel"
|
||||||
aria-label={props.language.t("session.panel.reviewAndFiles")}
|
aria-label={props.language.t("session.panel.reviewAndFiles")}
|
||||||
class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
|
class="relative min-w-0 h-full border-l border-border-weak-base flex"
|
||||||
|
classList={{
|
||||||
|
"flex-1": props.reviewOpen,
|
||||||
|
"shrink-0": !props.reviewOpen,
|
||||||
|
}}
|
||||||
|
style={{ width: props.reviewOpen ? undefined : `${props.layout.fileTree.width()}px` }}
|
||||||
>
|
>
|
||||||
<div class="flex-1 min-w-0 h-full">
|
<Show when={props.reviewOpen}>
|
||||||
<Show
|
<div class="flex-1 min-w-0 h-full">
|
||||||
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
|
<Show
|
||||||
fallback={
|
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
|
||||||
<DragDropProvider
|
fallback={
|
||||||
onDragStart={props.onDragStart}
|
<DragDropProvider
|
||||||
onDragEnd={props.onDragEnd}
|
onDragStart={props.onDragStart}
|
||||||
onDragOver={props.onDragOver}
|
onDragEnd={props.onDragEnd}
|
||||||
collisionDetector={closestCenter}
|
onDragOver={props.onDragOver}
|
||||||
>
|
collisionDetector={closestCenter}
|
||||||
<DragDropSensors />
|
>
|
||||||
<ConstrainDragYAxis />
|
<DragDropSensors />
|
||||||
<Tabs value={props.activeTab()} onChange={props.openTab}>
|
<ConstrainDragYAxis />
|
||||||
<div class="sticky top-0 shrink-0 flex">
|
<Tabs value={props.activeTab()} onChange={props.openTab}>
|
||||||
<Tabs.List
|
<div class="sticky top-0 shrink-0 flex">
|
||||||
ref={(el: HTMLDivElement) => {
|
<Tabs.List
|
||||||
const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
|
ref={(el: HTMLDivElement) => {
|
||||||
onCleanup(stop)
|
const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
|
||||||
}}
|
onCleanup(stop)
|
||||||
>
|
}}
|
||||||
<Show when={props.reviewTab}>
|
>
|
||||||
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
|
<Show when={props.reviewTab}>
|
||||||
<div class="flex items-center gap-1.5">
|
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
|
||||||
<div>{props.language.t("session.tab.review")}</div>
|
<div class="flex items-center gap-1.5">
|
||||||
<Show when={props.hasReview}>
|
<div>{props.language.t("session.tab.review")}</div>
|
||||||
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
<Show when={props.hasReview}>
|
||||||
{props.reviewCount}
|
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
||||||
</div>
|
{props.reviewCount}
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</Tabs.Trigger>
|
</div>
|
||||||
</Show>
|
</Tabs.Trigger>
|
||||||
<Show when={props.contextOpen()}>
|
</Show>
|
||||||
<Tabs.Trigger
|
<Show when={props.contextOpen()}>
|
||||||
value="context"
|
<Tabs.Trigger
|
||||||
closeButton={
|
value="context"
|
||||||
<Tooltip value={props.language.t("common.closeTab")} placement="bottom">
|
closeButton={
|
||||||
<IconButton
|
<Tooltip value={props.language.t("common.closeTab")} placement="bottom">
|
||||||
icon="close-small"
|
<IconButton
|
||||||
variant="ghost"
|
icon="close-small"
|
||||||
class="h-5 w-5"
|
variant="ghost"
|
||||||
onClick={() => props.tabs().close("context")}
|
class="h-5 w-5"
|
||||||
aria-label={props.language.t("common.closeTab")}
|
onClick={() => props.tabs().close("context")}
|
||||||
/>
|
aria-label={props.language.t("common.closeTab")}
|
||||||
</Tooltip>
|
/>
|
||||||
}
|
</Tooltip>
|
||||||
hideCloseButton
|
|
||||||
onMiddleClick={() => props.tabs().close("context")}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<SessionContextUsage variant="indicator" />
|
|
||||||
<div>{props.language.t("session.tab.context")}</div>
|
|
||||||
</div>
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</Show>
|
|
||||||
<SortableProvider ids={props.openedTabs()}>
|
|
||||||
<For each={props.openedTabs()}>
|
|
||||||
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
|
|
||||||
</For>
|
|
||||||
</SortableProvider>
|
|
||||||
<StickyAddButton>
|
|
||||||
<TooltipKeybind
|
|
||||||
title={props.language.t("command.file.open")}
|
|
||||||
keybind={props.command.keybind("file.open")}
|
|
||||||
class="flex items-center"
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon="plus-small"
|
|
||||||
variant="ghost"
|
|
||||||
iconSize="large"
|
|
||||||
onClick={() =>
|
|
||||||
props.dialog.show(() => <DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />)
|
|
||||||
}
|
}
|
||||||
aria-label={props.language.t("command.file.open")}
|
hideCloseButton
|
||||||
/>
|
onMiddleClick={() => props.tabs().close("context")}
|
||||||
</TooltipKeybind>
|
>
|
||||||
</StickyAddButton>
|
<div class="flex items-center gap-2">
|
||||||
</Tabs.List>
|
<SessionContextUsage variant="indicator" />
|
||||||
</div>
|
<div>{props.language.t("session.tab.context")}</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
|
<SortableProvider ids={props.openedTabs()}>
|
||||||
|
<For each={props.openedTabs()}>
|
||||||
|
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
|
||||||
|
</For>
|
||||||
|
</SortableProvider>
|
||||||
|
<StickyAddButton>
|
||||||
|
<TooltipKeybind
|
||||||
|
title={props.language.t("command.file.open")}
|
||||||
|
keybind={props.command.keybind("file.open")}
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus-small"
|
||||||
|
variant="ghost"
|
||||||
|
iconSize="large"
|
||||||
|
onClick={() =>
|
||||||
|
props.dialog.show(() => <DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />)
|
||||||
|
}
|
||||||
|
aria-label={props.language.t("command.file.open")}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</StickyAddButton>
|
||||||
|
</Tabs.List>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Show when={props.reviewTab}>
|
<Show when={props.reviewTab}>
|
||||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
|
<Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||||
</Tabs.Content>
|
</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 flex flex-col items-center justify-center text-center gap-6">
|
|
||||||
<Mark class="w-14 opacity-10" />
|
|
||||||
<div class="text-14-regular text-text-weak max-w-56">
|
|
||||||
{props.language.t("session.files.selectToOpen")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Tabs.Content>
|
|
||||||
|
|
||||||
<Show when={props.contextOpen()}>
|
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Show when={props.activeTab() === "empty"}>
|
||||||
<Show when={props.activeTab() === "context"}>
|
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<SessionContextTab
|
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
||||||
messages={props.messages as never}
|
<Mark class="w-14 opacity-10" />
|
||||||
visibleUserMessages={props.visibleUserMessages as never}
|
<div class="text-14-regular text-text-weak max-w-56">
|
||||||
view={props.view as never}
|
{props.language.t("session.files.selectToOpen")}
|
||||||
info={props.info as never}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={props.activeFileTab()} keyed>
|
<Show when={props.contextOpen()}>
|
||||||
{(tab) => (
|
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<FileTabContent
|
<Show when={props.activeTab() === "context"}>
|
||||||
tab={tab}
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
activeTab={props.activeTab}
|
<SessionContextTab
|
||||||
tabs={props.tabs}
|
messages={props.messages as never}
|
||||||
view={props.view}
|
visibleUserMessages={props.visibleUserMessages as never}
|
||||||
handoffFiles={props.handoffFiles}
|
view={props.view as never}
|
||||||
file={props.file}
|
info={props.info as never}
|
||||||
comments={props.comments}
|
/>
|
||||||
language={props.language}
|
</div>
|
||||||
codeComponent={props.codeComponent}
|
</Show>
|
||||||
addCommentToContext={props.addCommentToContext}
|
</Tabs.Content>
|
||||||
/>
|
</Show>
|
||||||
)}
|
|
||||||
</Show>
|
<Show when={props.activeFileTab()} keyed>
|
||||||
</Tabs>
|
{(tab) => (
|
||||||
<DragOverlay>
|
<FileTabContent
|
||||||
<Show when={props.activeDraggable()}>
|
tab={tab}
|
||||||
{(tab) => {
|
activeTab={props.activeTab}
|
||||||
const path = createMemo(() => props.file.pathFromTab(tab()))
|
tabs={props.tabs}
|
||||||
return (
|
view={props.view}
|
||||||
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
handoffFiles={props.handoffFiles}
|
||||||
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
file={props.file}
|
||||||
</div>
|
comments={props.comments}
|
||||||
)
|
language={props.language}
|
||||||
}}
|
codeComponent={props.codeComponent}
|
||||||
</Show>
|
addCommentToContext={props.addCommentToContext}
|
||||||
</DragOverlay>
|
/>
|
||||||
</DragDropProvider>
|
)}
|
||||||
}
|
</Show>
|
||||||
>
|
</Tabs>
|
||||||
{props.reviewPanel()}
|
<DragOverlay>
|
||||||
</Show>
|
<Show when={props.activeDraggable()}>
|
||||||
</div>
|
{(tab) => {
|
||||||
|
const path = createMemo(() => props.file.pathFromTab(tab()))
|
||||||
|
return (
|
||||||
|
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
||||||
|
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.reviewPanel()}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={props.layout.fileTree.opened()}>
|
<Show when={props.layout.fileTree.opened()}>
|
||||||
<div
|
<div
|
||||||
@@ -230,7 +238,10 @@ export function SessionSidePanel(props: {
|
|||||||
class="relative shrink-0 h-full"
|
class="relative shrink-0 h-full"
|
||||||
style={{ width: `${props.layout.fileTree.width()}px` }}
|
style={{ width: `${props.layout.fileTree.width()}px` }}
|
||||||
>
|
>
|
||||||
<div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree">
|
<div
|
||||||
|
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||||
|
classList={{ "border-l border-border-weak-base": props.reviewOpen }}
|
||||||
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
variant="pill"
|
variant="pill"
|
||||||
value={props.fileTreeTab()}
|
value={props.fileTreeTab()}
|
||||||
|
|||||||
@@ -139,11 +139,8 @@ export const useSessionCommands = (input: {
|
|||||||
title: input.language.t("command.fileTree.toggle"),
|
title: input.language.t("command.fileTree.toggle"),
|
||||||
description: "",
|
description: "",
|
||||||
category: input.language.t("command.category.view"),
|
category: input.language.t("command.category.view"),
|
||||||
onSelect: () => {
|
keybind: "mod+\\",
|
||||||
const opening = !input.layout.fileTree.opened()
|
onSelect: () => input.layout.fileTree.toggle(),
|
||||||
if (opening && !input.view().reviewPanel.opened()) input.view().reviewPanel.open()
|
|
||||||
input.layout.fileTree.toggle()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "terminal.new",
|
id: "terminal.new",
|
||||||
|
|||||||
Reference in New Issue
Block a user