diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 1c5edbf2b445..4ee54d306504 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -58,6 +58,7 @@ import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
+import { createAim } from "@/utils/aim"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { agentColor } from "@/utils/agent"
@@ -146,9 +147,20 @@ export default function Layout(props: ParentProps) {
const navLeave = { current: undefined as number | undefined }
+ const aim = createAim({
+ enabled: () => !layout.sidebar.opened(),
+ active: () => state.hoverProject,
+ el: () => state.nav,
+ onActivate: (directory) => {
+ globalSync.child(directory)
+ setState("hoverProject", directory)
+ setState("hoverSession", undefined)
+ },
+ })
+
onCleanup(() => {
- if (navLeave.current === undefined) return
- clearTimeout(navLeave.current)
+ if (navLeave.current !== undefined) clearTimeout(navLeave.current)
+ aim.reset()
})
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -162,15 +174,22 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
if (!layout.sidebar.opened()) return
+ aim.reset()
setState("hoverProject", undefined)
})
+ createEffect(() => {
+ if (state.hoverProject !== undefined) return
+ aim.reset()
+ })
+
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
() => {
if (layout.sidebar.opened()) return
if (!state.hoverProject) return
+ aim.reset()
setState("hoverSession", undefined)
setState("hoverProject", undefined)
},
@@ -2311,17 +2330,17 @@ export default function Layout(props: ParentProps) {
!selected() && !active(),
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
}}
- onMouseEnter={() => {
+ onMouseEnter={(event: MouseEvent) => {
+ if (!overlay()) return
+ aim.enter(props.project.worktree, event)
+ }}
+ onMouseLeave={() => {
if (!overlay()) return
- globalSync.child(props.project.worktree)
- setState("hoverProject", props.project.worktree)
- setState("hoverSession", undefined)
+ aim.leave(props.project.worktree)
}}
onFocus={() => {
if (!overlay()) return
- globalSync.child(props.project.worktree)
- setState("hoverProject", props.project.worktree)
- setState("hoverSession", undefined)
+ aim.activate(props.project.worktree)
}}
onClick={() => navigateToProject(props.project.worktree)}
onBlur={() => setOpen(false)}
@@ -2806,7 +2825,7 @@ export default function Layout(props: ParentProps) {
return (
-
+
{
+ aim.reset()
if (!sidebarHovering()) return
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
@@ -2916,7 +2936,7 @@ export default function Layout(props: ParentProps) {
{(project) => (
-
+
)}
diff --git a/packages/app/src/utils/aim.ts b/packages/app/src/utils/aim.ts
new file mode 100644
index 000000000000..23471959e168
--- /dev/null
+++ b/packages/app/src/utils/aim.ts
@@ -0,0 +1,138 @@
+type Point = { x: number; y: number }
+
+export function createAim(props: {
+ enabled: () => boolean
+ active: () => string | undefined
+ el: () => HTMLElement | undefined
+ onActivate: (id: string) => void
+ delay?: number
+ max?: number
+ tolerance?: number
+ edge?: number
+}) {
+ const state = {
+ locs: [] as Point[],
+ timer: undefined as number | undefined,
+ pending: undefined as string | undefined,
+ over: undefined as string | undefined,
+ last: undefined as Point | undefined,
+ }
+
+ const delay = props.delay ?? 250
+ const max = props.max ?? 4
+ const tolerance = props.tolerance ?? 80
+ const edge = props.edge ?? 18
+
+ const cancel = () => {
+ if (state.timer !== undefined) clearTimeout(state.timer)
+ state.timer = undefined
+ state.pending = undefined
+ }
+
+ const reset = () => {
+ cancel()
+ state.over = undefined
+ state.last = undefined
+ state.locs.length = 0
+ }
+
+ const move = (event: MouseEvent) => {
+ if (!props.enabled()) return
+ const el = props.el()
+ if (!el) return
+
+ const rect = el.getBoundingClientRect()
+ const x = event.clientX
+ const y = event.clientY
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return
+
+ state.locs.push({ x, y })
+ if (state.locs.length > max) state.locs.shift()
+ }
+
+ const wait = () => {
+ if (!props.enabled()) return 0
+ if (!props.active()) return 0
+
+ const el = props.el()
+ if (!el) return 0
+ if (state.locs.length < 2) return 0
+
+ const rect = el.getBoundingClientRect()
+ const loc = state.locs[state.locs.length - 1]
+ if (!loc) return 0
+
+ const prev = state.locs[0] ?? loc
+ if (prev.x < rect.left || prev.x > rect.right || prev.y < rect.top || prev.y > rect.bottom) return 0
+ if (state.last && loc.x === state.last.x && loc.y === state.last.y) return 0
+
+ if (rect.right - loc.x <= edge) {
+ state.last = loc
+ return delay
+ }
+
+ const upper = { x: rect.right, y: rect.top - tolerance }
+ const lower = { x: rect.right, y: rect.bottom + tolerance }
+ const slope = (a: Point, b: Point) => (b.y - a.y) / (b.x - a.x)
+
+ const decreasing = slope(loc, upper)
+ const increasing = slope(loc, lower)
+ const prevDecreasing = slope(prev, upper)
+ const prevIncreasing = slope(prev, lower)
+
+ if (decreasing < prevDecreasing && increasing > prevIncreasing) {
+ state.last = loc
+ return delay
+ }
+
+ state.last = undefined
+ return 0
+ }
+
+ const activate = (id: string) => {
+ cancel()
+ props.onActivate(id)
+ }
+
+ const request = (id: string) => {
+ if (!id) return
+ if (props.active() === id) return
+
+ if (!props.active()) {
+ activate(id)
+ return
+ }
+
+ const ms = wait()
+ if (ms === 0) {
+ activate(id)
+ return
+ }
+
+ cancel()
+ state.pending = id
+ state.timer = window.setTimeout(() => {
+ state.timer = undefined
+ if (state.pending !== id) return
+ state.pending = undefined
+ if (!props.enabled()) return
+ if (!props.active()) return
+ if (state.over !== id) return
+ props.onActivate(id)
+ }, ms)
+ }
+
+ const enter = (id: string, event: MouseEvent) => {
+ if (!props.enabled()) return
+ state.over = id
+ move(event)
+ request(id)
+ }
+
+ const leave = (id: string) => {
+ if (state.over === id) state.over = undefined
+ if (state.pending === id) cancel()
+ }
+
+ return { move, enter, leave, activate, request, cancel, reset }
+}