(promptDock = el)}
diff --git a/packages/app/src/utils/array.test.ts b/packages/app/src/utils/array.test.ts
new file mode 100644
index 00000000000..4e50e576847
--- /dev/null
+++ b/packages/app/src/utils/array.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, test } from "bun:test"
+import { createStore } from "solid-js/store"
+import { createRoot } from "solid-js"
+
+/**
+ * Tests for handling SolidJS store proxy arrays.
+ *
+ * SolidJS store proxies may not pass Array.isArray() checks in browser environments
+ * (they do pass in server/test environments). These tests verify that our array
+ * detection approach using typeof .filter === "function" works in all cases.
+ */
+describe("SolidJS store proxy array handling", () => {
+ test("Array.isArray behavior varies by environment (browser vs server)", () => {
+ createRoot((dispose) => {
+ const [store] = createStore({ items: [1, 2, 3] })
+
+ // In server/test mode, Array.isArray returns true
+ // In browser with proxies, it may return false
+ // Our code should handle both cases
+ const isArray = Array.isArray(store.items)
+ expect(typeof isArray).toBe("boolean")
+
+ dispose()
+ })
+ })
+
+ test("typeof .filter === 'function' works for store proxy arrays", () => {
+ createRoot((dispose) => {
+ const [store] = createStore({ items: [1, 2, 3] })
+
+ // This is our solution: check for array methods instead
+ expect(typeof store.items.filter).toBe("function")
+ expect(typeof store.items.map).toBe("function")
+ expect(typeof store.items.length).toBe("number")
+
+ dispose()
+ })
+ })
+
+ test("store proxy arrays can be filtered and mapped", () => {
+ createRoot((dispose) => {
+ const [store] = createStore({
+ items: [
+ { id: 1, active: true },
+ { id: 2, active: false },
+ { id: 3, active: true },
+ ],
+ })
+
+ // Verify filtering works
+ const active = store.items.filter((x) => x.active)
+ expect(active.length).toBe(2)
+ expect(active[0].id).toBe(1)
+ expect(active[1].id).toBe(3)
+
+ // Verify mapping works
+ const ids = store.items.map((x) => x.id)
+ expect(ids).toEqual([1, 2, 3])
+
+ dispose()
+ })
+ })
+
+ test("isArrayLike helper function", () => {
+ // Helper function that works with both real arrays and store proxies
+ const isArrayLike = (value: unknown): value is unknown[] =>
+ !!value && typeof value === "object" && typeof (value as { filter?: unknown }).filter === "function"
+
+ createRoot((dispose) => {
+ const [store] = createStore({ items: [1, 2, 3] })
+
+ // Works for store proxy
+ expect(isArrayLike(store.items)).toBe(true)
+
+ // Works for real array
+ expect(isArrayLike([1, 2, 3])).toBe(true)
+
+ // Returns false for non-arrays
+ expect(isArrayLike(null)).toBe(false)
+ expect(isArrayLike(undefined)).toBe(false)
+ expect(isArrayLike({})).toBe(false)
+ expect(isArrayLike("string")).toBe(false)
+ expect(isArrayLike(123)).toBe(false)
+
+ dispose()
+ })
+ })
+
+ test("empty store proxy arrays", () => {
+ createRoot((dispose) => {
+ const [store] = createStore({ items: [] as number[] })
+
+ expect(typeof store.items.filter).toBe("function")
+ expect(store.items.length).toBe(0)
+ expect(store.items.filter((x) => x > 0)).toEqual([])
+
+ dispose()
+ })
+ })
+
+ test("nested store proxy arrays", () => {
+ createRoot((dispose) => {
+ const [store] = createStore({
+ projects: [
+ { name: "foo", sessions: [{ id: "s1" }, { id: "s2" }] },
+ { name: "bar", sessions: [{ id: "s3" }] },
+ ],
+ })
+
+ // Top-level array
+ expect(typeof store.projects.filter).toBe("function")
+
+ // Nested arrays
+ expect(typeof store.projects[0].sessions.filter).toBe("function")
+ expect(store.projects[0].sessions.length).toBe(2)
+
+ // Filtering nested arrays
+ const allSessions = store.projects.flatMap((p) => p.sessions)
+ expect(allSessions.length).toBe(3)
+
+ dispose()
+ })
+ })
+})
diff --git a/packages/app/src/utils/directory-search.test.ts b/packages/app/src/utils/directory-search.test.ts
new file mode 100644
index 00000000000..fdfd6779844
--- /dev/null
+++ b/packages/app/src/utils/directory-search.test.ts
@@ -0,0 +1,175 @@
+import { describe, expect, test } from "bun:test"
+import {
+ joinPath,
+ displayPath,
+ normalizeQuery,
+ projectsToRelative,
+ filterProjects,
+ combineResults,
+} from "./directory-search"
+
+describe("directory-search utilities", () => {
+ describe("joinPath", () => {
+ test("joins base and relative paths", () => {
+ expect(joinPath("/Users/foo", "bar")).toBe("/Users/foo/bar")
+ expect(joinPath("/Users/foo/", "bar")).toBe("/Users/foo/bar")
+ expect(joinPath("/Users/foo", "/bar")).toBe("/Users/foo/bar")
+ expect(joinPath("/Users/foo/", "/bar/")).toBe("/Users/foo/bar")
+ })
+
+ test("handles empty base", () => {
+ expect(joinPath("", "bar")).toBe("bar")
+ expect(joinPath(undefined, "bar")).toBe("bar")
+ })
+
+ test("handles empty relative", () => {
+ expect(joinPath("/Users/foo", "")).toBe("/Users/foo")
+ expect(joinPath("/Users/foo/", "")).toBe("/Users/foo")
+ })
+
+ test("handles both empty", () => {
+ expect(joinPath("", "")).toBe("")
+ expect(joinPath(undefined, "")).toBe("")
+ })
+ })
+
+ describe("displayPath", () => {
+ const home = "/Users/athal"
+
+ test("shows ~ for home directory", () => {
+ expect(displayPath("/Users/athal", home)).toBe("~")
+ })
+
+ test("shows ~/relative for paths under home", () => {
+ expect(displayPath("/Users/athal/Documents", home)).toBe("~/Documents")
+ expect(displayPath("/Users/athal/Documents/GitHub", home)).toBe("~/Documents/GitHub")
+ })
+
+ test("shows full path for paths outside home", () => {
+ expect(displayPath("/opt/homebrew", home)).toBe("/opt/homebrew")
+ expect(displayPath("/var/log", home)).toBe("/var/log")
+ })
+
+ test("handles undefined home", () => {
+ expect(displayPath("/Users/athal/Documents", undefined)).toBe("/Users/athal/Documents")
+ })
+ })
+
+ describe("normalizeQuery", () => {
+ const home = "/Users/athal"
+
+ test("removes ~/ prefix", () => {
+ expect(normalizeQuery("~/Documents", home)).toBe("Documents")
+ expect(normalizeQuery("~/Documents/GitHub", home)).toBe("Documents/GitHub")
+ })
+
+ test("removes home directory prefix", () => {
+ expect(normalizeQuery("/Users/athal/Documents", home)).toBe("Documents")
+ expect(normalizeQuery("/Users/athal", home)).toBe("")
+ })
+
+ test("handles case-insensitive home prefix", () => {
+ expect(normalizeQuery("/USERS/ATHAL/Documents", home)).toBe("Documents")
+ })
+
+ test("returns query unchanged for other paths", () => {
+ expect(normalizeQuery("Documents", home)).toBe("Documents")
+ expect(normalizeQuery("opencode", home)).toBe("opencode")
+ })
+
+ test("handles empty query", () => {
+ expect(normalizeQuery("", home)).toBe("")
+ })
+
+ test("handles undefined home", () => {
+ expect(normalizeQuery("~/Documents", undefined)).toBe("Documents")
+ expect(normalizeQuery("/Users/athal/Documents", undefined)).toBe("/Users/athal/Documents")
+ })
+ })
+
+ describe("projectsToRelative", () => {
+ const home = "/Users/athal"
+
+ test("converts absolute paths to relative", () => {
+ const projects = [
+ { worktree: "/Users/athal/Documents/GitHub/opencode" },
+ { worktree: "/Users/athal/Documents/GitHub/chezmoi" },
+ ]
+ expect(projectsToRelative(projects, home)).toEqual(["Documents/GitHub/opencode", "Documents/GitHub/chezmoi"])
+ })
+
+ test("keeps paths outside home as absolute", () => {
+ const projects = [{ worktree: "/opt/projects/foo" }, { worktree: "/Users/athal/bar" }]
+ expect(projectsToRelative(projects, home)).toEqual(["/opt/projects/foo", "bar"])
+ })
+
+ test("filters out undefined worktrees", () => {
+ const projects = [{ worktree: "/Users/athal/foo" }, { worktree: undefined }, { worktree: "" }]
+ expect(projectsToRelative(projects, home)).toEqual(["foo"])
+ })
+
+ test("handles undefined home", () => {
+ const projects = [{ worktree: "/Users/athal/foo" }]
+ expect(projectsToRelative(projects, undefined)).toEqual(["/Users/athal/foo"])
+ })
+ })
+
+ describe("filterProjects", () => {
+ const projects = [
+ "Documents/GitHub/opencode",
+ "Documents/GitHub/chezmoi",
+ "Projects/work/api",
+ "Projects/personal/blog",
+ ]
+
+ test("filters by partial match", () => {
+ expect(filterProjects(projects, "open")).toEqual(["Documents/GitHub/opencode"])
+ expect(filterProjects(projects, "GitHub")).toEqual(["Documents/GitHub/opencode", "Documents/GitHub/chezmoi"])
+ })
+
+ test("is case-insensitive", () => {
+ expect(filterProjects(projects, "OPEN")).toEqual(["Documents/GitHub/opencode"])
+ expect(filterProjects(projects, "github")).toEqual(["Documents/GitHub/opencode", "Documents/GitHub/chezmoi"])
+ })
+
+ test("returns all projects for empty query", () => {
+ expect(filterProjects(projects, "")).toEqual(projects)
+ })
+
+ test("returns empty array for no matches", () => {
+ expect(filterProjects(projects, "nonexistent")).toEqual([])
+ })
+ })
+
+ describe("combineResults", () => {
+ test("puts projects first", () => {
+ const projects = ["foo", "bar"]
+ const search = ["baz", "qux"]
+ expect(combineResults(projects, search)).toEqual(["foo", "bar", "baz", "qux"])
+ })
+
+ test("deduplicates results", () => {
+ const projects = ["foo", "bar"]
+ const search = ["bar", "baz", "foo"]
+ expect(combineResults(projects, search)).toEqual(["foo", "bar", "baz"])
+ })
+
+ test("respects limit", () => {
+ const projects = ["a", "b"]
+ const search = ["c", "d", "e", "f"]
+ expect(combineResults(projects, search, 4)).toEqual(["a", "b", "c", "d"])
+ })
+
+ test("handles empty projects", () => {
+ const projects: string[] = []
+ const search = ["foo", "bar"]
+ expect(combineResults(projects, search)).toEqual(["foo", "bar"])
+ })
+
+ test("handles empty search", () => {
+ const projects = ["foo", "bar"]
+ const search: string[] = []
+ expect(combineResults(projects, search)).toEqual(["foo", "bar"])
+ })
+ })
+})
diff --git a/packages/app/src/utils/directory-search.ts b/packages/app/src/utils/directory-search.ts
new file mode 100644
index 00000000000..13cc64085f4
--- /dev/null
+++ b/packages/app/src/utils/directory-search.ts
@@ -0,0 +1,82 @@
+/**
+ * Utilities for directory search functionality.
+ * Used by DialogSelectDirectory to combine known projects with search results.
+ */
+
+/**
+ * Joins a base path with a relative path, handling slashes.
+ */
+export function joinPath(base: string | undefined, rel: string): string {
+ const b = (base ?? "").replace(/[\\/]+$/, "")
+ const r = rel.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "")
+ if (!b) return r
+ if (!r) return b
+ return b + "/" + r
+}
+
+/**
+ * Converts an absolute path to a display path with ~ for home.
+ */
+export function displayPath(full: string, home: string | undefined): string {
+ if (!home) return full
+ if (full === home) return "~"
+ if (full.startsWith(home + "/") || full.startsWith(home + "\\")) {
+ return "~" + full.slice(home.length)
+ }
+ return full
+}
+
+/**
+ * Normalizes a search query, handling ~ prefix and home directory prefix.
+ */
+export function normalizeQuery(query: string, home: string | undefined): string {
+ if (!query) return query
+ if (query.startsWith("~/")) return query.slice(2)
+
+ if (home) {
+ const lc = query.toLowerCase()
+ const hc = home.toLowerCase()
+ if (lc === hc || lc.startsWith(hc + "/") || lc.startsWith(hc + "\\")) {
+ return query.slice(home.length).replace(/^[\\/]+/, "")
+ }
+ }
+
+ return query
+}
+
+/**
+ * Converts absolute project paths to relative paths from home.
+ */
+export function projectsToRelative(projects: { worktree?: string }[], home: string | undefined): string[] {
+ return projects
+ .map((p) => p.worktree)
+ .filter((w): w is string => !!w)
+ .map((w) => {
+ if (home && (w.startsWith(home + "/") || w.startsWith(home + "\\"))) {
+ return w.slice(home.length + 1)
+ }
+ return w
+ })
+}
+
+/**
+ * Filters projects by a search query (case-insensitive partial match).
+ */
+export function filterProjects(projects: string[], query: string): string[] {
+ if (!query) return projects
+ const lowerQuery = query.toLowerCase()
+ return projects.filter((p) => p.toLowerCase().includes(lowerQuery))
+}
+
+/**
+ * Combines known projects with search results, deduplicating and prioritizing projects.
+ */
+export function combineResults(projects: string[], searchResults: string[], limit: number = 50): string[] {
+ const combined = [...projects]
+ for (const dir of searchResults) {
+ if (!combined.includes(dir)) {
+ combined.push(dir)
+ }
+ }
+ return combined.slice(0, limit)
+}
diff --git a/packages/app/src/utils/project-order.test.ts b/packages/app/src/utils/project-order.test.ts
new file mode 100644
index 00000000000..9419d3b53be
--- /dev/null
+++ b/packages/app/src/utils/project-order.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, test } from "bun:test"
+
+/**
+ * Tests for project reordering logic.
+ *
+ * Projects can be reordered via drag-and-drop (desktop) or
+ * "Move up/down" menu options (mobile).
+ */
+
+type Project = { worktree: string; name?: string }
+
+/**
+ * Moves a project from one index to another.
+ * Returns the new array with the project moved.
+ */
+function moveProject(projects: Project[], worktree: string, toIndex: number): Project[] {
+ const fromIndex = projects.findIndex((p) => p.worktree === worktree)
+ if (fromIndex === -1) return projects
+ if (toIndex < 0 || toIndex >= projects.length) return projects
+ if (fromIndex === toIndex) return projects
+
+ const result = [...projects]
+ const [removed] = result.splice(fromIndex, 1)
+ result.splice(toIndex, 0, removed)
+ return result
+}
+
+/**
+ * Determines if a project can move up (index > 0).
+ */
+function canMoveUp(projects: Project[], worktree: string): boolean {
+ const index = projects.findIndex((p) => p.worktree === worktree)
+ return index > 0
+}
+
+/**
+ * Determines if a project can move down (index < length - 1).
+ */
+function canMoveDown(projects: Project[], worktree: string): boolean {
+ const index = projects.findIndex((p) => p.worktree === worktree)
+ return index !== -1 && index < projects.length - 1
+}
+
+describe("moveProject", () => {
+ const projects: Project[] = [
+ { worktree: "/a", name: "A" },
+ { worktree: "/b", name: "B" },
+ { worktree: "/c", name: "C" },
+ ]
+
+ test("moves project up", () => {
+ const result = moveProject(projects, "/b", 0)
+ expect(result.map((p) => p.worktree)).toEqual(["/b", "/a", "/c"])
+ })
+
+ test("moves project down", () => {
+ const result = moveProject(projects, "/a", 1)
+ expect(result.map((p) => p.worktree)).toEqual(["/b", "/a", "/c"])
+ })
+
+ test("moves project to end", () => {
+ const result = moveProject(projects, "/a", 2)
+ expect(result.map((p) => p.worktree)).toEqual(["/b", "/c", "/a"])
+ })
+
+ test("returns original array if project not found", () => {
+ const result = moveProject(projects, "/nonexistent", 1)
+ expect(result).toEqual(projects)
+ })
+
+ test("returns original array if toIndex is out of bounds", () => {
+ expect(moveProject(projects, "/a", -1)).toEqual(projects)
+ expect(moveProject(projects, "/a", 10)).toEqual(projects)
+ })
+
+ test("returns original array if moving to same position", () => {
+ const result = moveProject(projects, "/b", 1)
+ expect(result).toEqual(projects)
+ })
+
+ test("does not mutate original array", () => {
+ const original = [...projects]
+ moveProject(projects, "/a", 2)
+ expect(projects).toEqual(original)
+ })
+})
+
+describe("canMoveUp", () => {
+ const projects: Project[] = [{ worktree: "/a" }, { worktree: "/b" }, { worktree: "/c" }]
+
+ test("returns false for first project", () => {
+ expect(canMoveUp(projects, "/a")).toBe(false)
+ })
+
+ test("returns true for middle project", () => {
+ expect(canMoveUp(projects, "/b")).toBe(true)
+ })
+
+ test("returns true for last project", () => {
+ expect(canMoveUp(projects, "/c")).toBe(true)
+ })
+
+ test("returns false for nonexistent project", () => {
+ expect(canMoveUp(projects, "/nonexistent")).toBe(false)
+ })
+})
+
+describe("canMoveDown", () => {
+ const projects: Project[] = [{ worktree: "/a" }, { worktree: "/b" }, { worktree: "/c" }]
+
+ test("returns true for first project", () => {
+ expect(canMoveDown(projects, "/a")).toBe(true)
+ })
+
+ test("returns true for middle project", () => {
+ expect(canMoveDown(projects, "/b")).toBe(true)
+ })
+
+ test("returns false for last project", () => {
+ expect(canMoveDown(projects, "/c")).toBe(false)
+ })
+
+ test("returns false for nonexistent project", () => {
+ expect(canMoveDown(projects, "/nonexistent")).toBe(false)
+ })
+})
+
+describe("move up/down integration", () => {
+ test("move up decrements index by 1", () => {
+ const projects: Project[] = [{ worktree: "/a" }, { worktree: "/b" }, { worktree: "/c" }]
+
+ // Simulate "Move up" for /b
+ const index = projects.findIndex((p) => p.worktree === "/b")
+ if (index > 0) {
+ const result = moveProject(projects, "/b", index - 1)
+ expect(result.map((p) => p.worktree)).toEqual(["/b", "/a", "/c"])
+ }
+ })
+
+ test("move down increments index by 1", () => {
+ const projects: Project[] = [{ worktree: "/a" }, { worktree: "/b" }, { worktree: "/c" }]
+
+ // Simulate "Move down" for /b
+ const index = projects.findIndex((p) => p.worktree === "/b")
+ if (index < projects.length - 1) {
+ const result = moveProject(projects, "/b", index + 1)
+ expect(result.map((p) => p.worktree)).toEqual(["/a", "/c", "/b"])
+ }
+ })
+})
diff --git a/packages/app/src/utils/swipe.test.ts b/packages/app/src/utils/swipe.test.ts
new file mode 100644
index 00000000000..6377fc7a70c
--- /dev/null
+++ b/packages/app/src/utils/swipe.test.ts
@@ -0,0 +1,208 @@
+import { describe, expect, test, mock } from "bun:test"
+import { createRoot } from "solid-js"
+import { createSwipeHandlers, isHorizontalSwipe, clampSwipeOffset } from "./swipe"
+
+// Helper to create mock TouchEvent
+function createTouchEvent(type: string, clientX: number, clientY: number): TouchEvent {
+ return {
+ type,
+ touches: [{ clientX, clientY }],
+ preventDefault: mock(() => {}),
+ } as unknown as TouchEvent
+}
+
+describe("swipe utilities", () => {
+ describe("isHorizontalSwipe", () => {
+ test("returns true when horizontal movement is greater", () => {
+ expect(isHorizontalSwipe(100, 50)).toBe(true)
+ expect(isHorizontalSwipe(-100, 50)).toBe(true)
+ expect(isHorizontalSwipe(100, -50)).toBe(true)
+ })
+
+ test("returns false when vertical movement is greater", () => {
+ expect(isHorizontalSwipe(50, 100)).toBe(false)
+ expect(isHorizontalSwipe(50, -100)).toBe(false)
+ expect(isHorizontalSwipe(-50, 100)).toBe(false)
+ })
+
+ test("returns false when movements are equal", () => {
+ expect(isHorizontalSwipe(50, 50)).toBe(false)
+ expect(isHorizontalSwipe(0, 0)).toBe(false)
+ })
+ })
+
+ describe("clampSwipeOffset", () => {
+ const threshold = 80
+
+ test("clamps left swipe within bounds", () => {
+ expect(clampSwipeOffset(-50, threshold, "left")).toBe(-50)
+ expect(clampSwipeOffset(-100, threshold, "left")).toBe(-100)
+ expect(clampSwipeOffset(-150, threshold, "left")).toBe(-100) // maxSwipe = 80 + 20
+ })
+
+ test("prevents right swipe when direction is left", () => {
+ expect(clampSwipeOffset(50, threshold, "left")).toBe(0)
+ expect(clampSwipeOffset(100, threshold, "left")).toBe(0)
+ })
+
+ test("clamps right swipe within bounds", () => {
+ expect(clampSwipeOffset(50, threshold, "right")).toBe(50)
+ expect(clampSwipeOffset(100, threshold, "right")).toBe(100)
+ expect(clampSwipeOffset(150, threshold, "right")).toBe(100) // maxSwipe = 80 + 20
+ })
+
+ test("prevents left swipe when direction is right", () => {
+ expect(clampSwipeOffset(-50, threshold, "right")).toBe(0)
+ expect(clampSwipeOffset(-100, threshold, "right")).toBe(0)
+ })
+ })
+
+ describe("createSwipeHandlers", () => {
+ test("initializes with default state", () => {
+ createRoot((dispose) => {
+ const { state } = createSwipeHandlers()
+
+ expect(state.x()).toBe(0)
+ expect(state.swiping()).toBe(false)
+ expect(state.triggered()).toBe(false)
+
+ dispose()
+ })
+ })
+
+ test("tracks swipe state during touch", () => {
+ createRoot((dispose) => {
+ const { state, handlers } = createSwipeHandlers({ direction: "left" })
+
+ // Start touch
+ handlers.onTouchStart(createTouchEvent("touchstart", 200, 100))
+ expect(state.swiping()).toBe(true)
+
+ // Move left (horizontal swipe)
+ handlers.onTouchMove(createTouchEvent("touchmove", 150, 100))
+ expect(state.x()).toBe(-50)
+
+ // End touch
+ handlers.onTouchEnd()
+ expect(state.swiping()).toBe(false)
+ expect(state.x()).toBe(0)
+
+ dispose()
+ })
+ })
+
+ test("triggers callback when threshold is reached", () => {
+ createRoot((dispose) => {
+ const onSwipe = mock(() => {})
+ const { state, handlers } = createSwipeHandlers({
+ direction: "left",
+ threshold: 80,
+ onSwipe,
+ })
+
+ // Start and swipe past threshold
+ handlers.onTouchStart(createTouchEvent("touchstart", 200, 100))
+ handlers.onTouchMove(createTouchEvent("touchmove", 100, 100)) // -100px, past threshold
+ handlers.onTouchEnd()
+
+ expect(onSwipe).toHaveBeenCalledTimes(1)
+ expect(state.triggered()).toBe(true)
+
+ dispose()
+ })
+ })
+
+ test("does not trigger when threshold is not reached", () => {
+ createRoot((dispose) => {
+ const onSwipe = mock(() => {})
+ const { state, handlers } = createSwipeHandlers({
+ direction: "left",
+ threshold: 80,
+ onSwipe,
+ })
+
+ // Start and swipe but not past threshold
+ handlers.onTouchStart(createTouchEvent("touchstart", 200, 100))
+ handlers.onTouchMove(createTouchEvent("touchmove", 160, 100)) // -40px, under threshold
+ handlers.onTouchEnd()
+
+ expect(onSwipe).not.toHaveBeenCalled()
+ expect(state.triggered()).toBe(false)
+
+ dispose()
+ })
+ })
+
+ test("ignores vertical swipes", () => {
+ createRoot((dispose) => {
+ const { state, handlers } = createSwipeHandlers({ direction: "left" })
+
+ // Start touch
+ handlers.onTouchStart(createTouchEvent("touchstart", 200, 100))
+
+ // Move vertically (scroll gesture)
+ handlers.onTouchMove(createTouchEvent("touchmove", 190, 200))
+
+ // X should not change for vertical movement
+ expect(state.x()).toBe(0)
+
+ dispose()
+ })
+ })
+
+ test("ignores wrong direction swipes", () => {
+ createRoot((dispose) => {
+ const { state, handlers } = createSwipeHandlers({ direction: "left" })
+
+ handlers.onTouchStart(createTouchEvent("touchstart", 100, 100))
+ handlers.onTouchMove(createTouchEvent("touchmove", 200, 100)) // Right swipe
+
+ expect(state.x()).toBe(0) // Should not move
+
+ dispose()
+ })
+ })
+
+ test("respects enabled option", () => {
+ createRoot((dispose) => {
+ const onSwipe = mock(() => {})
+ const { state, handlers } = createSwipeHandlers({
+ direction: "left",
+ enabled: false,
+ onSwipe,
+ })
+
+ handlers.onTouchStart(createTouchEvent("touchstart", 200, 100))
+ expect(state.swiping()).toBe(false)
+
+ handlers.onTouchMove(createTouchEvent("touchmove", 100, 100))
+ expect(state.x()).toBe(0)
+
+ handlers.onTouchEnd()
+ expect(onSwipe).not.toHaveBeenCalled()
+
+ dispose()
+ })
+ })
+
+ test("right swipe direction works correctly", () => {
+ createRoot((dispose) => {
+ const onSwipe = mock(() => {})
+ const { state, handlers } = createSwipeHandlers({
+ direction: "right",
+ threshold: 80,
+ onSwipe,
+ })
+
+ handlers.onTouchStart(createTouchEvent("touchstart", 100, 100))
+ handlers.onTouchMove(createTouchEvent("touchmove", 200, 100)) // +100px right
+ expect(state.x()).toBe(100)
+
+ handlers.onTouchEnd()
+ expect(onSwipe).toHaveBeenCalledTimes(1)
+
+ dispose()
+ })
+ })
+ })
+})
diff --git a/packages/app/src/utils/swipe.ts b/packages/app/src/utils/swipe.ts
new file mode 100644
index 00000000000..8d45db6a237
--- /dev/null
+++ b/packages/app/src/utils/swipe.ts
@@ -0,0 +1,136 @@
+import { createSignal, Accessor } from "solid-js"
+
+export interface SwipeState {
+ /** Current X offset during swipe */
+ x: Accessor
+ /** Whether a swipe is in progress */
+ swiping: Accessor
+ /** Whether swipe threshold was reached */
+ triggered: Accessor
+}
+
+export interface SwipeHandlers {
+ onTouchStart: (e: TouchEvent) => void
+ onTouchMove: (e: TouchEvent) => void
+ onTouchEnd: () => void
+}
+
+export interface SwipeOptions {
+ /** Swipe threshold in pixels to trigger action */
+ threshold?: number
+ /** Direction of swipe: 'left' or 'right' */
+ direction?: "left" | "right"
+ /** Callback when swipe threshold is reached */
+ onSwipe?: () => void
+ /** Whether swipe is enabled */
+ enabled?: boolean
+}
+
+const DEFAULT_THRESHOLD = 80
+
+/**
+ * Creates swipe gesture handlers for touch-based swipe-to-action UI.
+ *
+ * Usage:
+ * ```tsx
+ * const { state, handlers } = createSwipeHandlers({
+ * direction: 'left',
+ * threshold: 80,
+ * onSwipe: () => archiveItem()
+ * })
+ *
+ *
+ * Content
+ *
+ * ```
+ */
+export function createSwipeHandlers(options: SwipeOptions = {}): {
+ state: SwipeState
+ handlers: SwipeHandlers
+} {
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD
+ const direction = options.direction ?? "left"
+ const enabled = options.enabled ?? true
+
+ const [x, setX] = createSignal(0)
+ const [swiping, setSwiping] = createSignal(false)
+ const [triggered, setTriggered] = createSignal(false)
+
+ let touchStartX = 0
+ let touchStartY = 0
+
+ const onTouchStart = (e: TouchEvent) => {
+ if (!enabled) return
+ touchStartX = e.touches[0].clientX
+ touchStartY = e.touches[0].clientY
+ setSwiping(true)
+ setTriggered(false)
+ }
+
+ const onTouchMove = (e: TouchEvent) => {
+ if (!enabled || !swiping()) return
+
+ const deltaX = e.touches[0].clientX - touchStartX
+ const deltaY = e.touches[0].clientY - touchStartY
+
+ // Only handle horizontal swipes (avoid interfering with scroll)
+ if (Math.abs(deltaX) <= Math.abs(deltaY)) return
+
+ // Check direction
+ const isCorrectDirection = direction === "left" ? deltaX < 0 : deltaX > 0
+ if (!isCorrectDirection) {
+ setX(0)
+ return
+ }
+
+ e.preventDefault()
+
+ // Clamp the swipe distance
+ const maxSwipe = threshold + 20
+ const clampedX = direction === "left" ? Math.max(deltaX, -maxSwipe) : Math.min(deltaX, maxSwipe)
+
+ setX(clampedX)
+ }
+
+ const onTouchEnd = () => {
+ if (!enabled) return
+
+ const currentX = Math.abs(x())
+ if (currentX >= threshold) {
+ setTriggered(true)
+ options.onSwipe?.()
+ }
+
+ setX(0)
+ setSwiping(false)
+ }
+
+ return {
+ state: { x, swiping, triggered },
+ handlers: { onTouchStart, onTouchMove, onTouchEnd },
+ }
+}
+
+/**
+ * Determines if a touch movement is primarily horizontal.
+ * Used to distinguish swipe gestures from scroll gestures.
+ */
+export function isHorizontalSwipe(deltaX: number, deltaY: number): boolean {
+ return Math.abs(deltaX) > Math.abs(deltaY)
+}
+
+/**
+ * Calculates the clamped swipe offset.
+ */
+export function clampSwipeOffset(delta: number, threshold: number, direction: "left" | "right"): number {
+ const maxSwipe = threshold + 20
+ if (direction === "left") {
+ return Math.max(Math.min(delta, 0), -maxSwipe)
+ }
+ return Math.min(Math.max(delta, 0), maxSwipe)
+}
diff --git a/packages/app/src/utils/variant.test.ts b/packages/app/src/utils/variant.test.ts
new file mode 100644
index 00000000000..d3c84620579
--- /dev/null
+++ b/packages/app/src/utils/variant.test.ts
@@ -0,0 +1,148 @@
+import { describe, expect, test } from "bun:test"
+
+/**
+ * Tests for model variant (thinking effort) cycling logic.
+ *
+ * The variant system allows users to cycle through different "thinking effort"
+ * levels for models that support it (e.g., low, medium, high).
+ */
+
+type VariantState = {
+ current: string | undefined
+ variants: string[]
+}
+
+/**
+ * Pure function that implements the variant cycling logic.
+ * Extracted from local.tsx for testability.
+ */
+function cycleVariant(state: VariantState): string | undefined {
+ const { current, variants } = state
+
+ if (variants.length === 0) return current
+
+ if (!current) {
+ return variants[0]
+ }
+
+ const index = variants.indexOf(current)
+ if (index === -1 || index === variants.length - 1) {
+ return undefined // Reset to default
+ }
+
+ return variants[index + 1]
+}
+
+describe("variant cycling", () => {
+ test("returns undefined when no variants available", () => {
+ expect(cycleVariant({ current: undefined, variants: [] })).toBe(undefined)
+ expect(cycleVariant({ current: "low", variants: [] })).toBe("low")
+ })
+
+ test("starts with first variant when current is undefined", () => {
+ expect(cycleVariant({ current: undefined, variants: ["low", "medium", "high"] })).toBe("low")
+ })
+
+ test("cycles through variants in order", () => {
+ const variants = ["low", "medium", "high"]
+
+ expect(cycleVariant({ current: "low", variants })).toBe("medium")
+ expect(cycleVariant({ current: "medium", variants })).toBe("high")
+ })
+
+ test("resets to undefined after last variant", () => {
+ const variants = ["low", "medium", "high"]
+
+ expect(cycleVariant({ current: "high", variants })).toBe(undefined)
+ })
+
+ test("resets to undefined when current is not in variants list", () => {
+ const variants = ["low", "medium", "high"]
+
+ expect(cycleVariant({ current: "unknown", variants })).toBe(undefined)
+ })
+
+ test("handles single variant", () => {
+ const variants = ["low"]
+
+ expect(cycleVariant({ current: undefined, variants })).toBe("low")
+ expect(cycleVariant({ current: "low", variants })).toBe(undefined)
+ })
+
+ test("full cycle returns to start", () => {
+ const variants = ["low", "medium", "high"]
+ let current: string | undefined = undefined
+
+ // Cycle through all variants and back to default
+ current = cycleVariant({ current, variants }) // -> low
+ expect(current).toBe("low")
+
+ current = cycleVariant({ current, variants }) // -> medium
+ expect(current).toBe("medium")
+
+ current = cycleVariant({ current, variants }) // -> high
+ expect(current).toBe("high")
+
+ current = cycleVariant({ current, variants }) // -> undefined (default)
+ expect(current).toBe(undefined)
+
+ current = cycleVariant({ current, variants }) // -> low (restart)
+ expect(current).toBe("low")
+ })
+})
+
+describe("variant key generation", () => {
+ /**
+ * Generates a unique key for storing variant preference per model.
+ */
+ function getVariantKey(providerId: string, modelId: string): string {
+ return `${providerId}/${modelId}`
+ }
+
+ test("creates correct key format", () => {
+ expect(getVariantKey("anthropic", "claude-3-5-sonnet")).toBe("anthropic/claude-3-5-sonnet")
+ expect(getVariantKey("openai", "o1")).toBe("openai/o1")
+ })
+
+ test("handles special characters in IDs", () => {
+ expect(getVariantKey("custom-provider", "model-v2.1")).toBe("custom-provider/model-v2.1")
+ })
+})
+
+describe("variant list extraction", () => {
+ type Model = {
+ id: string
+ variants?: Record
+ }
+
+ function getVariantList(model: Model | undefined): string[] {
+ if (!model) return []
+ if (!model.variants) return []
+ return Object.keys(model.variants)
+ }
+
+ test("returns empty array for undefined model", () => {
+ expect(getVariantList(undefined)).toEqual([])
+ })
+
+ test("returns empty array for model without variants", () => {
+ expect(getVariantList({ id: "test" })).toEqual([])
+ expect(getVariantList({ id: "test", variants: undefined })).toEqual([])
+ })
+
+ test("returns variant keys for model with variants", () => {
+ const model = {
+ id: "o1",
+ variants: {
+ low: { maxTokens: 1000 },
+ medium: { maxTokens: 5000 },
+ high: { maxTokens: 10000 },
+ },
+ }
+ expect(getVariantList(model)).toEqual(["low", "medium", "high"])
+ })
+
+ test("returns empty array for empty variants object", () => {
+ expect(getVariantList({ id: "test", variants: {} })).toEqual([])
+ })
+})
diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts
index 6a29ae6345e..279c4c6602b 100644
--- a/packages/app/vite.config.ts
+++ b/packages/app/vite.config.ts
@@ -6,7 +6,30 @@ export default defineConfig({
server: {
host: "0.0.0.0",
allowedHosts: true,
- port: 3000,
+ port: 5173,
+ cors: true,
+ proxy: {
+ // Proxy API requests to the opencode server to avoid CORS issues
+ "/global": "http://localhost:4096",
+ "/session": "http://localhost:4096",
+ "/message": "http://localhost:4096",
+ "/project": "http://localhost:4096",
+ "/provider": "http://localhost:4096",
+ "/config": "http://localhost:4096",
+ "/path": "http://localhost:4096",
+ "/app": "http://localhost:4096",
+ "/agent": "http://localhost:4096",
+ "/command": "http://localhost:4096",
+ "/mcp": "http://localhost:4096",
+ "/lsp": "http://localhost:4096",
+ "/vcs": "http://localhost:4096",
+ "/permission": "http://localhost:4096",
+ "/question": "http://localhost:4096",
+ "/file": "http://localhost:4096",
+ "/terminal": "http://localhost:4096",
+ "/find": "http://localhost:4096",
+ "/log": "http://localhost:4096",
+ },
},
build: {
target: "esnext",
diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts
index a2887546d4b..d90dfee0b11 100644
--- a/packages/opencode/src/tool/question.ts
+++ b/packages/opencode/src/tool/question.ts
@@ -26,6 +26,8 @@ export const QuestionTool = Tool.define("question", {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
metadata: {
+ sessionID: ctx.sessionID,
+ callID: ctx.callID,
answers,
},
}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 165f46f6c50..cec83ec354e 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -449,6 +449,8 @@ export interface ToolProps {
defaultOpen?: boolean
forceOpen?: boolean
locked?: boolean
+ sessionID?: string
+ callID?: string
}
export type ToolComponent = Component
@@ -580,6 +582,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
forceOpen={forceOpen()}
locked={showPermission() || showQuestion()}
defaultOpen={props.defaultOpen}
+ sessionID={props.message.sessionID}
+ callID={part.callID}
/>
@@ -1143,7 +1147,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? [])
- data.replyToQuestion?.({
+ data.respondToQuestion?.({
requestID: props.request.id,
answers,
})
@@ -1165,7 +1169,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
setStore("custom", inputs)
}
if (single()) {
- data.replyToQuestion?.({
+ data.respondToQuestion?.({
requestID: props.request.id,
answers: [[answer]],
})
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 1e3cc0b2921..e6048919712 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -6,6 +6,7 @@
display: flex;
align-items: flex-start;
justify-content: flex-start;
+ overflow-x: hidden;
[data-slot="session-turn-content"] {
flex-grow: 1;
@@ -13,6 +14,7 @@
height: 100%;
min-width: 0;
overflow-y: auto;
+ overflow-x: hidden;
scrollbar-width: none;
}
@@ -28,37 +30,6 @@
min-width: 0;
gap: 28px;
overflow-anchor: none;
-
- [data-slot="session-turn-user-badges"] {
- position: absolute;
- right: 0;
- display: flex;
- gap: 6px;
- padding-left: 16px;
- background: linear-gradient(to right, transparent, var(--background-stronger) 12px);
- opacity: 0;
- transition: opacity 0.15s ease;
- pointer-events: none;
- }
-
- &:hover [data-slot="session-turn-user-badges"] {
- opacity: 1;
- pointer-events: auto;
- }
-
- [data-slot="session-turn-badge"] {
- display: inline-flex;
- align-items: center;
- padding: 2px 6px;
- border-radius: 4px;
- font-family: var(--font-family-mono);
- font-size: var(--font-size-x-small);
- font-weight: var(--font-weight-medium);
- line-height: var(--line-height-normal);
- white-space: nowrap;
- color: var(--text-base);
- background: var(--surface-raised-base);
- }
}
[data-slot="session-turn-sticky-title"] {
@@ -83,6 +54,7 @@
[data-slot="session-turn-message-header"] {
display: flex;
align-items: center;
+ gap: 8px;
align-self: stretch;
height: 32px;
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index ae1321bac14..d29367a3ea1 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -3,9 +3,9 @@ import {
Message as MessageType,
Part as PartType,
type PermissionRequest,
+ type QuestionRequest,
TextPart,
ToolPart,
- UserMessage,
} from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
@@ -22,8 +22,6 @@ import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
-import { ProviderIcon } from "./provider-icon"
-import type { IconName } from "./provider-icons/types"
import { IconButton } from "./icon-button"
import { Tooltip } from "./tooltip"
import { Card } from "./card"
@@ -255,6 +253,31 @@ export function SessionTurn(
return emptyPermissionParts
})
+ const emptyQuestions: QuestionRequest[] = []
+ const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
+ const questions = createMemo(() => data.store.question?.[props.sessionID] ?? emptyQuestions)
+ const questionCount = createMemo(() => questions().length)
+ const nextQuestion = createMemo(() => questions()[0])
+
+ const questionParts = createMemo(() => {
+ if (props.stepsExpanded) return emptyQuestionParts
+
+ const next = nextQuestion()
+ if (!next || !next.tool) return emptyQuestionParts
+
+ const message = assistantMessages().findLast((m) => m.id === next.tool!.messageID)
+ if (!message) return emptyQuestionParts
+
+ const parts = data.store.part[message.id] ?? emptyParts
+ for (const part of parts) {
+ if (part?.type !== "tool") continue
+ const tool = part as ToolPart
+ if (tool.callID === next.tool?.callID) return [{ part: tool, message }]
+ }
+
+ return emptyQuestionParts
+ })
+
const shellModePart = createMemo(() => {
const p = parts()
if (!p.every((part) => part?.type === "text" && part?.synthetic)) return
@@ -337,7 +360,20 @@ export function SessionTurn(
const handleCopyResponse = async () => {
const content = response()
if (!content) return
- await navigator.clipboard.writeText(content)
+ try {
+ await navigator.clipboard.writeText(content)
+ } catch {
+ // Fallback for iOS Safari
+ const textarea = document.createElement("textarea")
+ textarea.value = content
+ textarea.style.position = "fixed"
+ textarea.style.opacity = "0"
+ document.body.appendChild(textarea)
+ textarea.focus()
+ textarea.select()
+ document.execCommand("copy")
+ document.body.removeChild(textarea)
+ }
setResponseCopied(true)
setTimeout(() => setResponseCopied(false), 2000)
}
@@ -435,6 +471,14 @@ export function SessionTurn(
}),
)
+ createEffect(
+ on(questionCount, (count, prev) => {
+ if (!count) return
+ if (prev !== undefined && count <= prev) return
+ autoScroll.forceScrollToBottom()
+ }),
+ )
+
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
@@ -495,21 +539,6 @@ export function SessionTurn(