Skip to content

Commit 206a1ea

Browse files
committed
fix(app): expose context pane on mobile web
* workaround tooltip ignoring touch events * introduce third mobile tab state for context panel * two-tap pattern for opening context tab from tooltip
1 parent f607353 commit 206a1ea

File tree

6 files changed

+92
-31
lines changed

6 files changed

+92
-31
lines changed

packages/app/e2e/file-tree.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { test, expect } from "./fixtures"
33
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
44
await gotoSession()
55

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

99
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()

packages/app/src/components/session-context-usage.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Match, Show, Switch, createMemo } from "solid-js"
1+
import { Match, Show, Switch, createMemo, createSignal, onCleanup } from "solid-js"
22
import { Tooltip } from "@opencode-ai/ui/tooltip"
33
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
44
import { Button } from "@opencode-ai/ui/button"
@@ -14,15 +14,22 @@ interface SessionContextUsageProps {
1414
variant?: "button" | "indicator"
1515
}
1616

17+
const isTouch = () => window.matchMedia("(hover: none)").matches
18+
1719
export function SessionContextUsage(props: SessionContextUsageProps) {
1820
const sync = useSync()
1921
const params = useParams()
2022
const layout = useLayout()
2123
const language = useLanguage()
24+
const [touchTooltip, setTouchTooltip] = createSignal(false)
25+
let tooltipTimer: number | undefined
26+
27+
onCleanup(() => clearTimeout(tooltipTimer))
2228

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

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

5865
const openContext = () => {
5966
if (!params.id) return
60-
layout.fileTree.open()
6167
layout.fileTree.setTab("all")
68+
layout.fileTree.open()
69+
view().mobileTab.set("context")
6270
tabs().open("context")
6371
tabs().setActive("context")
6472
}
6573

74+
const handleClick = () => {
75+
if (variant() === "indicator") return
76+
if (isTouch() && !touchTooltip()) {
77+
setTouchTooltip(true)
78+
clearTimeout(tooltipTimer)
79+
tooltipTimer = window.setTimeout(() => setTouchTooltip(false), 3000)
80+
return
81+
}
82+
setTouchTooltip(false)
83+
openContext()
84+
}
85+
86+
const handleOpenChange = (open: boolean) => {
87+
// On touch devices, we control the tooltip state ourselves via handleClick
88+
// Ignore Kobalte's attempts to close it
89+
if (isTouch()) return
90+
setTouchTooltip(open)
91+
}
92+
6693
const circle = () => (
6794
<div class="p-1">
6895
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
@@ -94,15 +121,20 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
94121

95122
return (
96123
<Show when={params.id}>
97-
<Tooltip value={tooltipValue()} placement="top">
124+
<Tooltip
125+
value={tooltipValue()}
126+
placement="top"
127+
open={touchTooltip() || undefined}
128+
onOpenChange={handleOpenChange}
129+
>
98130
<Switch>
99131
<Match when={variant() === "indicator"}>{circle()}</Match>
100132
<Match when={true}>
101133
<Button
102134
type="button"
103135
variant="ghost"
104136
class="size-6"
105-
onClick={openContext}
137+
onClick={handleClick}
106138
aria-label={language.t("context.usage.view")}
107139
>
108140
{circle()}

packages/app/src/components/session/session-context-tab.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,14 +367,14 @@ export function SessionContextTab(props: SessionContextTabProps) {
367367

368368
return (
369369
<div
370-
class="@container h-full overflow-y-auto no-scrollbar pb-10"
370+
class="@container h-full overflow-y-auto no-scrollbar md:pb-10 pb-[calc(var(--prompt-height,6rem)+32px)]"
371371
ref={(el) => {
372372
scroll = el
373373
restoreScroll()
374374
}}
375375
onScroll={handleScroll}
376376
>
377-
<div class="px-6 pt-4 flex flex-col gap-10">
377+
<div class="px-4 md:px-6 pt-4 flex flex-col gap-10">
378378
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
379379
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
380380
</div>

packages/app/src/context/layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
113113
mobileSidebar: {
114114
opened: false,
115115
},
116+
mobileTab: "session" as "session" | "changes" | "context",
116117
sessionTabs: {} as Record<string, SessionTabs>,
117118
sessionView: {} as Record<string, SessionView>,
118119
}),
@@ -604,6 +605,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
604605
setStore("sessionView", session, "reviewOpen", open)
605606
},
606607
},
608+
mobileTab: {
609+
value: createMemo(() => store.mobileTab),
610+
set(tab: "session" | "changes" | "context") {
611+
setStore("mobileTab", tab)
612+
},
613+
},
607614
}
608615
},
609616
tabs(sessionKey: string | Accessor<string>) {

packages/app/src/pages/session.tsx

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,6 @@ export default function Page() {
435435
expanded: {} as Record<string, boolean>,
436436
messageId: undefined as string | undefined,
437437
turnStart: 0,
438-
mobileTab: "session" as "session" | "changes",
439438
newSessionWorktree: "main",
440439
promptHeight: 0,
441440
})
@@ -1104,7 +1103,8 @@ export default function Page() {
11041103
.filter((tab) => tab !== "context"),
11051104
)
11061105

1107-
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
1106+
const mobileChanges = createMemo(() => !isDesktop() && view().mobileTab.value() === "changes")
1107+
const mobileContext = createMemo(() => !isDesktop() && view().mobileTab.value() === "context")
11081108

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

1295-
const wants = isDesktop() ? layout.fileTree.opened() && fileTreeTab() === "changes" : store.mobileTab === "changes"
1295+
const wants = isDesktop()
1296+
? layout.fileTree.opened() && fileTreeTab() === "changes"
1297+
: view().mobileTab.value() === "changes"
12961298
if (!wants) return
12971299
if (sync.data.session_diff[id] !== undefined) return
12981300
if (sync.status === "loading") return
@@ -1728,31 +1730,28 @@ export default function Page() {
17281730
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
17291731
<SessionHeader />
17301732
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
1731-
{/* Mobile tab bar */}
1733+
{/* Mobile tab bar - only shown on mobile when there's a session */}
17321734
<Show when={!isDesktop() && params.id}>
1733-
<Tabs class="h-auto">
1735+
<Tabs
1736+
class="h-auto"
1737+
value={view().mobileTab.value()}
1738+
onChange={(value) => view().mobileTab.set(value as "session" | "changes" | "context")}
1739+
>
17341740
<Tabs.List>
1735-
<Tabs.Trigger
1736-
value="session"
1737-
class="w-1/2"
1738-
classes={{ button: "w-full" }}
1739-
onClick={() => setStore("mobileTab", "session")}
1740-
>
1741+
<Tabs.Trigger value="session" class="w-1/3" classes={{ button: "w-full" }}>
17411742
{language.t("session.tab.session")}
17421743
</Tabs.Trigger>
1743-
<Tabs.Trigger
1744-
value="changes"
1745-
class="w-1/2 !border-r-0"
1746-
classes={{ button: "w-full" }}
1747-
onClick={() => setStore("mobileTab", "changes")}
1748-
>
1744+
<Tabs.Trigger value="changes" class="w-1/3" classes={{ button: "w-full" }}>
17491745
<Switch>
17501746
<Match when={hasReview()}>
17511747
{language.t("session.review.filesChanged", { count: reviewCount() })}
17521748
</Match>
17531749
<Match when={true}>{language.t("session.review.change.other")}</Match>
17541750
</Switch>
17551751
</Tabs.Trigger>
1752+
<Tabs.Trigger value="context" class="w-1/3 !border-r-0" classes={{ button: "w-full" }}>
1753+
{language.t("session.tab.context")}
1754+
</Tabs.Trigger>
17561755
</Tabs.List>
17571756
</Tabs>
17581757
</Show>
@@ -1773,9 +1772,8 @@ export default function Page() {
17731772
<Switch>
17741773
<Match when={params.id}>
17751774
<Show when={activeMessage()}>
1776-
<Show
1777-
when={!mobileChanges()}
1778-
fallback={
1775+
<Switch>
1776+
<Match when={mobileChanges()}>
17791777
<div class="relative h-full overflow-hidden">
17801778
<Switch>
17811779
<Match when={hasReview()}>
@@ -1820,8 +1818,18 @@ export default function Page() {
18201818
</Match>
18211819
</Switch>
18221820
</div>
1823-
}
1824-
>
1821+
</Match>
1822+
<Match when={mobileContext()}>
1823+
<div class="relative h-full overflow-hidden">
1824+
<SessionContextTab
1825+
messages={messages}
1826+
visibleUserMessages={visibleUserMessages}
1827+
view={view}
1828+
info={info}
1829+
/>
1830+
</div>
1831+
</Match>
1832+
<Match when={true}>
18251833
<div class="relative w-full h-full min-w-0">
18261834
<div
18271835
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"
@@ -2045,7 +2053,8 @@ export default function Page() {
20452053
</div>
20462054
</div>
20472055
</div>
2048-
</Show>
2056+
</Match>
2057+
</Switch>
20492058
</Show>
20502059
</Match>
20512060
<Match when={true}>

packages/ui/src/components/tooltip.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> {
99
contentStyle?: JSX.CSSProperties
1010
inactive?: boolean
1111
forceOpen?: boolean
12+
open?: boolean
13+
onOpenChange?: (open: boolean) => void
1214
}
1315

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

3436
export function Tooltip(props: TooltipProps) {
35-
const [open, setOpen] = createSignal(false)
37+
const [internalOpen, setInternalOpen] = createSignal(false)
3638
const [local, others] = splitProps(props, [
3739
"children",
3840
"class",
3941
"contentClass",
4042
"contentStyle",
4143
"inactive",
4244
"forceOpen",
45+
"open",
46+
"onOpenChange",
4347
])
4448

49+
const open = () => local.open ?? internalOpen()
50+
const setOpen = (value: boolean) => {
51+
if (local.onOpenChange) {
52+
local.onOpenChange(value)
53+
} else {
54+
setInternalOpen(value)
55+
}
56+
}
57+
4558
const c = children(() => local.children)
4659

4760
onMount(() => {

0 commit comments

Comments
 (0)