diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 9e2bddc6be..2ab25ceeb4 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { TextField } from "@opencode-ai/ui/text-field" import { Icon } from "@opencode-ai/ui/icon" -import { createMemo, createSignal, For, Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" import { createStore } from "solid-js/store" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" @@ -29,35 +29,34 @@ export function DialogEditProject(props: { project: LocalProject }) { iconUrl: props.project.icon?.override || "", startup: props.project.commands?.start ?? "", saving: false, + dragOver: false, + iconHover: false, }) - const [dragOver, setDragOver] = createSignal(false) - const [iconHover, setIconHover] = createSignal(false) - function handleFileSelect(file: File) { if (!file.type.startsWith("image/")) return const reader = new FileReader() reader.onload = (e) => { setStore("iconUrl", e.target?.result as string) - setIconHover(false) + setStore("iconHover", false) } reader.readAsDataURL(file) } function handleDrop(e: DragEvent) { e.preventDefault() - setDragOver(false) + setStore("dragOver", false) const file = e.dataTransfer?.files[0] if (file) handleFileSelect(file) } function handleDragOver(e: DragEvent) { e.preventDefault() - setDragOver(true) + setStore("dragOver", true) } function handleDragLeave() { - setDragOver(false) + setStore("dragOver", false) } function handleInputChange(e: Event) { @@ -116,19 +115,23 @@ export function DialogEditProject(props: { project: LocalProject }) {
-
setIconHover(true)} onMouseLeave={() => setIconHover(false)}> +
setStore("iconHover", true)} + onMouseLeave={() => setStore("iconHover", false)} + >
{ - if (store.iconUrl && iconHover()) { + if (store.iconUrl && store.iconHover) { clearIcon() } else { document.getElementById("icon-upload")?.click() @@ -166,7 +169,7 @@ export function DialogEditProject(props: { project: LocalProject }) { "border-radius": "6px", "z-index": 10, "pointer-events": "none", - opacity: iconHover() && !store.iconUrl ? 1 : 0, + opacity: store.iconHover && !store.iconUrl ? 1 : 0, display: "flex", "align-items": "center", "justify-content": "center", @@ -185,7 +188,7 @@ export function DialogEditProject(props: { project: LocalProject }) { "border-radius": "6px", "z-index": 10, "pointer-events": "none", - opacity: iconHover() && store.iconUrl ? 1 : 0, + opacity: store.iconHover && store.iconUrl ? 1 : 0, display: "flex", "align-items": "center", "justify-content": "center", diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index d16379e80a..75e9b22f99 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -1,5 +1,6 @@ import type { JSX } from "solid-js" -import { createSignal, Show } from "solid-js" +import { Show } from "solid-js" +import { createStore } from "solid-js/store" import { createSortable } from "@thisbeyond/solid-dnd" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" @@ -12,11 +13,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => const terminal = useTerminal() const language = useLanguage() const sortable = createSortable(props.terminal.id) - const [editing, setEditing] = createSignal(false) - const [title, setTitle] = createSignal(props.terminal.title) - const [menuOpen, setMenuOpen] = createSignal(false) - const [menuPosition, setMenuPosition] = createSignal({ x: 0, y: 0 }) - const [blurEnabled, setBlurEnabled] = createSignal(false) + const [store, setStore] = createStore({ + editing: false, + title: props.terminal.title, + menuOpen: false, + menuPosition: { x: 0, y: 0 }, + blurEnabled: false, + }) const isDefaultTitle = () => { const number = props.terminal.titleNumber @@ -47,7 +50,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => } const focus = () => { - if (editing()) return + if (store.editing) return if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() @@ -71,26 +74,26 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => e.preventDefault() } - setBlurEnabled(false) - setTitle(props.terminal.title) - setEditing(true) + setStore("blurEnabled", false) + setStore("title", props.terminal.title) + setStore("editing", true) setTimeout(() => { const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement if (!input) return input.focus() input.select() - setTimeout(() => setBlurEnabled(true), 100) + setTimeout(() => setStore("blurEnabled", true), 100) }, 10) } const save = () => { - if (!blurEnabled()) return + if (!store.blurEnabled) return - const value = title().trim() + const value = store.title.trim() if (value && value !== props.terminal.title) { terminal.update({ id: props.terminal.id, title: value }) } - setEditing(false) + setStore("editing", false) } const keydown = (e: KeyboardEvent) => { @@ -101,14 +104,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => } if (e.key === "Escape") { e.preventDefault() - setEditing(false) + setStore("editing", false) } } const menu = (e: MouseEvent) => { e.preventDefault() - setMenuPosition({ x: e.clientX, y: e.clientY }) - setMenuOpen(true) + setStore("menuPosition", { x: e.clientX, y: e.clientY }) + setStore("menuOpen", true) } return ( @@ -143,17 +146,17 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => /> } > - + {label()} - +
setTitle(e.currentTarget.value)} + value={store.title} + onInput={(e) => setStore("title", e.currentTarget.value)} onBlur={save} onKeyDown={keydown} onMouseDown={(e) => e.stopPropagation()} @@ -161,13 +164,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => />
- + setStore("menuOpen", open)}> diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 4dc3971495..7ff3425ab2 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -1,4 +1,5 @@ -import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -111,24 +112,26 @@ export const SettingsKeybinds: Component = () => { const language = useLanguage() const settings = useSettings() - const [active, setActive] = createSignal(null) - const [filter, setFilter] = createSignal("") + const [store, setStore] = createStore({ + active: null as string | null, + filter: "", + }) const stop = () => { - if (!active()) return - setActive(null) + if (!store.active) return + setStore("active", null) command.keybinds(true) } const start = (id: string) => { - if (active() === id) { + if (store.active === id) { stop() return } - if (active()) stop() + if (store.active) stop() - setActive(id) + setStore("active", id) command.keybinds(false) } @@ -203,7 +206,7 @@ export const SettingsKeybinds: Component = () => { }) const filtered = createMemo(() => { - const query = filter().toLowerCase().trim() + const query = store.filter.toLowerCase().trim() if (!query) return grouped() const map = list() @@ -285,7 +288,7 @@ export const SettingsKeybinds: Component = () => { onMount(() => { const handle = (event: KeyboardEvent) => { - const id = active() + const id = store.active if (!id) return event.preventDefault() @@ -345,7 +348,7 @@ export const SettingsKeybinds: Component = () => { }) onCleanup(() => { - if (active()) command.keybinds(true) + if (store.active) command.keybinds(true) }) return ( @@ -370,8 +373,8 @@ export const SettingsKeybinds: Component = () => { setStore("filter", v)} placeholder={language.t("settings.shortcuts.search.placeholder")} spellcheck={false} autocorrect="off" @@ -379,8 +382,8 @@ export const SettingsKeybinds: Component = () => { autocapitalize="off" class="flex-1" /> - - setFilter("")} /> + + setStore("filter", "")} />
@@ -402,13 +405,13 @@ export const SettingsKeybinds: Component = () => { classList={{ "h-8 px-3 rounded-md text-12-regular": true, "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active": - active() !== id, - "border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id, + store.active !== id, + "border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id, }} onClick={() => start(id)} > {language.t("settings.shortcuts.pressKeys")} @@ -423,11 +426,11 @@ export const SettingsKeybinds: Component = () => { )} - +
{language.t("settings.shortcuts.search.empty")} - - "{filter()}" + + "{store.filter}"
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index cb45c36895..c2c4d268a2 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -39,9 +39,10 @@ export function StatusPopover() { const language = useLanguage() const navigate = useNavigate() - const [loading, setLoading] = createSignal(null) const [store, setStore] = createStore({ status: {} as Record, + loading: null as string | null, + defaultServerUrl: undefined as string | undefined, }) const servers = createMemo(() => { @@ -97,8 +98,8 @@ export function StatusPopover() { const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length) const toggleMcp = async (name: string) => { - if (loading()) return - setLoading(name) + if (store.loading) return + setStore("loading", name) const status = sync.data.mcp[name] if (status?.status === "connected") { await sdk.client.mcp.disconnect({ name }) @@ -107,7 +108,7 @@ export function StatusPopover() { } const result = await sdk.client.mcp.status() if (result.data) sync.set("mcp", result.data) - setLoading(null) + setStore("loading", null) } const lspItems = createMemo(() => sync.data.lsp ?? []) @@ -123,19 +124,17 @@ export function StatusPopover() { const serverCount = createMemo(() => sortedServers().length) - const [defaultServerUrl, setDefaultServerUrl] = createSignal() - const refreshDefaultServerUrl = () => { const result = platform.getDefaultServerUrl?.() if (!result) { - setDefaultServerUrl(undefined) + setStore("defaultServerUrl", undefined) return } if (result instanceof Promise) { - result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined)) + result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined)) return } - setDefaultServerUrl(normalizeServerUrl(result)) + setStore("defaultServerUrl", normalizeServerUrl(result)) } createEffect(() => { @@ -220,7 +219,7 @@ export function StatusPopover() { {(url) => { const isActive = () => url === server.url - const isDefault = () => url === defaultServerUrl() + const isDefault = () => url === store.defaultServerUrl const status = () => store.status[url] const isBlocked = () => status()?.healthy === false const [truncated, setTruncated] = createSignal(false) @@ -329,7 +328,7 @@ export function StatusPopover() { type="button" class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" onClick={() => toggleMcp(item.name)} - disabled={loading() === item.name} + disabled={store.loading === item.name} >
event.stopPropagation()}> toggleMcp(item.name)} />
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index dc5228b82d..7915695840 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -165,8 +165,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const dialog = useDialog() const settings = useSettings() const language = useLanguage() - const [registrations, setRegistrations] = createSignal[]>([]) - const [suspendCount, setSuspendCount] = createSignal(0) + const [store, setStore] = createStore({ + registrations: [] as Accessor[], + suspendCount: 0, + }) const [catalog, setCatalog, _, catalogReady] = persisted( Persist.global("command.catalog.v1"), @@ -184,7 +186,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const seen = new Set() const all: CommandOption[] = [] - for (const reg of registrations()) { + for (const reg of store.registrations) { for (const opt of reg()) { if (seen.has(opt.id)) continue seen.add(opt.id) @@ -230,7 +232,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex ] }) - const suspended = () => suspendCount() > 0 + const suspended = () => store.suspendCount > 0 const palette = createMemo(() => { const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND @@ -297,9 +299,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return { register(cb: () => CommandOption[]) { const results = createMemo(cb) - setRegistrations((arr) => [results, ...arr]) + setStore("registrations", (arr) => [results, ...arr]) onCleanup(() => { - setRegistrations((arr) => arr.filter((x) => x !== results)) + setStore("registrations", (arr) => arr.filter((x) => x !== results)) }) }, trigger(id: string, source?: "palette" | "keybind" | "slash") { @@ -321,7 +323,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }, show: showPalette, keybinds(enabled: boolean) { - setSuspendCount((count) => count + (enabled ? -1 : 1)) + setStore("suspendCount", (count) => count + (enabled ? -1 : 1)) }, suspended, get catalog() { diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index 41123042d8..f555145874 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -1,4 +1,4 @@ -import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js" +import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams } from "@solidjs/router" @@ -37,8 +37,16 @@ function createCommentSession(dir: string, id: string | undefined) { }), ) - const [focus, setFocus] = createSignal(null) - const [active, setActive] = createSignal(null) + const [state, setState] = createStore({ + focus: null as CommentFocus | null, + active: null as CommentFocus | null, + }) + + const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => + setState("focus", value) + + const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => + setState("active", value) const list = (file: string) => store.comments[file] ?? [] @@ -74,10 +82,10 @@ function createCommentSession(dir: string, id: string | undefined) { all, add, remove, - focus: createMemo(() => focus()), + focus: createMemo(() => state.focus), setFocus, clearFocus: () => setFocus(null), - active: createMemo(() => active()), + active: createMemo(() => state.active), setActive, clearActive: () => setActive(null), } diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1076570928..4a3f3c6d1a 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,6 +1,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { Persist, persisted } from "@/utils/persist" @@ -40,12 +40,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }), ) - const [active, setActiveRaw] = createSignal("") + const [state, setState] = createStore({ + active: "", + healthy: undefined as boolean | undefined, + }) + + const healthy = () => state.healthy function setActive(input: string) { const url = normalizeServerUrl(input) if (!url) return - setActiveRaw(url) + setState("active", url) } function add(input: string) { @@ -54,7 +59,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const fallback = normalizeServerUrl(props.defaultUrl) if (fallback && url === fallback) { - setActiveRaw(url) + setState("active", url) return } @@ -62,7 +67,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (!store.list.includes(url)) { setStore("list", store.list.length, url) } - setActiveRaw(url) + setState("active", url) }) } @@ -71,25 +76,23 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (!url) return const list = store.list.filter((x) => x !== url) - const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active() + const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active batch(() => { setStore("list", list) - setActiveRaw(next) + setState("active", next) }) } createEffect(() => { if (!ready()) return - if (active()) return + if (state.active) return const url = normalizeServerUrl(props.defaultUrl) if (!url) return - setActiveRaw(url) + setState("active", url) }) - const isReady = createMemo(() => ready() && !!active()) - - const [healthy, setHealthy] = createSignal(undefined) + const isReady = createMemo(() => ready() && !!state.active) const check = (url: string) => { const sdk = createOpencodeClient({ @@ -104,10 +107,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( } createEffect(() => { - const url = active() + const url = state.active if (!url) return - setHealthy(undefined) + setState("healthy", undefined) let alive = true let busy = false @@ -118,7 +121,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( void check(url) .then((next) => { if (!alive) return - setHealthy(next) + setState("healthy", next) }) .finally(() => { busy = false @@ -134,7 +137,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) }) - const origin = createMemo(() => projectsKey(active())) + const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) const isLocal = createMemo(() => origin() === "local") @@ -143,10 +146,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( healthy, isLocal, get url() { - return active() + return state.active }, get name() { - return serverDisplayName(active()) + return serverDisplayName(state.active) }, get list() { return store.list diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7ad97a989f..fd6f6e527d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -91,7 +91,6 @@ export default function Layout(props: ParentProps) { let scrollContainerRef: HTMLDivElement | undefined const params = useParams() - const [autoselect, setAutoselect] = createSignal(!params.dir) const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() const layout = useLayout() @@ -117,27 +116,31 @@ export default function Layout(props: ParentProps) { } const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) + const [state, setState] = createStore({ + autoselect: !params.dir, + busyWorkspaces: new Set(), + hoverSession: undefined as string | undefined, + hoverProject: undefined as string | undefined, + scrollSessionKey: undefined as string | undefined, + nav: undefined as HTMLElement | undefined, + }) + const [editor, setEditor] = createStore({ active: "" as string, value: "", }) - const [busyWorkspaces, setBusyWorkspaces] = createSignal>(new Set()) const setBusy = (directory: string, value: boolean) => { const key = workspaceKey(directory) - setBusyWorkspaces((prev) => { + setState("busyWorkspaces", (prev) => { const next = new Set(prev) if (value) next.add(key) else next.delete(key) return next }) } - const isBusy = (directory: string) => busyWorkspaces().has(workspaceKey(directory)) + const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory)) const editorRef = { current: undefined as HTMLInputElement | undefined } - const [hoverSession, setHoverSession] = createSignal() - const [hoverProject, setHoverProject] = createSignal() - - const [nav, setNav] = createSignal(undefined) const navLeave = { current: undefined as number | undefined } onCleanup(() => { @@ -145,18 +148,18 @@ export default function Layout(props: ParentProps) { clearTimeout(navLeave.current) }) - const sidebarHovering = createMemo(() => !layout.sidebar.opened() && hoverProject() !== undefined) + const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) const hoverProjectData = createMemo(() => { - const id = hoverProject() + const id = state.hoverProject if (!id) return return layout.projects.list().find((project) => project.worktree === id) }) createEffect(() => { if (!layout.sidebar.opened()) return - setHoverProject(undefined) + setState("hoverProject", undefined) }) createEffect( @@ -164,9 +167,9 @@ export default function Layout(props: ParentProps) { () => ({ dir: params.dir, id: params.id }), () => { if (layout.sidebar.opened()) return - if (!hoverProject()) return - setHoverSession(undefined) - setHoverProject(undefined) + if (!state.hoverProject) return + setState("hoverSession", undefined) + setState("hoverProject", undefined) }, { defer: true }, ), @@ -175,7 +178,7 @@ export default function Layout(props: ParentProps) { const autoselecting = createMemo(() => { if (params.dir) return false if (initialDir) return false - if (!autoselect()) return false + if (!state.autoselect) return false if (!pageReady()) return true if (!layoutReady()) return true const list = layout.projects.list() @@ -483,20 +486,18 @@ export default function Layout(props: ParentProps) { } } - const [scrollSessionKey, setScrollSessionKey] = createSignal(undefined) - function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return - if (scrollSessionKey() === sessionKey) return + if (state.scrollSessionKey === sessionKey) return const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) if (!element) return const containerRect = scrollContainerRef.getBoundingClientRect() const elementRect = element.getBoundingClientRect() if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) { - setScrollSessionKey(sessionKey) + setState("scrollSessionKey", sessionKey) return } - setScrollSessionKey(sessionKey) + setState("scrollSessionKey", sessionKey) element.scrollIntoView({ block: "nearest", behavior: "smooth" }) } @@ -544,7 +545,7 @@ export default function Layout(props: ParentProps) { (value) => { if (!value.ready) return if (!value.layoutReady) return - if (!autoselect()) return + if (!state.autoselect) return if (initialDir) return if (value.dir) return if (value.list.length === 0) return @@ -552,7 +553,7 @@ export default function Layout(props: ParentProps) { const last = server.projects.last() const next = value.list.find((project) => project.worktree === last) ?? value.list[0] if (!next) return - setAutoselect(false) + setState("autoselect", false) openProject(next.worktree, false) navigateToProject(next.worktree) }, @@ -1066,8 +1067,8 @@ export default function Layout(props: ParentProps) { function navigateToProject(directory: string | undefined) { if (!directory) return if (!layout.sidebar.opened()) { - setHoverSession(undefined) - setHoverProject(undefined) + setState("hoverSession", undefined) + setState("hoverProject", undefined) } server.projects.touch(directory) const lastSession = store.lastSession[directory] @@ -1078,8 +1079,8 @@ export default function Layout(props: ParentProps) { function navigateToSession(session: Session | undefined) { if (!session) return if (!layout.sidebar.opened()) { - setHoverSession(undefined) - setHoverProject(undefined) + setState("hoverSession", undefined) + setState("hoverProject", undefined) } navigate(`/${base64Encode(session.directory)}/session/${session.id}`) layout.mobileSidebar.hide() @@ -1472,7 +1473,7 @@ export default function Layout(props: ParentProps) { function handleDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return - setHoverProject(undefined) + setState("hoverProject", undefined) setStore("activeProject", id) } @@ -1632,8 +1633,10 @@ export default function Layout(props: ParentProps) { const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) - const [menuOpen, setMenuOpen] = createSignal(false) - const [pendingRename, setPendingRename] = createSignal(false) + const [menu, setMenu] = createStore({ + open: false, + pendingRename: false, + }) const messageLabel = (message: Message) => { const parts = sessionStore.part[message.id] ?? [] @@ -1644,13 +1647,13 @@ export default function Layout(props: ParentProps) { const item = ( prefetchSession(props.session, "high")} onFocus={() => prefetchSession(props.session, "high")} onClick={() => { - setHoverSession(undefined) + setState("hoverSession", undefined) if (layout.sidebar.opened()) return - queueMicrotask(() => setHoverProject(undefined)) + queueMicrotask(() => setState("hoverProject", undefined)) }} >
@@ -1713,9 +1716,9 @@ export default function Layout(props: ParentProps) { gutter={16} shift={-2} trigger={item} - mount={!props.mobile ? nav() : undefined} - open={hoverSession() === props.session.id} - onOpenChange={(open) => setHoverSession(open ? props.session.id : undefined)} + mount={!props.mobile ? state.nav : undefined} + open={state.hoverSession === props.session.id} + onOpenChange={(open) => setState("hoverSession", open ? props.session.id : undefined)} > - + setMenu("open", open)}> - + { - if (!pendingRename()) return + if (!menu.pendingRename) return event.preventDefault() - setPendingRename(false) + setMenu("pendingRename", false) openEditor(`session:${props.session.id}`, props.session.title) }} > { - setPendingRename(true) - setMenuOpen(false) + setMenu("pendingRename", true) + setMenu("open", false) }} > {language.t("common.rename")} @@ -1802,9 +1805,9 @@ export default function Layout(props: ParentProps) { end class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} onClick={() => { - setHoverSession(undefined) + setState("hoverSession", undefined) if (layout.sidebar.opened()) return - queueMicrotask(() => setHoverProject(undefined)) + queueMicrotask(() => setState("hoverProject", undefined)) }} >
@@ -1884,8 +1887,10 @@ export default function Layout(props: ParentProps) { const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.directory) const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) - const [menuOpen, setMenuOpen] = createSignal(false) - const [pendingRename, setPendingRename] = createSignal(false) + const [menu, setMenu] = createStore({ + open: false, + pendingRename: false, + }) const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => workspaceStore.session @@ -1995,13 +2000,17 @@ export default function Layout(props: ParentProps) {
- + setMenu("open", open)} + > - + { - if (!pendingRename()) return + if (!menu.pendingRename) return event.preventDefault() - setPendingRename(false) + setMenu("pendingRename", false) openEditor(`workspace:${props.directory}`, workspaceValue()) }} > { - setPendingRename(true) - setMenuOpen(false) + setMenu("pendingRename", true) + setMenu("open", false) }} > {language.t("common.rename")} @@ -2103,7 +2112,7 @@ export default function Layout(props: ParentProps) { const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) - const active = createMemo(() => (preview() ? open() : overlay() && hoverProject() === props.project.worktree)) + const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree)) createEffect(() => { if (preview()) return @@ -2155,14 +2164,14 @@ export default function Layout(props: ParentProps) { onMouseEnter={() => { if (!overlay()) return globalSync.child(props.project.worktree) - setHoverProject(props.project.worktree) - setHoverSession(undefined) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) }} onFocus={() => { if (!overlay()) return globalSync.child(props.project.worktree) - setHoverProject(props.project.worktree) - setHoverSession(undefined) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) }} onClick={() => navigateToProject(props.project.worktree)} onBlur={() => setOpen(false)} @@ -2184,7 +2193,7 @@ export default function Layout(props: ParentProps) { trigger={trigger} onOpenChange={(value) => { setOpen(value) - if (value) setHoverSession(undefined) + if (value) setState("hoverSession", undefined) }} >
@@ -2323,8 +2332,8 @@ export default function Layout(props: ParentProps) { const createWorkspace = async (project: LocalProject) => { if (!layout.sidebar.opened()) { - setHoverSession(undefined) - setHoverProject(undefined) + setState("hoverSession", undefined) + setState("hoverProject", undefined) } const created = await globalSDK.client.worktree .create({ directory: project.worktree }) @@ -2427,7 +2436,7 @@ export default function Layout(props: ParentProps) { class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" aria-label={language.t("common.moreOptions")} /> - + dialog.show(() => )}> {language.t("common.edit")} @@ -2476,8 +2485,8 @@ export default function Layout(props: ParentProps) { class="w-full" onClick={() => { if (!layout.sidebar.opened()) { - setHoverSession(undefined) - setHoverProject(undefined) + setState("hoverSession", undefined) + setState("hoverProject", undefined) } navigate(`/${base64Encode(p.worktree)}/session`) layout.mobileSidebar.hide() @@ -2668,7 +2677,7 @@ export default function Layout(props: ParentProps) { }} style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }} ref={(el) => { - setNav(el) + setState("nav", el) }} onMouseEnter={() => { if (navLeave.current === undefined) return @@ -2681,8 +2690,8 @@ export default function Layout(props: ParentProps) { if (navLeave.current !== undefined) clearTimeout(navLeave.current) navLeave.current = window.setTimeout(() => { navLeave.current = undefined - setHoverProject(undefined) - setHoverSession(undefined) + setState("hoverProject", undefined) + setState("hoverSession", undefined) }, 300) }} > diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 458decfc41..e9b29c03e3 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" @@ -198,12 +198,17 @@ export default function Page() { return next }) - const [responding, setResponding] = createSignal(false) + const [ui, setUi] = createStore({ + responding: false, + pendingMessage: undefined as string | undefined, + scrollGesture: 0, + autoCreated: false, + }) createEffect( on( () => request()?.id, - () => setResponding(false), + () => setUi("responding", false), { defer: true }, ), ) @@ -211,18 +216,17 @@ export default function Page() { const decide = (response: "once" | "always" | "reject") => { const perm = request() if (!perm) return - if (responding()) return + if (ui.responding) return - setResponding(true) + setUi("responding", true) sdk.client.permission .respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) - .finally(() => setResponding(false)) + .finally(() => setUi("responding", false)) } - const [pendingMessage, setPendingMessage] = createSignal(undefined) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) @@ -439,7 +443,6 @@ export default function Page() { let promptDock: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined - const [scrollGesture, setScrollGesture] = createSignal(0) const scrollGestureWindowMs = 250 const markScrollGesture = (target?: EventTarget | null) => { @@ -450,26 +453,24 @@ export default function Page() { const nested = el?.closest("[data-scrollable]") if (nested && nested !== root) return - setScrollGesture(Date.now()) + setUi("scrollGesture", Date.now()) } - const hasScrollGesture = () => Date.now() - scrollGesture() < scrollGestureWindowMs + const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs createEffect(() => { if (!params.id) return sync.session.sync(params.id) }) - const [autoCreated, setAutoCreated] = createSignal(false) - createEffect(() => { if (!view().terminal.opened()) { - setAutoCreated(false) + setUi("autoCreated", false) return } - if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return + if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return terminal.new() - setAutoCreated(true) + setUi("autoCreated", true) }) createEffect( @@ -1019,9 +1020,18 @@ export default function Page() { const showTabs = createMemo(() => view().reviewPanel.opened()) - const [fileTreeTab, setFileTreeTab] = createSignal<"changes" | "all">("changes") - const [reviewScroll, setReviewScroll] = createSignal(undefined) - const [pendingDiff, setPendingDiff] = createSignal(undefined) + const [tree, setTree] = createStore({ + fileTreeTab: "changes" as "changes" | "all", + reviewScroll: undefined as HTMLDivElement | undefined, + pendingDiff: undefined as string | undefined, + }) + + const fileTreeTab = () => tree.fileTreeTab + const setFileTreeTab = (value: "changes" | "all") => setTree("fileTreeTab", value) + const reviewScroll = () => tree.reviewScroll + const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value) + const pendingDiff = () => tree.pendingDiff + const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value) createEffect(() => { if (!layout.fileTree.opened()) return @@ -1316,7 +1326,7 @@ export default function Page() { if (pendingSessionID !== sessionID) return sessionStorage.removeItem("opencode.pendingMessage") - setPendingMessage(messageID) + setUi("pendingMessage", messageID) }) const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { @@ -1484,7 +1494,7 @@ export default function Page() { store.turnStart const targetId = - pendingMessage() ?? + ui.pendingMessage ?? (() => { const hash = window.location.hash.slice(1) const match = hash.match(/^message-(.+)$/) @@ -1496,7 +1506,7 @@ export default function Page() { const msg = visibleUserMessages().find((m) => m.id === targetId) if (!msg) return - if (pendingMessage() === targetId) setPendingMessage(undefined) + if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined) requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) @@ -1877,18 +1887,18 @@ export default function Page() {
- -
@@ -2144,11 +2154,40 @@ export default function Page() { const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) - const [openedComment, setOpenedComment] = createSignal(null) - const [commenting, setCommenting] = createSignal(null) - const [draft, setDraft] = createSignal("") - const [positions, setPositions] = createSignal>({}) - const [draftTop, setDraftTop] = createSignal(undefined) + const [note, setNote] = createStore({ + openedComment: null as string | null, + commenting: null as SelectedLineRange | null, + draft: "", + positions: {} as Record, + draftTop: undefined as number | undefined, + }) + + const openedComment = () => note.openedComment + const setOpenedComment = ( + value: + | typeof note.openedComment + | ((value: typeof note.openedComment) => typeof note.openedComment), + ) => setNote("openedComment", value) + + const commenting = () => note.commenting + const setCommenting = ( + value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting), + ) => setNote("commenting", value) + + const draft = () => note.draft + const setDraft = ( + value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft), + ) => setNote("draft", value) + + const positions = () => note.positions + const setPositions = ( + value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions), + ) => setNote("positions", value) + + const draftTop = () => note.draftTop + const setDraftTop = ( + value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop), + ) => setNote("draftTop", value) const commentLabel = (range: SelectedLineRange) => { const start = Math.min(range.start, range.end) @@ -2695,7 +2734,7 @@ export default function Page() { terminal={pty} onClose={() => { view().terminal.close() - setAutoCreated(false) + setUi("autoCreated", false) }} /> )} diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts index 921e0a159b..c8acf5241c 100644 --- a/packages/app/src/utils/speech.ts +++ b/packages/app/src/utils/speech.ts @@ -1,4 +1,5 @@ -import { createSignal, onCleanup } from "solid-js" +import { onCleanup } from "solid-js" +import { createStore } from "solid-js/store" // Minimal types to avoid relying on non-standard DOM typings type RecognitionResult = { @@ -59,9 +60,15 @@ export function createSpeechRecognition(opts?: { typeof window !== "undefined" && Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition) - const [isRecording, setIsRecording] = createSignal(false) - const [committed, setCommitted] = createSignal("") - const [interim, setInterim] = createSignal("") + const [store, setStore] = createStore({ + isRecording: false, + committed: "", + interim: "", + }) + + const isRecording = () => store.isRecording + const committed = () => store.committed + const interim = () => store.interim let recognition: Recognition | undefined let shouldContinue = false @@ -82,7 +89,7 @@ export function createSpeechRecognition(opts?: { const nextCommitted = appendSegment(committedText, segment) if (nextCommitted === committedText) return committedText = nextCommitted - setCommitted(committedText) + setStore("committed", committedText) if (opts?.onFinal) opts.onFinal(segment.trim()) } @@ -98,7 +105,7 @@ export function createSpeechRecognition(opts?: { pendingHypothesis = "" lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") } @@ -107,7 +114,7 @@ export function createSpeechRecognition(opts?: { pendingHypothesis = hypothesis lastInterimSuffix = suffix shrinkCandidate = undefined - setInterim(suffix) + setStore("interim", suffix) if (opts?.onInterim) { opts.onInterim(suffix ? appendSegment(committedText, suffix) : "") } @@ -122,7 +129,7 @@ export function createSpeechRecognition(opts?: { pendingHypothesis = "" lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") }, COMMIT_DELAY) } @@ -162,7 +169,7 @@ export function createSpeechRecognition(opts?: { pendingHypothesis = "" lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") return } @@ -211,7 +218,7 @@ export function createSpeechRecognition(opts?: { lastInterimSuffix = "" shrinkCandidate = undefined if (e.error === "no-speech" && shouldContinue) { - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") setTimeout(() => { try { @@ -221,7 +228,7 @@ export function createSpeechRecognition(opts?: { return } shouldContinue = false - setIsRecording(false) + setStore("isRecording", false) } recognition.onstart = () => { @@ -230,16 +237,16 @@ export function createSpeechRecognition(opts?: { cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") - setIsRecording(true) + setStore("isRecording", true) } recognition.onend = () => { cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined - setIsRecording(false) + setStore("isRecording", false) if (shouldContinue) { setTimeout(() => { try { @@ -258,7 +265,7 @@ export function createSpeechRecognition(opts?: { cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") try { recognition.start() } catch {} @@ -271,7 +278,7 @@ export function createSpeechRecognition(opts?: { cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") try { recognition.stop() @@ -284,7 +291,7 @@ export function createSpeechRecognition(opts?: { cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") try { recognition?.stop()