Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/app/e2e/file-tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { test, expect } from "./fixtures"
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
await gotoSession()

const toggle = page.getByRole("button", { name: "Toggle file tree" })
const toggle = page.getByRole("button", { name: "Toggle review" })
const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')

if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
Expand Down
40 changes: 36 additions & 4 deletions packages/app/src/components/session-context-usage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Match, Show, Switch, createMemo } from "solid-js"
import { Match, Show, Switch, createMemo, createSignal, onCleanup } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
Expand All @@ -14,15 +14,22 @@ interface SessionContextUsageProps {
variant?: "button" | "indicator"
}

const isTouch = () => window.matchMedia("(hover: none)").matches

export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync()
const params = useParams()
const layout = useLayout()
const language = useLanguage()
const [touchTooltip, setTouchTooltip] = createSignal(false)
let tooltipTimer: number | undefined

onCleanup(() => clearTimeout(tooltipTimer))

const variant = createMemo(() => props.variant ?? "button")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))

const usd = createMemo(
Expand Down Expand Up @@ -57,12 +64,32 @@ export function SessionContextUsage(props: SessionContextUsageProps) {

const openContext = () => {
if (!params.id) return
layout.fileTree.open()
layout.fileTree.setTab("all")
layout.fileTree.open()
view().mobileTab.set("context")
tabs().open("context")
tabs().setActive("context")
}

const handleClick = () => {
if (variant() === "indicator") return
if (isTouch() && !touchTooltip()) {
setTouchTooltip(true)
clearTimeout(tooltipTimer)
tooltipTimer = window.setTimeout(() => setTouchTooltip(false), 3000)
return
}
setTouchTooltip(false)
openContext()
}

const handleOpenChange = (open: boolean) => {
// On touch devices, we control the tooltip state ourselves via handleClick
// Ignore Kobalte's attempts to close it
if (isTouch()) return
setTouchTooltip(open)
}

const circle = () => (
<div class="p-1">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
Expand Down Expand Up @@ -94,15 +121,20 @@ export function SessionContextUsage(props: SessionContextUsageProps) {

return (
<Show when={params.id}>
<Tooltip value={tooltipValue()} placement="top">
<Tooltip
value={tooltipValue()}
placement="top"
open={touchTooltip() || undefined}
onOpenChange={handleOpenChange}
>
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={true}>
<Button
type="button"
variant="ghost"
class="size-6"
onClick={openContext}
onClick={handleClick}
aria-label={language.t("context.usage.view")}
>
{circle()}
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/components/session/session-context-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,14 +367,14 @@ export function SessionContextTab(props: SessionContextTabProps) {

return (
<div
class="@container h-full overflow-y-auto no-scrollbar pb-10"
class="@container h-full overflow-y-auto no-scrollbar md:pb-10 pb-[calc(var(--prompt-height,6rem)+32px)]"
ref={(el) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll}
>
<div class="px-6 pt-4 flex flex-col gap-10">
<div class="px-4 md:px-6 pt-4 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
</div>
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
mobileSidebar: {
opened: false,
},
mobileTab: "session" as "session" | "changes" | "context",
sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
}),
Expand Down Expand Up @@ -604,6 +605,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("sessionView", session, "reviewOpen", open)
},
},
mobileTab: {
value: createMemo(() => store.mobileTab),
set(tab: "session" | "changes" | "context") {
setStore("mobileTab", tab)
},
},
}
},
tabs(sessionKey: string | Accessor<string>) {
Expand Down
55 changes: 32 additions & 23 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,6 @@ export default function Page() {
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
newSessionWorktree: "main",
promptHeight: 0,
})
Expand Down Expand Up @@ -1104,7 +1103,8 @@ export default function Page() {
.filter((tab) => tab !== "context"),
)

const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const mobileChanges = createMemo(() => !isDesktop() && view().mobileTab.value() === "changes")
const mobileContext = createMemo(() => !isDesktop() && view().mobileTab.value() === "context")

const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
Expand Down Expand Up @@ -1292,7 +1292,9 @@ export default function Page() {
const id = params.id
if (!id) return

const wants = isDesktop() ? layout.fileTree.opened() && fileTreeTab() === "changes" : store.mobileTab === "changes"
const wants = isDesktop()
? layout.fileTree.opened() && fileTreeTab() === "changes"
: view().mobileTab.value() === "changes"
if (!wants) return
if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return
Expand Down Expand Up @@ -1728,31 +1730,28 @@ export default function Page() {
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
{/* Mobile tab bar */}
{/* Mobile tab bar - only shown on mobile when there's a session */}
<Show when={!isDesktop() && params.id}>
<Tabs class="h-auto">
<Tabs
class="h-auto"
value={view().mobileTab.value()}
onChange={(value) => view().mobileTab.set(value as "session" | "changes" | "context")}
>
<Tabs.List>
<Tabs.Trigger
value="session"
class="w-1/2"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
<Tabs.Trigger value="session" class="w-1/3" classes={{ button: "w-full" }}>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
class="w-1/2 !border-r-0"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
<Tabs.Trigger value="changes" class="w-1/3" classes={{ button: "w-full" }}>
<Switch>
<Match when={hasReview()}>
{language.t("session.review.filesChanged", { count: reviewCount() })}
</Match>
<Match when={true}>{language.t("session.review.change.other")}</Match>
</Switch>
</Tabs.Trigger>
<Tabs.Trigger value="context" class="w-1/3 !border-r-0" classes={{ button: "w-full" }}>
{language.t("session.tab.context")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</Show>
Expand All @@ -1773,9 +1772,8 @@ export default function Page() {
<Switch>
<Match when={params.id}>
<Show when={activeMessage()}>
<Show
when={!mobileChanges()}
fallback={
<Switch>
<Match when={mobileChanges()}>
<div class="relative h-full overflow-hidden">
<Switch>
<Match when={hasReview()}>
Expand Down Expand Up @@ -1820,8 +1818,18 @@ export default function Page() {
</Match>
</Switch>
</div>
}
>
</Match>
<Match when={mobileContext()}>
<div class="relative h-full overflow-hidden">
<SessionContextTab
messages={messages}
visibleUserMessages={visibleUserMessages}
view={view}
info={info}
/>
</div>
</Match>
<Match when={true}>
<div class="relative w-full h-full min-w-0">
<div
class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
Expand Down Expand Up @@ -2045,7 +2053,8 @@ export default function Page() {
</div>
</div>
</div>
</Show>
</Match>
</Switch>
</Show>
</Match>
<Match when={true}>
Expand Down
15 changes: 14 additions & 1 deletion packages/ui/src/components/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> {
contentStyle?: JSX.CSSProperties
inactive?: boolean
forceOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}

export interface TooltipKeybindProps extends Omit<TooltipProps, "value"> {
Expand All @@ -32,16 +34,27 @@ export function TooltipKeybind(props: TooltipKeybindProps) {
}

export function Tooltip(props: TooltipProps) {
const [open, setOpen] = createSignal(false)
const [internalOpen, setInternalOpen] = createSignal(false)
const [local, others] = splitProps(props, [
"children",
"class",
"contentClass",
"contentStyle",
"inactive",
"forceOpen",
"open",
"onOpenChange",
])

const open = () => local.open ?? internalOpen()
const setOpen = (value: boolean) => {
if (local.onOpenChange) {
local.onOpenChange(value)
} else {
setInternalOpen(value)
}
}

const c = children(() => local.children)

onMount(() => {
Expand Down