summaryrefslogtreecommitdiffhomepage
path: root/packages/app
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-01 10:52:26 -0600
committerAdam <[email protected]>2026-01-01 21:03:03 -0600
commitb8872d9d20c76ef351a0ec356558b1484a74f20f (patch)
treedca28cfbb0f72e3d2507df06849b1647975daccd /packages/app
parent78940d5b7ee2f3e5020f87b400db1785b37a7d71 (diff)
downloadopencode-b8872d9d20c76ef351a0ec356558b1484a74f20f.tar.gz
opencode-b8872d9d20c76ef351a0ec356558b1484a74f20f.zip
wip(desktop): progress
Diffstat (limited to 'packages/app')
-rw-r--r--packages/app/src/context/layout.tsx78
-rw-r--r--packages/app/src/pages/layout.tsx22
-rw-r--r--packages/app/src/pages/session.tsx199
3 files changed, 244 insertions, 55 deletions
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 613a0e0c1..6a9258b4c 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -23,11 +23,28 @@ export function getAvatarColors(key?: string) {
}
}
+function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
+ if (a === b) return true
+ if (!a || !b) return false
+ if (a.length !== b.length) return false
+ return a.every((x, i) => x === b[i])
+}
+
type SessionTabs = {
active?: string
all: string[]
}
+type SessionScroll = {
+ x: number
+ y: number
+}
+
+type SessionView = {
+ scroll: Record<string, SessionScroll>
+ reviewOpen?: string[]
+}
+
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type ReviewDiffStyle = "unified" | "split"
@@ -39,7 +56,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync()
const server = useServer()
const [store, setStore, _, ready] = persisted(
- "layout.v4",
+ "layout.v6",
createStore({
sidebar: {
opened: false,
@@ -56,7 +73,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
session: {
width: 600,
},
+ mobileSidebar: {
+ opened: false,
+ },
sessionTabs: {} as Record<string, SessionTabs>,
+ sessionView: {} as Record<string, SessionView>,
}),
)
@@ -182,11 +203,55 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
resize(width: number) {
if (!store.session) {
setStore("session", { width })
- } else {
- setStore("session", "width", width)
+ return
}
+ setStore("session", "width", width)
+ },
+ },
+ mobileSidebar: {
+ opened: createMemo(() => store.mobileSidebar?.opened ?? false),
+ show() {
+ setStore("mobileSidebar", "opened", true)
+ },
+ hide() {
+ setStore("mobileSidebar", "opened", false)
+ },
+ toggle() {
+ setStore("mobileSidebar", "opened", (x) => !x)
},
},
+ view(sessionKey: string) {
+ const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
+ return {
+ scroll(tab: string) {
+ return s().scroll?.[tab]
+ },
+ setScroll(tab: string, pos: SessionScroll) {
+ const current = store.sessionView[sessionKey]
+ if (!current) {
+ setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
+ return
+ }
+
+ const prev = current.scroll?.[tab]
+ if (prev?.x === pos.x && prev?.y === pos.y) return
+ setStore("sessionView", sessionKey, "scroll", tab, pos)
+ },
+ review: {
+ open: createMemo(() => s().reviewOpen),
+ setOpen(open: string[]) {
+ const current = store.sessionView[sessionKey]
+ if (!current) {
+ setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
+ return
+ }
+
+ if (same(current.reviewOpen, open)) return
+ setStore("sessionView", sessionKey, "reviewOpen", open)
+ },
+ },
+ }
+ },
tabs(sessionKey: string) {
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return {
@@ -256,11 +321,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (current.active !== tab) return
const index = current.all.findIndex((f) => f === tab)
- if (index <= 0) {
- setStore("sessionTabs", sessionKey, "active", undefined)
- return
- }
- setStore("sessionTabs", sessionKey, "active", current.all[index - 1])
+ const next = all[index - 1] ?? all[0]
+ setStore("sessionTabs", sessionKey, "active", next)
})
},
move(tab: string, to: number) {
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 4629cd9b6..e237d2184 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -62,17 +62,9 @@ export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
lastSession: {} as { [directory: string]: string },
activeDraggable: undefined as string | undefined,
- mobileSidebarOpen: false,
mobileProjectsExpanded: {} as Record<string, boolean>,
})
- const mobileSidebar = {
- open: () => store.mobileSidebarOpen,
- show: () => setStore("mobileSidebarOpen", true),
- hide: () => setStore("mobileSidebarOpen", false),
- toggle: () => setStore("mobileSidebarOpen", (x) => !x),
- }
-
const mobileProjects = {
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
@@ -468,13 +460,13 @@ export default function Layout(props: ParentProps) {
if (!directory) return
const lastSession = store.lastSession[directory]
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
- mobileSidebar.hide()
+ layout.mobileSidebar.hide()
}
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session?.id}`)
- mobileSidebar.hide()
+ layout.mobileSidebar.hide()
}
function openProject(directory: string, navigate = true) {
@@ -1064,18 +1056,18 @@ export default function Layout(props: ParentProps) {
<div
classList={{
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
- "opacity-100 pointer-events-auto": mobileSidebar.open(),
- "opacity-0 pointer-events-none": !mobileSidebar.open(),
+ "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
+ "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
- if (e.target === e.currentTarget) mobileSidebar.hide()
+ if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<div
classList={{
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
- "translate-x-0": mobileSidebar.open(),
- "-translate-x-full": !mobileSidebar.open(),
+ "translate-x-0": layout.mobileSidebar.opened(),
+ "-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index f0e6a6e1d..7f0203222 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -84,7 +84,7 @@ function same<T>(a: readonly T[], b: readonly T[]) {
return a.every((x, i) => x === b[i])
}
-function Header(props: { onMobileMenuToggle?: () => void }) {
+function Header() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
@@ -113,7 +113,7 @@ function Header(props: { onMobileMenuToggle?: () => void }) {
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
- onClick={props.onMobileMenuToggle}
+ onClick={layout.mobileSidebar.toggle}
>
<Icon name="menu" size="small" />
</button>
@@ -291,6 +291,7 @@ export default function Page() {
const permission = usePermission()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
+ const view = createMemo(() => layout.view(sessionKey()))
function normalizeTab(tab: string) {
if (!tab.startsWith("file://")) return tab
@@ -822,6 +823,8 @@ export default function Page() {
.filter((tab) => tab !== "context"),
)
+ const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
+
const showTabs = createMemo(
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
)
@@ -829,8 +832,19 @@ export default function Page() {
const activeTab = createMemo(() => {
const active = tabs().active()
if (active) return active
- if (diffs().length > 0) return "review"
- return tabs().all()[0] ?? "review"
+ if (reviewTab()) return "review"
+
+ const first = openedTabs()[0]
+ if (first) return first
+ if (contextOpen()) return "context"
+ return "review"
+ })
+
+ createEffect(() => {
+ if (!layout.ready()) return
+ if (tabs().active()) return
+ if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return
+ tabs().setActive(activeTab())
})
const mobileWorking = createMemo(() => status().type !== "idle")
@@ -1209,8 +1223,63 @@ export default function Page() {
)
}
+ let scroll: HTMLDivElement | undefined
+ let frame: number | undefined
+ let pending: { x: number; y: number } | undefined
+
+ const restoreScroll = () => {
+ const el = scroll
+ if (!el) return
+
+ const s = view()?.scroll("context")
+ if (!s) return
+
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+ }
+
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ pending = {
+ x: event.currentTarget.scrollLeft,
+ y: event.currentTarget.scrollTop,
+ }
+ if (frame !== undefined) return
+
+ frame = requestAnimationFrame(() => {
+ frame = undefined
+
+ const next = pending
+ pending = undefined
+ if (!next) return
+
+ view().setScroll("context", next)
+ })
+ }
+
+ createEffect(
+ on(
+ () => messages().length,
+ () => {
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ onCleanup(() => {
+ if (frame === undefined) return
+ cancelAnimationFrame(frame)
+ })
+
return (
- <div class="@container h-full overflow-y-auto no-scrollbar pb-10">
+ <div
+ class="@container h-full overflow-y-auto no-scrollbar pb-10"
+ ref={(el) => {
+ scroll = el
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ >
<div class="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>
@@ -1271,6 +1340,79 @@ export default function Page() {
)
}
+ const ReviewTab = () => {
+ let scroll: HTMLDivElement | undefined
+ let frame: number | undefined
+ let pending: { x: number; y: number } | undefined
+
+ const restoreScroll = () => {
+ const el = scroll
+ console.log("restoreScroll", el)
+ if (!el) return
+
+ const s = view().scroll("review")
+ console.log("restoreScroll", s)
+ if (!s) return
+
+ console.log("restoreScroll", el.scrollTop, s.y)
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+ }
+
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ pending = {
+ x: event.currentTarget.scrollLeft,
+ y: event.currentTarget.scrollTop,
+ }
+ if (frame !== undefined) return
+
+ frame = requestAnimationFrame(() => {
+ frame = undefined
+
+ const next = pending
+ pending = undefined
+ if (!next) return
+
+ view().setScroll("review", next)
+ })
+ }
+
+ createEffect(
+ on(
+ () => diffs().length,
+ () => {
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ onCleanup(() => {
+ if (frame === undefined) return
+ cancelAnimationFrame(frame)
+ })
+
+ return (
+ <SessionReview
+ scrollRef={(el) => {
+ scroll = el
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ open={view().review.open()}
+ onOpenChange={view().review.setOpen}
+ classes={{
+ root: "pb-40",
+ header: "px-6",
+ container: "px-6",
+ }}
+ diffs={diffs()}
+ diffStyle={layout.review.diffStyle()}
+ onDiffStyleChange={layout.review.setDiffStyle}
+ />
+ )
+ }
+
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<Header />
@@ -1300,6 +1442,8 @@ export default function Page() {
diffs={diffs()}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
+ open={view().review.open()}
+ onOpenChange={view().review.setOpen}
classes={{
root: "pb-32",
header: "px-4",
@@ -1373,7 +1517,7 @@ export default function Page() {
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
- <Show when={diffs().length}>
+ <Show when={reviewTab()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-3">
<Show when={diffs()}>
@@ -1425,19 +1569,10 @@ export default function Page() {
</div>
</Tabs.List>
</div>
- <Show when={diffs().length}>
+ <Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <SessionReview
- classes={{
- root: "pb-40",
- header: "px-6",
- container: "px-6",
- }}
- diffs={diffs()}
- diffStyle={layout.review.diffStyle()}
- onDiffStyleChange={layout.review.setDiffStyle}
- />
+ <ReviewTab />
</div>
</Tabs.Content>
</Show>
@@ -1452,7 +1587,7 @@ export default function Page() {
{(tab) => {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
- let pendingTop: number | undefined
+ let pending: { x: number; y: number } | undefined
const path = createMemo(() => file.pathFromTab(tab))
const state = createMemo(() => {
@@ -1480,30 +1615,30 @@ export default function Page() {
const restoreScroll = () => {
const el = scroll
- const p = path()
- if (!el || !p) return
+ if (!el) return
+
+ const s = view()?.scroll(tab)
+ if (!s) return
- const top = file.scrollTop(p)
- if (top === undefined) return
- if (el.scrollTop === top) return
- el.scrollTop = top
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- const p = path()
- if (!p) return
-
- pendingTop = event.currentTarget.scrollTop
+ pending = {
+ x: event.currentTarget.scrollLeft,
+ y: event.currentTarget.scrollTop,
+ }
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
- const top = pendingTop
- pendingTop = undefined
- if (top === undefined) return
+ const next = pending
+ pending = undefined
+ if (!next) return
- file.setScrollTop(p, top)
+ view().setScroll(tab, next)
})
}