(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/server/push.ts b/packages/opencode/src/server/push.ts
new file mode 100644
index 00000000000..0ed2a16c8e4
--- /dev/null
+++ b/packages/opencode/src/server/push.ts
@@ -0,0 +1,83 @@
+import { Hono } from "hono"
+import { describeRoute, resolver, validator } from "hono-openapi"
+import z from "zod"
+import { Log } from "../util/log"
+
+const log = Log.create({ service: "push" })
+
+// Web Push subscription schema
+const PushSubscriptionSchema = z.object({
+ endpoint: z.string().url(),
+ keys: z.object({
+ p256dh: z.string(),
+ auth: z.string(),
+ }),
+})
+
+type PushSubscription = z.infer
+
+// In-memory subscription store (per server instance)
+const subscriptions = new Map()
+
+export function getSubscriptionCount() {
+ return subscriptions.size
+}
+
+export function getSubscriptions() {
+ return Array.from(subscriptions.values())
+}
+
+export const PushRoute = new Hono()
+ .post(
+ "/subscribe",
+ describeRoute({
+ summary: "Subscribe to push notifications",
+ description: "Register a push subscription for Web Push notifications",
+ operationId: "push.subscribe",
+ responses: {
+ 200: {
+ description: "Subscription registered",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ success: z.boolean() })),
+ },
+ },
+ },
+ },
+ }),
+ validator("json", PushSubscriptionSchema),
+ async (c) => {
+ const subscription = c.req.valid("json")
+ // Use endpoint hash as unique ID
+ const id = btoa(subscription.endpoint).slice(0, 32)
+ subscriptions.set(id, subscription)
+ log.info("push subscription added", { id, total: subscriptions.size })
+ return c.json({ success: true })
+ },
+ )
+ .post(
+ "/unsubscribe",
+ describeRoute({
+ summary: "Unsubscribe from push notifications",
+ description: "Remove a push subscription",
+ operationId: "push.unsubscribe",
+ responses: {
+ 200: {
+ description: "Subscription removed",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ success: z.boolean() })),
+ },
+ },
+ },
+ },
+ }),
+ validator("json", z.object({ endpoint: z.string().url() })),
+ async (c) => {
+ const { endpoint } = c.req.valid("json")
+ const id = btoa(endpoint).slice(0, 32)
+ const deleted = subscriptions.delete(id)
+ log.info("push subscription removed", { id, deleted, total: subscriptions.size })
+ return c.json({ success: true })
+ },
+ )
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 52457515b8e..ce1fbd41875 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -52,6 +52,7 @@ import { errors } from "./error"
import { Pty } from "@/pty"
import { PermissionNext } from "@/permission/next"
import { QuestionRoute } from "./question"
+import { PushRoute } from "./push"
import { Installation } from "@/installation"
import { MDNS } from "./mdns"
import { Worktree } from "../worktree"
@@ -1705,6 +1706,7 @@ export namespace Server {
},
)
.route("/question", QuestionRoute)
+ .route("/push", PushRoute)
.get(
"/command",
describeRoute({
diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts
index c6f0b91599b..ff06fb3a6c4 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/assets/favicon/site.webmanifest b/packages/ui/src/assets/favicon/site.webmanifest
index 41290e840c3..b04bf025535 100644
--- a/packages/ui/src/assets/favicon/site.webmanifest
+++ b/packages/ui/src/assets/favicon/site.webmanifest
@@ -1,6 +1,11 @@
{
"name": "OpenCode",
"short_name": "OpenCode",
+ "description": "AI-powered coding assistant",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
@@ -13,9 +18,14 @@
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
+ },
+ {
+ "src": "/favicon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png",
+ "purpose": "any"
}
],
"theme_color": "#ffffff",
- "background_color": "#ffffff",
- "display": "standalone"
+ "background_color": "#ffffff"
}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index b087b59e17d..9d502b507de 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -40,6 +40,10 @@
border-color: var(--border-strong-base);
}
+ &[data-clickable="true"] {
+ cursor: pointer;
+ }
+
&[data-type="image"] {
width: 48px;
height: 48px;
@@ -490,3 +494,159 @@
justify-content: flex-end;
}
}
+
+[data-component="question-tool"] {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 12px;
+
+ [data-slot="question-tabs"] {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+ }
+
+ [data-slot="question-tab"] {
+ padding: 4px 12px;
+ border-radius: 4px;
+ border: none;
+ background-color: var(--surface-raised-base);
+ color: var(--text-weak);
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ transition:
+ background-color 0.15s,
+ color 0.15s;
+
+ &:hover {
+ background-color: var(--surface-raised-base-hover);
+ }
+
+ &[data-active="true"] {
+ background-color: var(--surface-accent-weak);
+ color: var(--text-accent-strong);
+ }
+
+ &[data-answered="true"]:not([data-active="true"]) {
+ color: var(--text-success-base);
+ }
+ }
+
+ [data-slot="question-content"] {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ [data-slot="question-text"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-large);
+ color: var(--text-base);
+ }
+
+ [data-slot="question-options"] {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ [data-slot="question-option"] {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 10px 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border-weak-base);
+ background-color: var(--surface-raised-base);
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ background-color 0.15s;
+ text-align: left;
+ width: 100%;
+
+ &:hover:not([data-disabled="true"]) {
+ border-color: var(--border-base);
+ background-color: var(--surface-raised-base-hover);
+ }
+
+ &[data-selected="true"] {
+ border-color: var(--border-accent-base);
+ background-color: var(--surface-accent-weak);
+ }
+
+ &[data-disabled="true"] {
+ cursor: default;
+ opacity: 0.7;
+ }
+
+ [data-slot="icon-svg"] {
+ flex-shrink: 0;
+ color: var(--icon-accent-strong);
+ margin-left: auto;
+ }
+ }
+
+ [data-slot="question-option-number"] {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+ color: var(--text-weak);
+ flex-shrink: 0;
+ min-width: 20px;
+ }
+
+ [data-slot="question-option-content"] {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1;
+ min-width: 0;
+ }
+
+ [data-slot="question-option-label"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-medium);
+ color: var(--text-strong);
+ }
+
+ [data-slot="question-option-description"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-regular);
+ color: var(--text-weak);
+ line-height: var(--line-height-large);
+ }
+
+ [data-slot="question-actions"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: flex-end;
+ padding-top: 8px;
+ border-top: 1px solid var(--border-weak-base);
+ }
+
+ [data-slot="question-answered"] {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ background-color: var(--surface-success-weak);
+ border-radius: 6px;
+ color: var(--text-success-strong);
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+
+ [data-slot="icon-svg"] {
+ color: var(--icon-success-strong);
+ }
+ }
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 71ff37161fa..f8e192880df 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -313,7 +313,20 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const handleCopy = async () => {
const content = text()
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)
+ }
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
@@ -327,6 +340,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
{
if (file.mime.startsWith("image/") && file.url) {
openImagePreview(file.url, file.filename)
@@ -438,6 +452,8 @@ export interface ToolProps {
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
+ sessionID?: string
+ callID?: string
}
export type ToolComponent = Component
@@ -550,6 +566,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
hideDetails={props.hideDetails}
forceOpen={forceOpen()}
defaultOpen={props.defaultOpen}
+ sessionID={props.message.sessionID}
+ callID={part.callID}
/>
@@ -1042,3 +1060,165 @@ ToolRegistry.register({
)
},
})
+
+ToolRegistry.register({
+ name: "question",
+ render(props) {
+ const data = useData()
+ const questions = createMemo(() => props.input.questions ?? [])
+
+ const questionRequest = createMemo(() => {
+ if (!props.sessionID) return undefined
+ const requests = data.store.question?.[props.sessionID] ?? []
+ return requests.find((r) => r.tool?.callID === props.callID)
+ })
+
+ const [answers, setAnswers] = createSignal([])
+ const [currentTab, setCurrentTab] = createSignal(0)
+
+ const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
+ const currentQuestion = createMemo(() => questions()[currentTab()])
+
+ const handleSelect = (questionIndex: number, optionLabel: string) => {
+ const q = questions()[questionIndex]
+ if (!q) return
+
+ setAnswers((prev) => {
+ const next = [...prev]
+ if (q.multiple) {
+ const existing = next[questionIndex] ?? []
+ const idx = existing.indexOf(optionLabel)
+ if (idx === -1) {
+ next[questionIndex] = [...existing, optionLabel]
+ } else {
+ next[questionIndex] = existing.filter((l) => l !== optionLabel)
+ }
+ } else {
+ next[questionIndex] = [optionLabel]
+ }
+ return next
+ })
+
+ if (!q.multiple && single()) {
+ const request = questionRequest()
+ if (request && data.respondToQuestion) {
+ data.respondToQuestion({
+ requestID: request.id,
+ answers: [[optionLabel]],
+ })
+ }
+ } else if (!q.multiple) {
+ setCurrentTab((t) => Math.min(t + 1, questions().length - 1))
+ }
+ }
+
+ const handleSubmit = () => {
+ const request = questionRequest()
+ if (!request || !data.respondToQuestion) return
+ data.respondToQuestion({
+ requestID: request.id,
+ answers: answers(),
+ })
+ }
+
+ const handleReject = () => {
+ const request = questionRequest()
+ if (!request || !data.rejectQuestion) return
+ data.rejectQuestion({ requestID: request.id })
+ }
+
+ const isAnswered = createMemo(() => !questionRequest())
+
+ return (
+ 1 ? `${questions().length} questions` : undefined,
+ }}
+ >
+ 0}>
+
+
1 && !isAnswered()}>
+
+
+ {(q, i) => (
+
+ )}
+
+
+
+
+
+ {(q) => (
+
+
+ {q().question}
+ {q().multiple ? " (select all that apply)" : ""}
+
+
+
+ {(opt, i) => {
+ const selected = () => answers()[currentTab()]?.includes(opt.label) ?? false
+ return (
+
+ )
+ }}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ Answered
+
+
+
+
+
+ )
+ },
+})
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(
-
-
- {(msg() as UserMessage).agent}
-
-
-
-
- {(msg() as UserMessage).model?.modelID}
-
-
-
{(msg() as UserMessage).variant || "default"}
-