summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-12 09:49:14 -0600
committerGitHub <[email protected]>2026-02-12 09:49:14 -0600
commitff4414bb152acfddb5c0eb073c38bedc1df4ae14 (patch)
tree78381c67d21ef6f089647f6b19e7aa2976840dbc /packages/app/src/pages
parent56ad2db02055955f926fda0e4a89055b22ead6f9 (diff)
downloadopencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.tar.gz
opencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.zip
chore: refactor packages/app files (#13236)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <[email protected]>
Diffstat (limited to 'packages/app/src/pages')
-rw-r--r--packages/app/src/pages/directory-layout.tsx75
-rw-r--r--packages/app/src/pages/error.tsx51
-rw-r--r--packages/app/src/pages/home.tsx14
-rw-r--r--packages/app/src/pages/layout.tsx388
-rw-r--r--packages/app/src/pages/layout/inline-editor.tsx17
-rw-r--r--packages/app/src/pages/layout/sidebar-items.tsx238
-rw-r--r--packages/app/src/pages/layout/sidebar-project.tsx381
-rw-r--r--packages/app/src/pages/layout/sidebar-shell.tsx11
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx445
-rw-r--r--packages/app/src/pages/session.tsx69
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx94
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx78
-rw-r--r--packages/app/src/pages/session/review-tab.tsx14
-rw-r--r--packages/app/src/pages/session/session-mobile-tabs.tsx14
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx9
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx24
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx5
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx152
18 files changed, 1149 insertions, 930 deletions
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index f36bb7ab4..2dee09dfb 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -1,21 +1,47 @@
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
+import { createStore } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { DataProvider } from "@opencode-ai/ui/context"
-import { iife } from "@opencode-ai/util/iife"
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
+function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
+ const params = useParams()
+ const navigate = useNavigate()
+ const sync = useSync()
+ const sdk = useSDK()
+
+ return (
+ <DataProvider
+ data={sync.data}
+ directory={props.directory}
+ onPermissionRespond={(input: {
+ sessionID: string
+ permissionID: string
+ response: "once" | "always" | "reject"
+ }) => sdk.client.permission.respond(input)}
+ onQuestionReply={(input: { requestID: string; answers: QuestionAnswer[] }) => sdk.client.question.reply(input)}
+ onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
+ onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
+ onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
+ onSyncSession={(sessionID: string) => sync.session.sync(sessionID)}
+ >
+ <LocalProvider>{props.children}</LocalProvider>
+ </DataProvider>
+ )
+}
+
export default function Layout(props: ParentProps) {
const params = useParams()
const navigate = useNavigate()
const language = useLanguage()
- let invalid = ""
+ const [store, setStore] = createStore({ invalid: "" })
const directory = createMemo(() => {
return decode64(params.dir) ?? ""
})
@@ -23,8 +49,8 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
if (!params.dir) return
if (directory()) return
- if (invalid === params.dir) return
- invalid = params.dir
+ if (store.invalid === params.dir) return
+ setStore("invalid", params.dir)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
@@ -36,46 +62,7 @@ export default function Layout(props: ParentProps) {
<Show when={directory()}>
<SDKProvider directory={directory}>
<SyncProvider>
- {iife(() => {
- const sync = useSync()
- const sdk = useSDK()
- const respond = (input: {
- sessionID: string
- permissionID: string
- response: "once" | "always" | "reject"
- }) => sdk.client.permission.respond(input)
-
- const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
- sdk.client.question.reply(input)
-
- const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input)
-
- const navigateToSession = (sessionID: string) => {
- navigate(`/${params.dir}/session/${sessionID}`)
- }
-
- const sessionHref = (sessionID: string) => {
- if (params.dir) return `/${params.dir}/session/${sessionID}`
- return `/session/${sessionID}`
- }
-
- const syncSession = (sessionID: string) => sync.session.sync(sessionID)
-
- return (
- <DataProvider
- data={sync.data}
- directory={directory()}
- onPermissionRespond={respond}
- onQuestionReply={replyToQuestion}
- onQuestionReject={rejectQuestion}
- onNavigateToSession={navigateToSession}
- onSessionHref={sessionHref}
- onSyncSession={syncSession}
- >
- <LocalProvider>{props.children}</LocalProvider>
- </DataProvider>
- )
- })}
+ <DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
</SyncProvider>
</SDKProvider>
</Show>
diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx
index 6d6faf6fa..a30d86d18 100644
--- a/packages/app/src/pages/error.tsx
+++ b/packages/app/src/pages/error.tsx
@@ -13,6 +13,17 @@ export type InitError = {
}
type Translator = ReturnType<typeof useLanguage>["t"]
+const CHAIN_SEPARATOR = "\n" + "─".repeat(40) + "\n"
+
+function isIssue(value: unknown): value is { message: string; path: string[] } {
+ if (!value || typeof value !== "object") return false
+ if (!("message" in value) || !("path" in value)) return false
+ const message = (value as { message: unknown }).message
+ const path = (value as { path: unknown }).path
+ if (typeof message !== "string") return false
+ if (!Array.isArray(path)) return false
+ return path.every((part) => typeof part === "string")
+}
function isInitError(error: unknown): error is InitError {
return (
@@ -112,9 +123,7 @@ function formatInitError(error: InitError, t: Translator): string {
}
case "ConfigInvalidError": {
const issues = Array.isArray(data.issues)
- ? data.issues.map(
- (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
- )
+ ? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join("."))
: []
const message = typeof data.message === "string" ? data.message : ""
const path = typeof data.path === "string" ? data.path : safeJson(data.path)
@@ -139,14 +148,14 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag
if (isInitError(error)) {
const message = formatInitError(error, t)
if (depth > 0 && parentMessage === message) return ""
- const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
+ const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
return indent + `${error.name}\n${message}`
}
if (error instanceof Error) {
const isDuplicate = depth > 0 && parentMessage === error.message
const parts: string[] = []
- const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
+ const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
const stack = error.stack?.trim()
@@ -190,11 +199,11 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag
if (typeof error === "string") {
if (depth > 0 && parentMessage === error) return ""
- const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
+ const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
return indent + error
}
- const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
+ const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
return indent + safeJson(error)
}
@@ -212,20 +221,35 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
const [store, setStore] = createStore({
checking: false,
version: undefined as string | undefined,
+ actionError: undefined as string | undefined,
})
async function checkForUpdates() {
if (!platform.checkUpdate) return
setStore("checking", true)
- const result = await platform.checkUpdate()
- setStore("checking", false)
- if (result.updateAvailable && result.version) setStore("version", result.version)
+ await platform
+ .checkUpdate()
+ .then((result) => {
+ setStore("actionError", undefined)
+ if (result.updateAvailable && result.version) setStore("version", result.version)
+ })
+ .catch((err) => {
+ setStore("actionError", formatError(err, language.t))
+ })
+ .finally(() => {
+ setStore("checking", false)
+ })
}
async function installUpdate() {
if (!platform.update || !platform.restart) return
- await platform.update()
- await platform.restart()
+ await platform
+ .update()
+ .then(() => platform.restart!())
+ .then(() => setStore("actionError", undefined))
+ .catch((err) => {
+ setStore("actionError", formatError(err, language.t))
+ })
}
return (
@@ -266,6 +290,9 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
</Show>
</Show>
</div>
+ <Show when={store.actionError}>
+ {(message) => <p class="text-xs text-text-danger-base text-center max-w-2xl">{message()}</p>}
+ </Show>
<div class="flex flex-col items-center gap-2">
<div class="flex items-center justify-center gap-1">
{language.t("error.page.report.prefix")}
diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
index 6b61ed300..ba3a2b942 100644
--- a/packages/app/src/pages/home.tsx
+++ b/packages/app/src/pages/home.tsx
@@ -30,6 +30,13 @@ export default function Home() {
.slice(0, 5)
})
+ const serverDotClass = createMemo(() => {
+ const healthy = server.healthy()
+ if (healthy === true) return "bg-icon-success-base"
+ if (healthy === false) return "bg-icon-critical-base"
+ return "bg-border-weak-base"
+ })
+
function openProject(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
@@ -73,9 +80,7 @@ export default function Home() {
<div
classList={{
"size-2 rounded-full": true,
- "bg-icon-success-base": server.healthy() === true,
- "bg-icon-critical-base": server.healthy() === false,
- "bg-border-weak-base": server.healthy() === undefined,
+ [serverDotClass()]: true,
}}
/>
{server.name}
@@ -115,8 +120,7 @@ export default function Home() {
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
</div>
- <div />
- <Button class="px-3" onClick={chooseProject}>
+ <Button class="px-3 mt-1" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 1513752f0..aca52564b 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -207,6 +207,18 @@ export default function Layout(props: ParentProps) {
const setEditor = editor.setEditor
const InlineEditor = editor.InlineEditor
+ const clearSidebarHoverState = () => {
+ if (layout.sidebar.opened()) return
+ setState("hoverSession", undefined)
+ setState("hoverProject", undefined)
+ }
+
+ const navigateWithSidebarReset = (href: string) => {
+ clearSidebarHoverState()
+ navigate(href)
+ layout.mobileSidebar.hide()
+ }
+
function cycleTheme(direction = 1) {
const ids = availableThemeEntries().map(([id]) => id)
if (ids.length === 0) return
@@ -252,166 +264,167 @@ export default function Layout(props: ParentProps) {
setLocale(next)
}
- onMount(() => {
- if (!platform.checkUpdate || !platform.update || !platform.restart) return
-
- let toastId: number | undefined
- let interval: ReturnType<typeof setInterval> | undefined
-
- async function pollUpdate() {
- const { updateAvailable, version } = await platform.checkUpdate!()
- if (updateAvailable && toastId === undefined) {
- toastId = showToast({
- persistent: true,
- icon: "download",
- title: language.t("toast.update.title"),
- description: language.t("toast.update.description", { version: version ?? "" }),
- actions: [
- {
- label: language.t("toast.update.action.installRestart"),
- onClick: async () => {
- await platform.update!()
- await platform.restart!()
+ const useUpdatePolling = () =>
+ onMount(() => {
+ if (!platform.checkUpdate || !platform.update || !platform.restart) return
+
+ let toastId: number | undefined
+ let interval: ReturnType<typeof setInterval> | undefined
+
+ const pollUpdate = () =>
+ platform.checkUpdate!().then(({ updateAvailable, version }) => {
+ if (!updateAvailable) return
+ if (toastId !== undefined) return
+ toastId = showToast({
+ persistent: true,
+ icon: "download",
+ title: language.t("toast.update.title"),
+ description: language.t("toast.update.description", { version: version ?? "" }),
+ actions: [
+ {
+ label: language.t("toast.update.action.installRestart"),
+ onClick: async () => {
+ await platform.update!()
+ await platform.restart!()
+ },
},
- },
- {
- label: language.t("toast.update.action.notYet"),
- onClick: "dismiss",
- },
- ],
+ {
+ label: language.t("toast.update.action.notYet"),
+ onClick: "dismiss",
+ },
+ ],
+ })
})
- }
- }
- createEffect(() => {
- if (!settings.ready()) return
+ createEffect(() => {
+ if (!settings.ready()) return
- if (!settings.updates.startup()) {
+ if (!settings.updates.startup()) {
+ if (interval === undefined) return
+ clearInterval(interval)
+ interval = undefined
+ return
+ }
+
+ if (interval !== undefined) return
+ void pollUpdate()
+ interval = setInterval(pollUpdate, 10 * 60 * 1000)
+ })
+
+ onCleanup(() => {
if (interval === undefined) return
clearInterval(interval)
- interval = undefined
- return
- }
-
- if (interval !== undefined) return
- void pollUpdate()
- interval = setInterval(pollUpdate, 10 * 60 * 1000)
+ })
})
- onCleanup(() => {
- if (interval === undefined) return
- clearInterval(interval)
- })
- })
+ const useSDKNotificationToasts = () =>
+ onMount(() => {
+ const toastBySession = new Map<string, number>()
+ const alertedAtBySession = new Map<string, number>()
+ const cooldownMs = 5000
- onMount(() => {
- const toastBySession = new Map<string, number>()
- const alertedAtBySession = new Map<string, number>()
- const cooldownMs = 5000
-
- const unsub = globalSDK.event.listen((e) => {
- if (e.details?.type === "worktree.ready") {
- setBusy(e.name, false)
- WorktreeState.ready(e.name)
- return
+ const dismissSessionAlert = (sessionKey: string) => {
+ const toastId = toastBySession.get(sessionKey)
+ if (toastId === undefined) return
+ toaster.dismiss(toastId)
+ toastBySession.delete(sessionKey)
+ alertedAtBySession.delete(sessionKey)
}
- if (e.details?.type === "worktree.failed") {
- setBusy(e.name, false)
- WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
- return
- }
+ const unsub = globalSDK.event.listen((e) => {
+ if (e.details?.type === "worktree.ready") {
+ setBusy(e.name, false)
+ WorktreeState.ready(e.name)
+ return
+ }
- if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
- const title =
- e.details.type === "permission.asked"
- ? language.t("notification.permission.title")
- : language.t("notification.question.title")
- const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
- const directory = e.name
- const props = e.details.properties
- if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
-
- const [store] = globalSync.child(directory, { bootstrap: false })
- const session = store.session.find((s) => s.id === props.sessionID)
- const sessionKey = `${directory}:${props.sessionID}`
-
- const sessionTitle = session?.title ?? language.t("command.session.new")
- const projectName = getFilename(directory)
- const description =
- e.details.type === "permission.asked"
- ? language.t("notification.permission.description", { sessionTitle, projectName })
- : language.t("notification.question.description", { sessionTitle, projectName })
- const href = `/${base64Encode(directory)}/session/${props.sessionID}`
-
- const now = Date.now()
- const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
- if (now - lastAlerted < cooldownMs) return
- alertedAtBySession.set(sessionKey, now)
-
- if (e.details.type === "permission.asked") {
- playSound(soundSrc(settings.sounds.permissions()))
- if (settings.notifications.permissions()) {
- void platform.notify(title, description, href)
+ if (e.details?.type === "worktree.failed") {
+ setBusy(e.name, false)
+ WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
+ return
}
- }
- if (e.details.type === "question.asked") {
- if (settings.notifications.agent()) {
- void platform.notify(title, description, href)
+ if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
+ const title =
+ e.details.type === "permission.asked"
+ ? language.t("notification.permission.title")
+ : language.t("notification.question.title")
+ const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
+ const directory = e.name
+ const props = e.details.properties
+ if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
+
+ const [store] = globalSync.child(directory, { bootstrap: false })
+ const session = store.session.find((s) => s.id === props.sessionID)
+ const sessionKey = `${directory}:${props.sessionID}`
+
+ const sessionTitle = session?.title ?? language.t("command.session.new")
+ const projectName = getFilename(directory)
+ const description =
+ e.details.type === "permission.asked"
+ ? language.t("notification.permission.description", { sessionTitle, projectName })
+ : language.t("notification.question.description", { sessionTitle, projectName })
+ const href = `/${base64Encode(directory)}/session/${props.sessionID}`
+
+ const now = Date.now()
+ const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
+ if (now - lastAlerted < cooldownMs) return
+ alertedAtBySession.set(sessionKey, now)
+
+ if (e.details.type === "permission.asked") {
+ playSound(soundSrc(settings.sounds.permissions()))
+ if (settings.notifications.permissions()) {
+ void platform.notify(title, description, href)
+ }
}
- }
- const currentSession = params.id
- if (directory === currentDir() && props.sessionID === currentSession) return
- if (directory === currentDir() && session?.parentID === currentSession) return
-
- const existingToastId = toastBySession.get(sessionKey)
- if (existingToastId !== undefined) toaster.dismiss(existingToastId)
-
- const toastId = showToast({
- persistent: true,
- icon,
- title,
- description,
- actions: [
- {
- label: language.t("notification.action.goToSession"),
- onClick: () => navigate(href),
- },
- {
- label: language.t("common.dismiss"),
- onClick: "dismiss",
- },
- ],
+ if (e.details.type === "question.asked") {
+ if (settings.notifications.agent()) {
+ void platform.notify(title, description, href)
+ }
+ }
+
+ const currentSession = params.id
+ if (directory === currentDir() && props.sessionID === currentSession) return
+ if (directory === currentDir() && session?.parentID === currentSession) return
+
+ dismissSessionAlert(sessionKey)
+
+ const toastId = showToast({
+ persistent: true,
+ icon,
+ title,
+ description,
+ actions: [
+ {
+ label: language.t("notification.action.goToSession"),
+ onClick: () => navigate(href),
+ },
+ {
+ label: language.t("common.dismiss"),
+ onClick: "dismiss",
+ },
+ ],
+ })
+ toastBySession.set(sessionKey, toastId)
})
- toastBySession.set(sessionKey, toastId)
- })
- onCleanup(unsub)
-
- createEffect(() => {
- const currentSession = params.id
- if (!currentDir() || !currentSession) return
- const sessionKey = `${currentDir()}:${currentSession}`
- const toastId = toastBySession.get(sessionKey)
- if (toastId !== undefined) {
- toaster.dismiss(toastId)
- toastBySession.delete(sessionKey)
- alertedAtBySession.delete(sessionKey)
- }
- const [store] = globalSync.child(currentDir(), { bootstrap: false })
- const childSessions = store.session.filter((s) => s.parentID === currentSession)
- for (const child of childSessions) {
- const childKey = `${currentDir()}:${child.id}`
- const childToastId = toastBySession.get(childKey)
- if (childToastId !== undefined) {
- toaster.dismiss(childToastId)
- toastBySession.delete(childKey)
- alertedAtBySession.delete(childKey)
+ onCleanup(unsub)
+
+ createEffect(() => {
+ const currentSession = params.id
+ if (!currentDir() || !currentSession) return
+ const sessionKey = `${currentDir()}:${currentSession}`
+ dismissSessionAlert(sessionKey)
+ const [store] = globalSync.child(currentDir(), { bootstrap: false })
+ const childSessions = store.session.filter((s) => s.parentID === currentSession)
+ for (const child of childSessions) {
+ dismissSessionAlert(`${currentDir()}:${child.id}`)
}
- }
+ })
})
- })
+
+ useUpdatePolling()
+ useSDKNotificationToasts()
function scrollToSession(sessionId: string, sessionKey: string) {
if (!scrollContainerRef) return
@@ -641,6 +654,21 @@ export default function Layout(props: ParentProps) {
return created
}
+ const mergeByID = <T extends { id: string }>(current: T[], incoming: T[]) => {
+ if (current.length === 0) {
+ return incoming.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ }
+
+ const map = new Map<string, T>()
+ for (const item of current) {
+ map.set(item.id, item)
+ }
+ for (const item of incoming) {
+ map.set(item.id, item)
+ }
+ return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ }
+
async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
@@ -649,51 +677,24 @@ export default function Layout(props: ParentProps) {
if (prefetchToken.value !== token) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
- const next = items
- .map((x) => x.info)
- .filter((m) => !!m?.id)
- .slice()
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
+ const sorted = mergeByID([], next)
const current = store.message[sessionID] ?? []
- const merged = (() => {
- if (current.length === 0) return next
-
- const map = new Map<string, Message>()
- for (const item of current) {
- if (!item?.id) continue
- map.set(item.id, item)
- }
- for (const item of next) {
- map.set(item.id, item)
- }
- return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- })()
+ const merged = mergeByID(
+ current.filter((item): item is Message => !!item?.id),
+ sorted,
+ )
batch(() => {
setStore("message", sessionID, reconcile(merged, { key: "id" }))
for (const message of items) {
const currentParts = store.part[message.info.id] ?? []
- const mergedParts = (() => {
- if (currentParts.length === 0) {
- return message.parts
- .filter((p) => !!p?.id)
- .slice()
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- }
-
- const map = new Map<string, (typeof currentParts)[number]>()
- for (const item of currentParts) {
- if (!item?.id) continue
- map.set(item.id, item)
- }
- for (const item of message.parts) {
- if (!item?.id) continue
- map.set(item.id, item)
- }
- return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- })()
+ const mergedParts = mergeByID(
+ currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
+ message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
+ )
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
}
@@ -1073,24 +1074,14 @@ export default function Layout(props: ParentProps) {
function navigateToProject(directory: string | undefined) {
if (!directory) return
- if (!layout.sidebar.opened()) {
- setState("hoverSession", undefined)
- setState("hoverProject", undefined)
- }
server.projects.touch(directory)
const lastSession = store.lastSession[directory]
- navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
- layout.mobileSidebar.hide()
+ navigateWithSidebarReset(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
}
function navigateToSession(session: Session | undefined) {
if (!session) return
- if (!layout.sidebar.opened()) {
- setState("hoverSession", undefined)
- setState("hoverProject", undefined)
- }
- navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
- layout.mobileSidebar.hide()
+ navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
}
function openProject(directory: string, navigate = true) {
@@ -1555,10 +1546,7 @@ export default function Layout(props: ParentProps) {
}
const createWorkspace = async (project: LocalProject) => {
- if (!layout.sidebar.opened()) {
- setState("hoverSession", undefined)
- setState("hoverProject", undefined)
- }
+ clearSidebarHoverState()
const created = await globalSDK.client.worktree
.create({ directory: project.worktree })
.then((x) => x.data)
@@ -1595,8 +1583,7 @@ export default function Layout(props: ParentProps) {
})
globalSync.child(created.directory)
- navigate(`/${base64Encode(created.directory)}/session`)
- layout.mobileSidebar.hide()
+ navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
}
const workspaceSidebarCtx: WorkspaceSidebarContext = {
@@ -1772,14 +1759,7 @@ export default function Layout(props: ParentProps) {
size="large"
icon="plus-small"
class="w-full"
- onClick={() => {
- if (!layout.sidebar.opened()) {
- setState("hoverSession", undefined)
- setState("hoverProject", undefined)
- }
- navigate(`/${base64Encode(p().worktree)}/session`)
- layout.mobileSidebar.hide()
- }}
+ onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
diff --git a/packages/app/src/pages/layout/inline-editor.tsx b/packages/app/src/pages/layout/inline-editor.tsx
index 0bbfe244c..4189e4a72 100644
--- a/packages/app/src/pages/layout/inline-editor.tsx
+++ b/packages/app/src/pages/layout/inline-editor.tsx
@@ -1,8 +1,9 @@
import { createStore } from "solid-js/store"
-import { Show, type Accessor } from "solid-js"
+import { onCleanup, Show, type Accessor } from "solid-js"
import { InlineInput } from "@opencode-ai/ui/inline-input"
export function createInlineEditorController() {
+ // This controller intentionally supports one active inline editor at a time.
const [editor, setEditor] = createStore({
active: "" as string,
value: "",
@@ -47,6 +48,13 @@ export function createInlineEditorController() {
stopPropagation?: boolean
openOnDblClick?: boolean
}) => {
+ let frame: number | undefined
+
+ onCleanup(() => {
+ if (frame === undefined) return
+ cancelAnimationFrame(frame)
+ })
+
const isEditing = () => props.editing ?? editorOpen(props.id)
const stopEvents = () => props.stopPropagation ?? false
const allowDblClick = () => props.openOnDblClick ?? true
@@ -78,7 +86,12 @@ export function createInlineEditorController() {
>
<InlineInput
ref={(el) => {
- requestAnimationFrame(() => el.focus())
+ if (frame !== undefined) cancelAnimationFrame(frame)
+ frame = requestAnimationFrame(() => {
+ frame = undefined
+ if (!el.isConnected) return
+ el.focus()
+ })
}}
value={editorValue()}
class={props.class}
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index 678bfa0d8..d55090370 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -13,7 +13,7 @@ import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/util/path"
-import { type Message, type Session, type TextPart } from "@opencode-ai/sdk/v2/client"
+import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client"
import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
import { agentColor } from "@/utils/agent"
@@ -70,6 +70,116 @@ export type SessionItemProps = {
archiveSession: (session: Session) => Promise<void>
}
+const SessionRow = (props: {
+ session: Session
+ slug: string
+ mobile?: boolean
+ dense?: boolean
+ tint: Accessor<string | undefined>
+ isWorking: Accessor<boolean>
+ hasPermissions: Accessor<boolean>
+ hasError: Accessor<boolean>
+ unseenCount: Accessor<number>
+ setHoverSession: (id: string | undefined) => void
+ clearHoverProjectSoon: () => void
+ sidebarOpened: Accessor<boolean>
+ prefetchSession: (session: Session, priority?: "high" | "low") => void
+ scheduleHoverPrefetch: () => void
+ cancelHoverPrefetch: () => void
+}): JSX.Element => (
+ <A
+ href={`/${props.slug}/session/${props.session.id}`}
+ class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
+ onPointerEnter={props.scheduleHoverPrefetch}
+ onPointerLeave={props.cancelHoverPrefetch}
+ onMouseEnter={props.scheduleHoverPrefetch}
+ onMouseLeave={props.cancelHoverPrefetch}
+ onFocus={() => props.prefetchSession(props.session, "high")}
+ onClick={() => {
+ props.setHoverSession(undefined)
+ if (props.sidebarOpened()) return
+ props.clearHoverProjectSoon()
+ }}
+ >
+ <div class="flex items-center gap-1 w-full">
+ <div
+ class="shrink-0 size-6 flex items-center justify-center"
+ style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
+ >
+ <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
+ <Match when={props.isWorking()}>
+ <Spinner class="size-[15px]" />
+ </Match>
+ <Match when={props.hasPermissions()}>
+ <div class="size-1.5 rounded-full bg-surface-warning-strong" />
+ </Match>
+ <Match when={props.hasError()}>
+ <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
+ </Match>
+ <Match when={props.unseenCount() > 0}>
+ <div class="size-1.5 rounded-full bg-text-interactive-base" />
+ </Match>
+ </Switch>
+ </div>
+ <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
+ {props.session.title}
+ </span>
+ <Show when={props.session.summary}>
+ {(summary) => (
+ <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
+ <DiffChanges changes={summary()} />
+ </div>
+ )}
+ </Show>
+ </div>
+ </A>
+)
+
+const SessionHoverPreview = (props: {
+ mobile?: boolean
+ nav: Accessor<HTMLElement | undefined>
+ hoverSession: Accessor<string | undefined>
+ session: Session
+ sidebarHovering: Accessor<boolean>
+ hoverReady: Accessor<boolean>
+ hoverMessages: Accessor<UserMessage[] | undefined>
+ language: ReturnType<typeof useLanguage>
+ isActive: Accessor<boolean>
+ slug: string
+ setHoverSession: (id: string | undefined) => void
+ messageLabel: (message: Message) => string | undefined
+ onMessageSelect: (message: Message) => void
+ trigger: JSX.Element
+}): JSX.Element => (
+ <HoverCard
+ openDelay={1000}
+ closeDelay={props.sidebarHovering() ? 600 : 0}
+ placement="right-start"
+ gutter={16}
+ shift={-2}
+ trigger={props.trigger}
+ mount={!props.mobile ? props.nav() : undefined}
+ open={props.hoverSession() === props.session.id}
+ onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
+ >
+ <Show
+ when={props.hoverReady()}
+ fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
+ >
+ <div class="overflow-y-auto max-h-72 h-full">
+ <MessageNav
+ messages={props.hoverMessages() ?? []}
+ current={undefined}
+ getLabel={props.messageLabel}
+ onMessageSelect={props.onMessageSelect}
+ size="normal"
+ class="w-60"
+ />
+ </div>
+ </Show>
+ </HoverCard>
+)
+
export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams()
const navigate = useNavigate()
@@ -113,7 +223,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
})
const hoverMessages = createMemo(() =>
- sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
+ sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
)
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
@@ -141,54 +251,24 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
return text?.text
}
-
const item = (
- <A
- href={`/${props.slug}/session/${props.session.id}`}
- class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
- onPointerEnter={scheduleHoverPrefetch}
- onPointerLeave={cancelHoverPrefetch}
- onMouseEnter={scheduleHoverPrefetch}
- onMouseLeave={cancelHoverPrefetch}
- onFocus={() => props.prefetchSession(props.session, "high")}
- onClick={() => {
- props.setHoverSession(undefined)
- if (layout.sidebar.opened()) return
- props.clearHoverProjectSoon()
- }}
- >
- <div class="flex items-center gap-1 w-full">
- <div
- class="shrink-0 size-6 flex items-center justify-center"
- style={{ color: tint() ?? "var(--icon-interactive-base)" }}
- >
- <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
- <Match when={isWorking()}>
- <Spinner class="size-[15px]" />
- </Match>
- <Match when={hasPermissions()}>
- <div class="size-1.5 rounded-full bg-surface-warning-strong" />
- </Match>
- <Match when={hasError()}>
- <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
- </Match>
- <Match when={unseenCount() > 0}>
- <div class="size-1.5 rounded-full bg-text-interactive-base" />
- </Match>
- </Switch>
- </div>
- <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
- {props.session.title}
- </span>
- <Show when={props.session.summary}>
- {(summary) => (
- <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
- <DiffChanges changes={summary()} />
- </div>
- )}
- </Show>
- </div>
- </A>
+ <SessionRow
+ session={props.session}
+ slug={props.slug}
+ mobile={props.mobile}
+ dense={props.dense}
+ tint={tint}
+ isWorking={isWorking}
+ hasPermissions={hasPermissions}
+ hasError={hasError}
+ unseenCount={unseenCount}
+ setHoverSession={props.setHoverSession}
+ clearHoverProjectSoon={props.clearHoverProjectSoon}
+ sidebarOpened={layout.sidebar.opened}
+ prefetchSession={props.prefetchSession}
+ scheduleHoverPrefetch={scheduleHoverPrefetch}
+ cancelHoverPrefetch={cancelHoverPrefetch}
+ />
)
return (
@@ -205,44 +285,30 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Tooltip>
}
>
- <HoverCard
- openDelay={1000}
- closeDelay={props.sidebarHovering() ? 600 : 0}
- placement="right-start"
- gutter={16}
- shift={-2}
+ <SessionHoverPreview
+ mobile={props.mobile}
+ nav={props.nav}
+ hoverSession={props.hoverSession}
+ session={props.session}
+ sidebarHovering={props.sidebarHovering}
+ hoverReady={hoverReady}
+ hoverMessages={hoverMessages}
+ language={language}
+ isActive={isActive}
+ slug={props.slug}
+ setHoverSession={props.setHoverSession}
+ messageLabel={messageLabel}
+ onMessageSelect={(message) => {
+ if (!isActive()) {
+ layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
+ navigate(`${props.slug}/session/${props.session.id}`)
+ return
+ }
+ window.history.replaceState(null, "", `#message-${message.id}`)
+ window.dispatchEvent(new HashChangeEvent("hashchange"))
+ }}
trigger={item}
- mount={!props.mobile ? props.nav() : undefined}
- open={props.hoverSession() === props.session.id}
- onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
- >
- <Show
- when={hoverReady()}
- fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>}
- >
- <div class="overflow-y-auto max-h-72 h-full">
- <MessageNav
- messages={hoverMessages() ?? []}
- current={undefined}
- getLabel={messageLabel}
- onMessageSelect={(message) => {
- if (!isActive()) {
- layout.pendingMessage.set(
- `${base64Encode(props.session.directory)}/${props.session.id}`,
- message.id,
- )
- navigate(`${props.slug}/session/${props.session.id}`)
- return
- }
- window.history.replaceState(null, "", `#message-${message.id}`)
- window.dispatchEvent(new HashChangeEvent("hashchange"))
- }}
- size="normal"
- class="w-60"
- />
- </div>
- </Show>
- </HoverCard>
+ />
</Show>
<div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx
index c91dc987d..9afa205b6 100644
--- a/packages/app/src/pages/layout/sidebar-project.tsx
+++ b/packages/app/src/pages/layout/sidebar-project.tsx
@@ -51,6 +51,195 @@ export const ProjectDragOverlay = (props: {
)
}
+const ProjectTile = (props: {
+ project: LocalProject
+ mobile?: boolean
+ nav: Accessor<HTMLElement | undefined>
+ sidebarHovering: Accessor<boolean>
+ selected: Accessor<boolean>
+ active: Accessor<boolean>
+ overlay: Accessor<boolean>
+ onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
+ onProjectMouseLeave: (worktree: string) => void
+ onProjectFocus: (worktree: string) => void
+ navigateToProject: (directory: string) => void
+ showEditProjectDialog: (project: LocalProject) => void
+ toggleProjectWorkspaces: (project: LocalProject) => void
+ workspacesEnabled: (project: LocalProject) => boolean
+ closeProject: (directory: string) => void
+ setMenu: (value: boolean) => void
+ setOpen: (value: boolean) => void
+ language: ReturnType<typeof useLanguage>
+}): JSX.Element => (
+ <ContextMenu
+ modal={!props.sidebarHovering()}
+ onOpenChange={(value) => {
+ props.setMenu(value)
+ if (value) props.setOpen(false)
+ }}
+ >
+ <ContextMenu.Trigger
+ as="button"
+ type="button"
+ aria-label={displayName(props.project)}
+ data-action="project-switch"
+ data-project={base64Encode(props.project.worktree)}
+ classList={{
+ "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
+ "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
+ "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
+ !props.selected() && !props.active(),
+ "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
+ }}
+ onMouseEnter={(event: MouseEvent) => {
+ if (!props.overlay()) return
+ props.onProjectMouseEnter(props.project.worktree, event)
+ }}
+ onMouseLeave={() => {
+ if (!props.overlay()) return
+ props.onProjectMouseLeave(props.project.worktree)
+ }}
+ onFocus={() => {
+ if (!props.overlay()) return
+ props.onProjectFocus(props.project.worktree)
+ }}
+ onClick={() => props.navigateToProject(props.project.worktree)}
+ onBlur={() => props.setOpen(false)}
+ >
+ <ProjectIcon project={props.project} notify />
+ </ContextMenu.Trigger>
+ <ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
+ <ContextMenu.Content>
+ <ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
+ <ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ <ContextMenu.Item
+ data-action="project-workspaces-toggle"
+ data-project={base64Encode(props.project.worktree)}
+ disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
+ onSelect={() => props.toggleProjectWorkspaces(props.project)}
+ >
+ <ContextMenu.ItemLabel>
+ {props.workspacesEnabled(props.project)
+ ? props.language.t("sidebar.workspaces.disable")
+ : props.language.t("sidebar.workspaces.enable")}
+ </ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ <ContextMenu.Separator />
+ <ContextMenu.Item
+ data-action="project-close-menu"
+ data-project={base64Encode(props.project.worktree)}
+ onSelect={() => props.closeProject(props.project.worktree)}
+ >
+ <ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ </ContextMenu.Content>
+ </ContextMenu.Portal>
+ </ContextMenu>
+)
+
+const ProjectPreviewPanel = (props: {
+ project: LocalProject
+ mobile?: boolean
+ selected: Accessor<boolean>
+ workspaceEnabled: Accessor<boolean>
+ workspaces: Accessor<string[]>
+ label: (directory: string) => string
+ projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
+ projectChildren: Accessor<Map<string, string[]>>
+ workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
+ workspaceChildren: (directory: string) => Map<string, string[]>
+ setOpen: (value: boolean) => void
+ ctx: ProjectSidebarContext
+ language: ReturnType<typeof useLanguage>
+}): JSX.Element => (
+ <div class="-m-3 p-2 flex flex-col w-72">
+ <div class="px-4 pt-2 pb-1 flex items-center gap-2">
+ <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
+ <Tooltip value={props.language.t("common.close")} placement="top" gutter={6}>
+ <IconButton
+ icon="circle-x"
+ variant="ghost"
+ class="shrink-0"
+ data-action="project-close-hover"
+ data-project={base64Encode(props.project.worktree)}
+ aria-label={props.language.t("common.close")}
+ onClick={(event) => {
+ event.stopPropagation()
+ props.setOpen(false)
+ props.ctx.closeProject(props.project.worktree)
+ }}
+ />
+ </Tooltip>
+ </div>
+ <div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
+ <div class="px-2 pb-2 flex flex-col gap-2">
+ <Show
+ when={props.workspaceEnabled()}
+ fallback={
+ <For each={props.projectSessions()}>
+ {(session) => (
+ <SessionItem
+ {...props.ctx.sessionProps}
+ session={session}
+ slug={base64Encode(props.project.worktree)}
+ dense
+ mobile={props.mobile}
+ popover={false}
+ children={props.projectChildren()}
+ />
+ )}
+ </For>
+ }
+ >
+ <For each={props.workspaces()}>
+ {(directory) => {
+ const sessions = createMemo(() => props.workspaceSessions(directory))
+ const children = createMemo(() => props.workspaceChildren(directory))
+ return (
+ <div class="flex flex-col gap-1">
+ <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
+ <div class="shrink-0 size-6 flex items-center justify-center">
+ <Icon name="branch" size="small" class="text-icon-base" />
+ </div>
+ <span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
+ </div>
+ <For each={sessions()}>
+ {(session) => (
+ <SessionItem
+ {...props.ctx.sessionProps}
+ session={session}
+ slug={base64Encode(directory)}
+ dense
+ mobile={props.mobile}
+ popover={false}
+ children={children()}
+ />
+ )}
+ </For>
+ </div>
+ )
+ }}
+ </For>
+ </Show>
+ </div>
+ <div class="px-2 py-2 border-t border-border-weak-base">
+ <Button
+ variant="ghost"
+ class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
+ onClick={() => {
+ props.ctx.openSidebar()
+ props.setOpen(false)
+ if (props.selected()) return
+ props.ctx.navigateToProject(props.project.worktree)
+ }}
+ >
+ {props.language.t("sidebar.project.viewAllSessions")}
+ </Button>
+ </div>
+ </div>
+)
+
export const SortableProject = (props: {
project: LocalProject
mobile?: boolean
@@ -105,177 +294,61 @@ export const SortableProject = (props: {
const [data] = globalSync.child(directory, { bootstrap: false })
return childMapByParent(data.session)
}
-
- const Trigger = () => (
- <ContextMenu
- modal={!props.ctx.sidebarHovering()}
- onOpenChange={(value) => {
- setMenu(value)
- if (value) setOpen(false)
- }}
- >
- <ContextMenu.Trigger
- as="button"
- type="button"
- aria-label={displayName(props.project)}
- data-action="project-switch"
- data-project={base64Encode(props.project.worktree)}
- classList={{
- "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
- "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
- "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
- !selected() && !active(),
- "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
- }}
- onMouseEnter={(event: MouseEvent) => {
- if (!overlay()) return
- props.ctx.onProjectMouseEnter(props.project.worktree, event)
- }}
- onMouseLeave={() => {
- if (!overlay()) return
- props.ctx.onProjectMouseLeave(props.project.worktree)
- }}
- onFocus={() => {
- if (!overlay()) return
- props.ctx.onProjectFocus(props.project.worktree)
- }}
- onClick={() => props.ctx.navigateToProject(props.project.worktree)}
- onBlur={() => setOpen(false)}
- >
- <ProjectIcon project={props.project} notify />
- </ContextMenu.Trigger>
- <ContextMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
- <ContextMenu.Content>
- <ContextMenu.Item onSelect={() => props.ctx.showEditProjectDialog(props.project)}>
- <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
- </ContextMenu.Item>
- <ContextMenu.Item
- data-action="project-workspaces-toggle"
- data-project={base64Encode(props.project.worktree)}
- disabled={props.project.vcs !== "git" && !props.ctx.workspacesEnabled(props.project)}
- onSelect={() => props.ctx.toggleProjectWorkspaces(props.project)}
- >
- <ContextMenu.ItemLabel>
- {props.ctx.workspacesEnabled(props.project)
- ? language.t("sidebar.workspaces.disable")
- : language.t("sidebar.workspaces.enable")}
- </ContextMenu.ItemLabel>
- </ContextMenu.Item>
- <ContextMenu.Separator />
- <ContextMenu.Item
- data-action="project-close-menu"
- data-project={base64Encode(props.project.worktree)}
- onSelect={() => props.ctx.closeProject(props.project.worktree)}
- >
- <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
- </ContextMenu.Item>
- </ContextMenu.Content>
- </ContextMenu.Portal>
- </ContextMenu>
+ const trigger = (
+ <ProjectTile
+ project={props.project}
+ mobile={props.mobile}
+ nav={props.ctx.nav}
+ sidebarHovering={props.ctx.sidebarHovering}
+ selected={selected}
+ active={active}
+ overlay={overlay}
+ onProjectMouseEnter={props.ctx.onProjectMouseEnter}
+ onProjectMouseLeave={props.ctx.onProjectMouseLeave}
+ onProjectFocus={props.ctx.onProjectFocus}
+ navigateToProject={props.ctx.navigateToProject}
+ showEditProjectDialog={props.ctx.showEditProjectDialog}
+ toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
+ workspacesEnabled={props.ctx.workspacesEnabled}
+ closeProject={props.ctx.closeProject}
+ setMenu={setMenu}
+ setOpen={setOpen}
+ language={language}
+ />
)
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
- <Show when={preview()} fallback={<Trigger />}>
+ <Show when={preview()} fallback={trigger}>
<HoverCard
open={open() && !menu()}
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={6}
- trigger={<Trigger />}
+ trigger={trigger}
onOpenChange={(value) => {
if (menu()) return
setOpen(value)
if (value) props.ctx.setHoverSession(undefined)
}}
>
- <div class="-m-3 p-2 flex flex-col w-72">
- <div class="px-4 pt-2 pb-1 flex items-center gap-2">
- <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
- <Tooltip value={language.t("common.close")} placement="top" gutter={6}>
- <IconButton
- icon="circle-x"
- variant="ghost"
- class="shrink-0"
- data-action="project-close-hover"
- data-project={base64Encode(props.project.worktree)}
- aria-label={language.t("common.close")}
- onClick={(event) => {
- event.stopPropagation()
- setOpen(false)
- props.ctx.closeProject(props.project.worktree)
- }}
- />
- </Tooltip>
- </div>
- <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div>
- <div class="px-2 pb-2 flex flex-col gap-2">
- <Show
- when={workspaceEnabled()}
- fallback={
- <For each={projectSessions()}>
- {(session) => (
- <SessionItem
- {...props.ctx.sessionProps}
- session={session}
- slug={base64Encode(props.project.worktree)}
- dense
- mobile={props.mobile}
- popover={false}
- children={projectChildren()}
- />
- )}
- </For>
- }
- >
- <For each={workspaces()}>
- {(directory) => {
- const sessions = createMemo(() => workspaceSessions(directory))
- const children = createMemo(() => workspaceChildren(directory))
- return (
- <div class="flex flex-col gap-1">
- <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
- <div class="shrink-0 size-6 flex items-center justify-center">
- <Icon name="branch" size="small" class="text-icon-base" />
- </div>
- <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
- </div>
- <For each={sessions()}>
- {(session) => (
- <SessionItem
- {...props.ctx.sessionProps}
- session={session}
- slug={base64Encode(directory)}
- dense
- mobile={props.mobile}
- popover={false}
- children={children()}
- />
- )}
- </For>
- </div>
- )
- }}
- </For>
- </Show>
- </div>
- <div class="px-2 py-2 border-t border-border-weak-base">
- <Button
- variant="ghost"
- class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
- onClick={() => {
- props.ctx.openSidebar()
- setOpen(false)
- if (selected()) return
- props.ctx.navigateToProject(props.project.worktree)
- }}
- >
- {language.t("sidebar.project.viewAllSessions")}
- </Button>
- </div>
- </div>
+ <ProjectPreviewPanel
+ project={props.project}
+ mobile={props.mobile}
+ selected={selected}
+ workspaceEnabled={workspaceEnabled}
+ workspaces={workspaces}
+ label={label}
+ projectSessions={projectSessions}
+ projectChildren={projectChildren}
+ workspaceSessions={workspaceSessions}
+ workspaceChildren={workspaceChildren}
+ setOpen={setOpen}
+ ctx={props.ctx}
+ language={language}
+ />
</HoverCard>
</Show>
</div>
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
index ce96a09d1..23abdf157 100644
--- a/packages/app/src/pages/layout/sidebar-shell.tsx
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
@@ -34,6 +34,7 @@ export const SidebarContent = (props: {
renderPanel: () => JSX.Element
}): JSX.Element => {
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
+ const placement = () => (props.mobile ? "bottom" : "right")
return (
<div class="flex h-full w-full overflow-hidden">
@@ -55,7 +56,7 @@ export const SidebarContent = (props: {
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
</SortableProvider>
<Tooltip
- placement={props.mobile ? "bottom" : "right"}
+ placement={placement()}
value={
<div class="flex items-center gap-2">
<span>{props.openProjectLabel}</span>
@@ -78,11 +79,7 @@ export const SidebarContent = (props: {
</DragDropProvider>
</div>
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
- <TooltipKeybind
- placement={props.mobile ? "bottom" : "right"}
- title={props.settingsLabel()}
- keybind={props.settingsKeybind() ?? ""}
- >
+ <TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
<IconButton
icon="settings-gear"
variant="ghost"
@@ -91,7 +88,7 @@ export const SidebarContent = (props: {
aria-label={props.settingsLabel()}
/>
</TooltipKeybind>
- <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}>
+ <Tooltip placement={placement()} value={props.helpLabel()}>
<IconButton
icon="help"
variant="ghost"
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index 13c1e55ef..1d9c2e685 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -82,6 +82,222 @@ export const WorkspaceDragOverlay = (props: {
)
}
+const WorkspaceHeader = (props: {
+ local: Accessor<boolean>
+ busy: Accessor<boolean>
+ open: Accessor<boolean>
+ directory: string
+ language: ReturnType<typeof useLanguage>
+ branch: Accessor<string | undefined>
+ workspaceValue: Accessor<string>
+ workspaceEditActive: Accessor<boolean>
+ InlineEditor: WorkspaceSidebarContext["InlineEditor"]
+ renameWorkspace: WorkspaceSidebarContext["renameWorkspace"]
+ setEditor: WorkspaceSidebarContext["setEditor"]
+ projectId?: string
+}): JSX.Element => (
+ <div class="flex items-center gap-1 min-w-0 flex-1">
+ <div class="flex items-center justify-center shrink-0 size-6">
+ <Show when={props.busy()} fallback={<Icon name="branch" size="small" />}>
+ <Spinner class="size-[15px]" />
+ </Show>
+ </div>
+ <span class="text-14-medium text-text-base shrink-0">
+ {props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} :
+ </span>
+ <Show
+ when={!props.local()}
+ fallback={
+ <span class="text-14-medium text-text-base min-w-0 truncate">
+ {props.branch() ?? getFilename(props.directory)}
+ </span>
+ }
+ >
+ <props.InlineEditor
+ id={`workspace:${props.directory}`}
+ value={props.workspaceValue}
+ onSave={(next) => {
+ const trimmed = next.trim()
+ if (!trimmed) return
+ props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch())
+ props.setEditor("value", props.workspaceValue())
+ }}
+ class="text-14-medium text-text-base min-w-0 truncate"
+ displayClass="text-14-medium text-text-base min-w-0 truncate"
+ editing={props.workspaceEditActive()}
+ stopPropagation={false}
+ openOnDblClick={false}
+ />
+ </Show>
+ <div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
+ <Icon name={props.open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
+ </div>
+ </div>
+)
+
+const WorkspaceActions = (props: {
+ directory: string
+ local: Accessor<boolean>
+ busy: Accessor<boolean>
+ menuOpen: Accessor<boolean>
+ pendingRename: Accessor<boolean>
+ setMenuOpen: (open: boolean) => void
+ setPendingRename: (value: boolean) => void
+ sidebarHovering: Accessor<boolean>
+ mobile?: boolean
+ nav: Accessor<HTMLElement | undefined>
+ touch: Accessor<boolean>
+ language: ReturnType<typeof useLanguage>
+ workspaceValue: Accessor<string>
+ openEditor: WorkspaceSidebarContext["openEditor"]
+ showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
+ showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
+ root: string
+ setHoverSession: WorkspaceSidebarContext["setHoverSession"]
+ clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
+ navigateToNewSession: () => void
+}): JSX.Element => (
+ <div
+ class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
+ classList={{
+ "opacity-100 pointer-events-auto": props.menuOpen(),
+ "opacity-0 pointer-events-none": !props.menuOpen(),
+ "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
+ "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
+ }}
+ >
+ <DropdownMenu
+ modal={!props.sidebarHovering()}
+ open={props.menuOpen()}
+ onOpenChange={(open) => props.setMenuOpen(open)}
+ >
+ <Tooltip value={props.language.t("common.moreOptions")} placement="top">
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ class="size-6 rounded-md"
+ data-action="workspace-menu"
+ data-workspace={base64Encode(props.directory)}
+ aria-label={props.language.t("common.moreOptions")}
+ />
+ </Tooltip>
+ <DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
+ <DropdownMenu.Content
+ onCloseAutoFocus={(event) => {
+ if (!props.pendingRename()) return
+ event.preventDefault()
+ props.setPendingRename(false)
+ props.openEditor(`workspace:${props.directory}`, props.workspaceValue())
+ }}
+ >
+ <DropdownMenu.Item
+ disabled={props.local()}
+ onSelect={() => {
+ props.setPendingRename(true)
+ props.setMenuOpen(false)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ disabled={props.local() || props.busy()}
+ onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
+ >
+ <DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ disabled={props.local() || props.busy()}
+ onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
+ >
+ <DropdownMenu.ItemLabel>{props.language.t("common.delete")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ <Show when={!props.touch()}>
+ <Tooltip value={props.language.t("command.session.new")} placement="top">
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
+ data-action="workspace-new-session"
+ data-workspace={base64Encode(props.directory)}
+ aria-label={props.language.t("command.session.new")}
+ onClick={(event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ props.setHoverSession(undefined)
+ props.clearHoverProjectSoon()
+ props.navigateToNewSession()
+ }}
+ />
+ </Tooltip>
+ </Show>
+ </div>
+)
+
+const WorkspaceSessionList = (props: {
+ slug: Accessor<string>
+ mobile?: boolean
+ ctx: WorkspaceSidebarContext
+ showNew: Accessor<boolean>
+ loading: Accessor<boolean>
+ sessions: Accessor<Session[]>
+ children: Accessor<Map<string, string[]>>
+ hasMore: Accessor<boolean>
+ loadMore: () => Promise<void>
+ language: ReturnType<typeof useLanguage>
+}): JSX.Element => (
+ <nav class="flex flex-col gap-1 px-2">
+ <Show when={props.showNew()}>
+ <NewSessionItem
+ slug={props.slug()}
+ mobile={props.mobile}
+ sidebarExpanded={props.ctx.sidebarExpanded}
+ clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+ setHoverSession={props.ctx.setHoverSession}
+ />
+ </Show>
+ <Show when={props.loading()}>
+ <SessionSkeleton />
+ </Show>
+ <For each={props.sessions()}>
+ {(session) => (
+ <SessionItem
+ session={session}
+ slug={props.slug()}
+ mobile={props.mobile}
+ children={props.children()}
+ sidebarExpanded={props.ctx.sidebarExpanded}
+ sidebarHovering={props.ctx.sidebarHovering}
+ nav={props.ctx.nav}
+ hoverSession={props.ctx.hoverSession}
+ setHoverSession={props.ctx.setHoverSession}
+ clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+ prefetchSession={props.ctx.prefetchSession}
+ archiveSession={props.ctx.archiveSession}
+ />
+ )}
+ </For>
+ <Show when={props.hasMore()}>
+ <div class="relative w-full py-1">
+ <Button
+ variant="ghost"
+ class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
+ size="large"
+ onClick={(e: MouseEvent) => {
+ props.loadMore()
+ ;(e.currentTarget as HTMLButtonElement).blur()
+ }}
+ >
+ {props.language.t("common.loadMore")}
+ </Button>
+ </div>
+ </Show>
+ </nav>
+)
+
export const SortableWorkspace = (props: {
ctx: WorkspaceSidebarContext
directory: string
@@ -135,46 +351,6 @@ export const SortableWorkspace = (props: {
globalSync.child(props.directory, { bootstrap: true })
})
- const header = () => (
- <div class="flex items-center gap-1 min-w-0 flex-1">
- <div class="flex items-center justify-center shrink-0 size-6">
- <Show when={busy()} fallback={<Icon name="branch" size="small" />}>
- <Spinner class="size-[15px]" />
- </Show>
- </div>
- <span class="text-14-medium text-text-base shrink-0">
- {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} :
- </span>
- <Show
- when={!local()}
- fallback={
- <span class="text-14-medium text-text-base min-w-0 truncate">
- {workspaceStore.vcs?.branch ?? getFilename(props.directory)}
- </span>
- }
- >
- <props.ctx.InlineEditor
- id={`workspace:${props.directory}`}
- value={workspaceValue}
- onSave={(next) => {
- const trimmed = next.trim()
- if (!trimmed) return
- props.ctx.renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
- props.ctx.setEditor("value", workspaceValue())
- }}
- class="text-14-medium text-text-base min-w-0 truncate"
- displayClass="text-14-medium text-text-base min-w-0 truncate"
- editing={workspaceEditActive()}
- stopPropagation={false}
- openOnDblClick={false}
- />
- </Show>
- <div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
- <Icon name={open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
- </div>
- </div>
- )
-
return (
<div
// @ts-ignore
@@ -202,7 +378,20 @@ export const SortableWorkspace = (props: {
data-action="workspace-toggle"
data-workspace={base64Encode(props.directory)}
>
- {header()}
+ <WorkspaceHeader
+ local={local}
+ busy={busy}
+ open={open}
+ directory={props.directory}
+ language={language}
+ branch={() => workspaceStore.vcs?.branch}
+ workspaceValue={workspaceValue}
+ workspaceEditActive={workspaceEditActive}
+ InlineEditor={props.ctx.InlineEditor}
+ renameWorkspace={props.ctx.renameWorkspace}
+ setEditor={props.ctx.setEditor}
+ projectId={props.project.id}
+ />
</Collapsible.Trigger>
}
>
@@ -211,139 +400,61 @@ export const SortableWorkspace = (props: {
menu.open ? "pr-16" : "pr-2"
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
>
- {header()}
+ <WorkspaceHeader
+ local={local}
+ busy={busy}
+ open={open}
+ directory={props.directory}
+ language={language}
+ branch={() => workspaceStore.vcs?.branch}
+ workspaceValue={workspaceValue}
+ workspaceEditActive={workspaceEditActive}
+ InlineEditor={props.ctx.InlineEditor}
+ renameWorkspace={props.ctx.renameWorkspace}
+ setEditor={props.ctx.setEditor}
+ projectId={props.project.id}
+ />
</div>
</Show>
- <div
- class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
- classList={{
- "opacity-100 pointer-events-auto": menu.open,
- "opacity-0 pointer-events-none": !menu.open,
- "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
- "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
- }}
- >
- <DropdownMenu
- modal={!props.ctx.sidebarHovering()}
- open={menu.open}
- onOpenChange={(open) => setMenu("open", open)}
- >
- <Tooltip value={language.t("common.moreOptions")} placement="top">
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- class="size-6 rounded-md"
- data-action="workspace-menu"
- data-workspace={base64Encode(props.directory)}
- aria-label={language.t("common.moreOptions")}
- />
- </Tooltip>
- <DropdownMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
- <DropdownMenu.Content
- onCloseAutoFocus={(event) => {
- if (!menu.pendingRename) return
- event.preventDefault()
- setMenu("pendingRename", false)
- props.ctx.openEditor(`workspace:${props.directory}`, workspaceValue())
- }}
- >
- <DropdownMenu.Item
- disabled={local()}
- onSelect={() => {
- setMenu("pendingRename", true)
- setMenu("open", false)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- disabled={local() || busy()}
- onSelect={() => props.ctx.showResetWorkspaceDialog(props.project.worktree, props.directory)}
- >
- <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- disabled={local() || busy()}
- onSelect={() => props.ctx.showDeleteWorkspaceDialog(props.project.worktree, props.directory)}
- >
- <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
- <Show when={!touch()}>
- <Tooltip value={language.t("command.session.new")} placement="top">
- <IconButton
- icon="plus-small"
- variant="ghost"
- class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
- data-action="workspace-new-session"
- data-workspace={base64Encode(props.directory)}
- aria-label={language.t("command.session.new")}
- onClick={(event) => {
- event.preventDefault()
- event.stopPropagation()
- props.ctx.setHoverSession(undefined)
- props.ctx.clearHoverProjectSoon()
- navigate(`/${slug()}/session`)
- }}
- />
- </Tooltip>
- </Show>
- </div>
+ <WorkspaceActions
+ directory={props.directory}
+ local={local}
+ busy={busy}
+ menuOpen={() => menu.open}
+ pendingRename={() => menu.pendingRename}
+ setMenuOpen={(open) => setMenu("open", open)}
+ setPendingRename={(value) => setMenu("pendingRename", value)}
+ sidebarHovering={props.ctx.sidebarHovering}
+ mobile={props.mobile}
+ nav={props.ctx.nav}
+ touch={touch}
+ language={language}
+ workspaceValue={workspaceValue}
+ openEditor={props.ctx.openEditor}
+ showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
+ showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
+ root={props.project.worktree}
+ setHoverSession={props.ctx.setHoverSession}
+ clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+ navigateToNewSession={() => navigate(`/${slug()}/session`)}
+ />
</div>
</div>
</div>
<Collapsible.Content>
- <nav class="flex flex-col gap-1 px-2">
- <Show when={showNew()}>
- <NewSessionItem
- slug={slug()}
- mobile={props.mobile}
- sidebarExpanded={props.ctx.sidebarExpanded}
- clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
- setHoverSession={props.ctx.setHoverSession}
- />
- </Show>
- <Show when={loading()}>
- <SessionSkeleton />
- </Show>
- <For each={sessions()}>
- {(session) => (
- <SessionItem
- session={session}
- slug={slug()}
- mobile={props.mobile}
- children={children()}
- sidebarExpanded={props.ctx.sidebarExpanded}
- sidebarHovering={props.ctx.sidebarHovering}
- nav={props.ctx.nav}
- hoverSession={props.ctx.hoverSession}
- setHoverSession={props.ctx.setHoverSession}
- clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
- prefetchSession={props.ctx.prefetchSession}
- archiveSession={props.ctx.archiveSession}
- />
- )}
- </For>
- <Show when={hasMore()}>
- <div class="relative w-full py-1">
- <Button
- variant="ghost"
- class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
- size="large"
- onClick={(e: MouseEvent) => {
- loadMore()
- ;(e.currentTarget as HTMLButtonElement).blur()
- }}
- >
- {language.t("common.loadMore")}
- </Button>
- </div>
- </Show>
- </nav>
+ <WorkspaceSessionList
+ slug={slug}
+ mobile={props.mobile}
+ ctx={props.ctx}
+ showNew={showNew}
+ loading={loading}
+ sessions={sessions}
+ children={children}
+ hasMore={hasMore}
+ loadMore={loadMore}
+ language={language}
+ />
</Collapsible.Content>
</Collapsible>
</div>
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 9453dd703..edcc660a0 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -394,6 +394,19 @@ export default function Page() {
})
}
+ const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
+ if (params.id !== sessionID) return
+ if (parentID) {
+ navigate(`/${params.dir}/session/${parentID}`)
+ return
+ }
+ if (nextSessionID) {
+ navigate(`/${params.dir}/session/${nextSessionID}`)
+ return
+ }
+ navigate(`/${params.dir}/session`)
+ }
+
async function archiveSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return
@@ -411,17 +424,7 @@ export default function Page() {
if (index !== -1) draft.session.splice(index, 1)
}),
)
-
- if (params.id !== sessionID) return
- if (session.parentID) {
- navigate(`/${params.dir}/session/${session.parentID}`)
- return
- }
- if (nextSession) {
- navigate(`/${params.dir}/session/${nextSession.id}`)
- return
- }
- navigate(`/${params.dir}/session`)
+ navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
@@ -487,16 +490,7 @@ export default function Page() {
}),
)
- if (params.id !== sessionID) return true
- if (session.parentID) {
- navigate(`/${params.dir}/session/${session.parentID}`)
- return true
- }
- if (nextSession) {
- navigate(`/${params.dir}/session/${nextSession.id}`)
- return true
- }
- navigate(`/${params.dir}/session`)
+ navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
@@ -1532,15 +1526,18 @@ export default function Page() {
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
- files: Object.fromEntries(
- tabs()
- .all()
- .flatMap((tab) => {
- const path = file.pathFromTab(tab)
- if (!path) return []
- return [[path, file.selectedLines(path) ?? null] as const]
- }),
- ),
+ files: tabs()
+ .all()
+ .reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
+ const path = file.pathFromTab(tab)
+ if (!path) return acc
+ const selected = file.selectedLines(path)
+ acc[path] =
+ selected && typeof selected === "object" && "start" in selected && "end" in selected
+ ? (selected as SelectedLineRange)
+ : null
+ return acc
+ }, {}),
})
})
@@ -1557,6 +1554,7 @@ export default function Page() {
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
<SessionMobileTabs
open={!isDesktop() && !!params.id}
+ mobileTab={store.mobileTab}
hasReview={hasReview()}
reviewCount={reviewCount()}
onSession={() => setStore("mobileTab", "session")}
@@ -1719,7 +1717,6 @@ export default function Page() {
dialog={dialog}
file={file}
comments={comments}
- sync={sync}
hasReview={hasReview()}
reviewCount={reviewCount()}
reviewTab={reviewTab()}
@@ -1731,10 +1728,12 @@ export default function Page() {
openTab={openTab}
showAllFiles={showAllFiles}
reviewPanel={reviewPanel}
- messages={messages as () => unknown[]}
- visibleUserMessages={visibleUserMessages as () => unknown[]}
- view={view}
- info={info as () => unknown}
+ vm={{
+ messages,
+ visibleUserMessages,
+ view,
+ info,
+ }}
handoffFiles={() => handoff.session.get(sessionKey())?.files}
codeComponent={codeComponent}
addCommentToContext={addCommentToContext}
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
index 0c8281a66..c94c0ff35 100644
--- a/packages/app/src/pages/session/file-tabs.tsx
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -12,6 +12,13 @@ import { useFile, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
+const formatCommentLabel = (range: SelectedLineRange) => {
+ const start = Math.min(range.start, range.end)
+ const end = Math.max(range.start, range.end)
+ if (start === end) return `line ${start}`
+ return `lines ${start}-${end}`
+}
+
export function FileTabContent(props: {
tab: string
activeTab: () => string
@@ -76,7 +83,6 @@ export function FileTabContent(props: {
showToast({
variant: "error",
title: props.language.t("toast.file.loadFailed.title"),
- description: "Invalid base64 content.",
})
})
const svgPreviewUrl = createMemo(() => {
@@ -116,34 +122,6 @@ export function FileTabContent(props: {
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)
- const end = Math.max(range.start, range.end)
- if (start === end) return `line ${start}`
- return `lines ${start}-${end}`
- }
-
const getRoot = () => {
const el = wrap
if (!el) return
@@ -174,8 +152,8 @@ export function FileTabContent(props: {
const el = wrap
const root = getRoot()
if (!el || !root) {
- setPositions({})
- setDraftTop(undefined)
+ setNote("positions", {})
+ setNote("draftTop", undefined)
return
}
@@ -186,21 +164,21 @@ export function FileTabContent(props: {
next[comment.id] = markerTop(el, marker)
}
- setPositions(next)
+ setNote("positions", next)
- const range = commenting()
+ const range = note.commenting
if (!range) {
- setDraftTop(undefined)
+ setNote("draftTop", undefined)
return
}
const marker = findMarker(root, range)
if (!marker) {
- setDraftTop(undefined)
+ setNote("draftTop", undefined)
return
}
- setDraftTop(markerTop(el, marker))
+ setNote("draftTop", markerTop(el, marker))
}
const scheduleComments = () => {
@@ -213,10 +191,10 @@ export function FileTabContent(props: {
})
createEffect(() => {
- const range = commenting()
+ const range = note.commenting
scheduleComments()
if (!range) return
- setDraft("")
+ setNote("draft", "")
})
createEffect(() => {
@@ -229,8 +207,8 @@ export function FileTabContent(props: {
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
- setOpenedComment(target.id)
- setCommenting(null)
+ setNote("openedComment", target.id)
+ setNote("commenting", null)
props.file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => props.comments.clearFocus())
})
@@ -390,16 +368,16 @@ export function FileTabContent(props: {
const p = path()
if (!p) return
props.file.setSelectedLines(p, range)
- if (!range) setCommenting(null)
+ if (!range) setNote("commenting", null)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
- setCommenting(null)
+ setNote("commenting", null)
return
}
- setOpenedComment(null)
- setCommenting(range)
+ setNote("openedComment", null)
+ setNote("commenting", range)
}}
overflow="scroll"
class="select-text"
@@ -408,10 +386,10 @@ export function FileTabContent(props: {
{(comment) => (
<LineCommentView
id={comment.id}
- top={positions()[comment.id]}
- open={openedComment() === comment.id}
+ top={note.positions[comment.id]}
+ open={note.openedComment === comment.id}
comment={comment.comment}
- selection={commentLabel(comment.selection)}
+ selection={formatCommentLabel(comment.selection)}
onMouseEnter={() => {
const p = path()
if (!p) return
@@ -420,22 +398,22 @@ export function FileTabContent(props: {
onClick={() => {
const p = path()
if (!p) return
- setCommenting(null)
- setOpenedComment((current) => (current === comment.id ? null : comment.id))
+ setNote("commenting", null)
+ setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
props.file.setSelectedLines(p, comment.selection)
}}
/>
)}
</For>
- <Show when={commenting()}>
+ <Show when={note.commenting}>
{(range) => (
- <Show when={draftTop() !== undefined}>
+ <Show when={note.draftTop !== undefined}>
<LineCommentEditor
- top={draftTop()}
- value={draft()}
- selection={commentLabel(range())}
- onInput={(value) => setDraft(value)}
- onCancel={() => setCommenting(null)}
+ top={note.draftTop}
+ value={note.draft}
+ selection={formatCommentLabel(range())}
+ onInput={(value) => setNote("draft", value)}
+ onCancel={() => setNote("commenting", null)}
onSubmit={(value) => {
const p = path()
if (!p) return
@@ -445,7 +423,7 @@ export function FileTabContent(props: {
comment: value,
origin: "file",
})
- setCommenting(null)
+ setNote("commenting", null)
}}
onPopoverFocusOut={(e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
@@ -454,7 +432,7 @@ export function FileTabContent(props: {
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
- setCommenting(null)
+ setNote("commenting", null)
}
}, 0)
}}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index a4ca06dd5..d5f04ccf9 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -9,6 +9,37 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
+const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
+ const current = target instanceof Element ? target : undefined
+ const nested = current?.closest("[data-scrollable]")
+ if (!nested || nested === root) return root
+ if (!(nested instanceof HTMLElement)) return root
+ return nested
+}
+
+const markBoundaryGesture = (input: {
+ root: HTMLDivElement
+ target: EventTarget | null
+ delta: number
+ onMarkScrollGesture: (target?: EventTarget | null) => void
+}) => {
+ const target = boundaryTarget(input.root, input.target)
+ if (target === input.root) {
+ input.onMarkScrollGesture(input.root)
+ return
+ }
+ if (
+ shouldMarkBoundaryGesture({
+ delta: input.delta,
+ scrollTop: target.scrollTop,
+ scrollHeight: target.scrollHeight,
+ clientHeight: target.clientHeight,
+ })
+ ) {
+ input.onMarkScrollGesture(input.root)
+ }
+}
+
export function MessageTimeline(props: {
mobileChanges: boolean
mobileFallback: JSX.Element
@@ -86,35 +117,13 @@ export function MessageTimeline(props: {
ref={props.setScrollRef}
onWheel={(e) => {
const root = e.currentTarget
- const target = e.target instanceof Element ? e.target : undefined
- const nested = target?.closest("[data-scrollable]")
- if (!nested || nested === root) {
- props.onMarkScrollGesture(root)
- return
- }
-
- if (!(nested instanceof HTMLElement)) {
- props.onMarkScrollGesture(root)
- return
- }
-
const delta = normalizeWheelDelta({
deltaY: e.deltaY,
deltaMode: e.deltaMode,
rootHeight: root.clientHeight,
})
if (!delta) return
-
- if (
- shouldMarkBoundaryGesture({
- delta,
- scrollTop: nested.scrollTop,
- scrollHeight: nested.scrollHeight,
- clientHeight: nested.clientHeight,
- })
- ) {
- props.onMarkScrollGesture(root)
- }
+ markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
}}
onTouchStart={(e) => {
touchGesture = e.touches[0]?.clientY
@@ -129,28 +138,7 @@ export function MessageTimeline(props: {
if (!delta) return
const root = e.currentTarget
- const target = e.target instanceof Element ? e.target : undefined
- const nested = target?.closest("[data-scrollable]")
- if (!nested || nested === root) {
- props.onMarkScrollGesture(root)
- return
- }
-
- if (!(nested instanceof HTMLElement)) {
- props.onMarkScrollGesture(root)
- return
- }
-
- if (
- shouldMarkBoundaryGesture({
- delta,
- scrollTop: nested.scrollTop,
- scrollHeight: nested.scrollHeight,
- clientHeight: nested.clientHeight,
- })
- ) {
- props.onMarkScrollGesture(root)
- }
+ markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
}}
onTouchEnd={() => {
touchGesture = undefined
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx
index 72518c68e..634491c72 100644
--- a/packages/app/src/pages/session/review-tab.tsx
+++ b/packages/app/src/pages/session/review-tab.tsx
@@ -1,4 +1,5 @@
-import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js"
+import { createEffect, on, onCleanup, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review"
import type { SelectedLineRange } from "@/context/file"
@@ -30,7 +31,7 @@ export interface SessionReviewTabProps {
}
export function StickyAddButton(props: { children: JSX.Element }) {
- const [stuck, setStuck] = createSignal(false)
+ const [state, setState] = createStore({ stuck: false })
let button: HTMLDivElement | undefined
createEffect(() => {
@@ -43,7 +44,7 @@ export function StickyAddButton(props: { children: JSX.Element }) {
const handler = () => {
const rect = node.getBoundingClientRect()
const scrollRect = scroll.getBoundingClientRect()
- setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
+ setState("stuck", rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
}
scroll.addEventListener("scroll", handler, { passive: true })
@@ -60,7 +61,7 @@ export function StickyAddButton(props: { children: JSX.Element }) {
<div
ref={button}
class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3"
- classList={{ "border-l": stuck() }}
+ classList={{ "border-l": state.stuck }}
>
{props.children}
</div>
@@ -78,7 +79,10 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
return sdk.client.file
.read({ path })
.then((x) => x.data)
- .catch(() => undefined)
+ .catch((error) => {
+ console.debug("[session-review] failed to read file", { path, error })
+ return undefined
+ })
}
const restoreScroll = () => {
diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx
index 41f058231..6afe8024a 100644
--- a/packages/app/src/pages/session/session-mobile-tabs.tsx
+++ b/packages/app/src/pages/session/session-mobile-tabs.tsx
@@ -1,8 +1,9 @@
-import { Match, Show, Switch } from "solid-js"
+import { Show } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
export function SessionMobileTabs(props: {
open: boolean
+ mobileTab: "session" | "changes"
hasReview: boolean
reviewCount: number
onSession: () => void
@@ -11,7 +12,7 @@ export function SessionMobileTabs(props: {
}) {
return (
<Show when={props.open}>
- <Tabs class="h-auto">
+ <Tabs value={props.mobileTab} class="h-auto">
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}>
{props.t("session.tab.session")}
@@ -22,12 +23,9 @@ export function SessionMobileTabs(props: {
classes={{ button: "w-full" }}
onClick={props.onChanges}
>
- <Switch>
- <Match when={props.hasReview}>
- {props.t("session.review.filesChanged", { count: props.reviewCount })}
- </Match>
- <Match when={true}>{props.t("session.review.change.other")}</Match>
- </Switch>
+ {props.hasReview
+ ? props.t("session.review.filesChanged", { count: props.reviewCount })
+ : props.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx
index eaf0564b2..8ec4f3b9f 100644
--- a/packages/app/src/pages/session/session-prompt-dock.tsx
+++ b/packages/app/src/pages/session/session-prompt-dock.tsx
@@ -1,15 +1,14 @@
-import { For, Show, type ComponentProps } from "solid-js"
+import { For, Show } from "solid-js"
+import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
-const questionDockRequest = (value: unknown) => value as ComponentProps<typeof QuestionDock>["request"]
-
export function SessionPromptDock(props: {
centered: boolean
- questionRequest: () => { questions: unknown[] } | undefined
+ questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
promptReady: boolean
@@ -48,7 +47,7 @@ export function SessionPromptDock(props: {
subtitle,
}}
/>
- <QuestionDock request={questionDockRequest(req)} />
+ <QuestionDock request={req} />
</div>
)
}}
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
index d9460cc1a..15ad90ffe 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -21,6 +21,14 @@ import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
+import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client"
+
+type SessionSidePanelViewModel = {
+ messages: () => Message[]
+ visibleUserMessages: () => UserMessage[]
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
+}
export function SessionSidePanel(props: {
open: boolean
@@ -31,7 +39,6 @@ export function SessionSidePanel(props: {
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
comments: ReturnType<typeof useComments>
- sync: ReturnType<typeof useSync>
hasReview: boolean
reviewCount: number
reviewTab: boolean
@@ -43,10 +50,7 @@ export function SessionSidePanel(props: {
openTab: (value: string) => void
showAllFiles: () => void
reviewPanel: () => JSX.Element
- messages: () => unknown[]
- visibleUserMessages: () => unknown[]
- view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
- info: () => unknown
+ vm: SessionSidePanelViewModel
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
codeComponent: NonNullable<ValidComponent>
addCommentToContext: (input: {
@@ -187,10 +191,10 @@ export function SessionSidePanel(props: {
<Show when={props.activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab
- messages={props.messages as never}
- visibleUserMessages={props.visibleUserMessages as never}
- view={props.view as never}
- info={props.info as never}
+ messages={props.vm.messages}
+ visibleUserMessages={props.vm.visibleUserMessages}
+ view={props.vm.view}
+ info={props.vm.info}
/>
</div>
</Show>
@@ -203,7 +207,7 @@ export function SessionSidePanel(props: {
tab={tab}
activeTab={props.activeTab}
tabs={props.tabs}
- view={props.view}
+ view={props.vm.view}
handoffFiles={props.handoffFiles}
file={props.file}
comments={props.comments}
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index 2e65fde0e..d3475c714 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -1,4 +1,4 @@
-import { createMemo, For, Show } from "solid-js"
+import { For, Show } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -141,9 +141,8 @@ export function TerminalPanel(props: {
<DragOverlay>
<Show when={props.activeTerminalDraggable()}>
{(draggedId) => {
- const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId()))
return (
- <Show when={pty()}>
+ <Show when={props.terminal.all().find((t: LocalPTY) => t.id === draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index d52022d73..81c71133f 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -1,8 +1,8 @@
import { createMemo } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
-import { useCommand } from "@/context/command"
+import { useCommand, type CommandOption } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useFile, selectionFromLines, type FileSelection } from "@/context/file"
+import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
@@ -22,7 +22,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { combineCommandSections } from "@/pages/session/helpers"
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
-export const useSessionCommands = (input: {
+export type SessionCommandContext = {
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
@@ -49,32 +49,48 @@ export const useSessionCommands = (input: {
setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
-}) => {
+}
+
+const withCategory = (category: string) => {
+ return (option: Omit<CommandOption, "category">): CommandOption => ({
+ ...option,
+ category,
+ })
+}
+
+export const useSessionCommands = (input: SessionCommandContext) => {
+ const sessionCommand = withCategory(input.language.t("command.category.session"))
+ const fileCommand = withCategory(input.language.t("command.category.file"))
+ const contextCommand = withCategory(input.language.t("command.category.context"))
+ const viewCommand = withCategory(input.language.t("command.category.view"))
+ const terminalCommand = withCategory(input.language.t("command.category.terminal"))
+ const modelCommand = withCategory(input.language.t("command.category.model"))
+ const mcpCommand = withCategory(input.language.t("command.category.mcp"))
+ const agentCommand = withCategory(input.language.t("command.category.agent"))
+ const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
+
const sessionCommands = createMemo(() => [
- {
+ sessionCommand({
id: "session.new",
title: input.language.t("command.session.new"),
- category: input.language.t("command.category.session"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => input.navigate(`/${input.params.dir}/session`),
- },
+ }),
])
const fileCommands = createMemo(() => [
- {
+ fileCommand({
id: "file.open",
title: input.language.t("command.file.open"),
description: input.language.t("palette.search.placeholder"),
- category: input.language.t("command.category.file"),
keybind: "mod+p",
slash: "open",
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
- },
- {
+ }),
+ fileCommand({
id: "tab.close",
title: input.language.t("command.tab.close"),
- category: input.language.t("command.category.file"),
keybind: "mod+w",
disabled: !input.tabs().active(),
onSelect: () => {
@@ -82,15 +98,14 @@ export const useSessionCommands = (input: {
if (!active) return
input.tabs().close(active)
},
- },
+ }),
])
const contextCommands = createMemo(() => [
- {
+ contextCommand({
id: "context.addSelection",
title: input.language.t("command.context.addSelection"),
description: input.language.t("command.context.addSelection.description"),
- category: input.language.t("command.category.context"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext({
active: input.tabs().active(),
@@ -103,7 +118,7 @@ export const useSessionCommands = (input: {
const path = input.file.pathFromTab(active)
if (!path) return
- const range = input.file.selectedLines(path)
+ const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: input.language.t("toast.context.noLineSelection.title"),
@@ -114,58 +129,49 @@ export const useSessionCommands = (input: {
input.addSelectionToContext(path, selectionFromLines(range))
},
- },
+ }),
])
const viewCommands = createMemo(() => [
- {
+ viewCommand({
id: "terminal.toggle",
title: input.language.t("command.terminal.toggle"),
- description: "",
- category: input.language.t("command.category.view"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => input.view().terminal.toggle(),
- },
- {
+ }),
+ viewCommand({
id: "review.toggle",
title: input.language.t("command.review.toggle"),
- description: "",
- category: input.language.t("command.category.view"),
keybind: "mod+shift+r",
onSelect: () => input.view().reviewPanel.toggle(),
- },
- {
+ }),
+ viewCommand({
id: "fileTree.toggle",
title: input.language.t("command.fileTree.toggle"),
- description: "",
- category: input.language.t("command.category.view"),
keybind: "mod+\\",
onSelect: () => input.layout.fileTree.toggle(),
- },
- {
+ }),
+ viewCommand({
id: "input.focus",
title: input.language.t("command.input.focus"),
- category: input.language.t("command.category.view"),
keybind: "ctrl+l",
onSelect: () => input.focusInput(),
- },
- {
+ }),
+ terminalCommand({
id: "terminal.new",
title: input.language.t("command.terminal.new"),
description: input.language.t("command.terminal.new.description"),
- category: input.language.t("command.category.terminal"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (input.terminal.all().length > 0) input.terminal.new()
input.view().terminal.open()
},
- },
- {
+ }),
+ viewCommand({
id: "steps.toggle",
title: input.language.t("command.steps.toggle"),
description: input.language.t("command.steps.toggle.description"),
- category: input.language.t("command.category.view"),
keybind: "mod+e",
slash: "steps",
disabled: !input.params.id,
@@ -174,86 +180,78 @@ export const useSessionCommands = (input: {
if (!msg) return
input.setExpanded(msg.id, (open: boolean | undefined) => !open)
},
- },
+ }),
])
const messageCommands = createMemo(() => [
- {
+ sessionCommand({
id: "message.previous",
title: input.language.t("command.message.previous"),
description: input.language.t("command.message.previous.description"),
- category: input.language.t("command.category.session"),
keybind: "mod+arrowup",
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(-1),
- },
- {
+ }),
+ sessionCommand({
id: "message.next",
title: input.language.t("command.message.next"),
description: input.language.t("command.message.next.description"),
- category: input.language.t("command.category.session"),
keybind: "mod+arrowdown",
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(1),
- },
+ }),
])
const agentCommands = createMemo(() => [
- {
+ modelCommand({
id: "model.choose",
title: input.language.t("command.model.choose"),
description: input.language.t("command.model.choose.description"),
- category: input.language.t("command.category.model"),
keybind: "mod+'",
slash: "model",
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
- },
- {
+ }),
+ mcpCommand({
id: "mcp.toggle",
title: input.language.t("command.mcp.toggle"),
description: input.language.t("command.mcp.toggle.description"),
- category: input.language.t("command.category.mcp"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
- },
- {
+ }),
+ agentCommand({
id: "agent.cycle",
title: input.language.t("command.agent.cycle"),
description: input.language.t("command.agent.cycle.description"),
- category: input.language.t("command.category.agent"),
keybind: "mod+.",
slash: "agent",
onSelect: () => input.local.agent.move(1),
- },
- {
+ }),
+ agentCommand({
id: "agent.cycle.reverse",
title: input.language.t("command.agent.cycle.reverse"),
description: input.language.t("command.agent.cycle.reverse.description"),
- category: input.language.t("command.category.agent"),
keybind: "shift+mod+.",
onSelect: () => input.local.agent.move(-1),
- },
- {
+ }),
+ modelCommand({
id: "model.variant.cycle",
title: input.language.t("command.model.variant.cycle"),
description: input.language.t("command.model.variant.cycle.description"),
- category: input.language.t("command.category.model"),
keybind: "shift+mod+d",
onSelect: () => {
input.local.model.variant.cycle()
},
- },
+ }),
])
const permissionCommands = createMemo(() => [
- {
+ permissionsCommand({
id: "permissions.autoaccept",
title:
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
? input.language.t("command.permissions.autoaccept.disable")
: input.language.t("command.permissions.autoaccept.enable"),
- category: input.language.t("command.category.permissions"),
keybind: "mod+shift+a",
disabled: !input.params.id || !input.permission.permissionsEnabled(),
onSelect: () => {
@@ -269,15 +267,14 @@ export const useSessionCommands = (input: {
: input.language.t("toast.permissions.autoaccept.off.description"),
})
},
- },
+ }),
])
const sessionActionCommands = createMemo(() => [
- {
+ sessionCommand({
id: "session.undo",
title: input.language.t("command.session.undo"),
description: input.language.t("command.session.undo.description"),
- category: input.language.t("command.category.session"),
slash: "undo",
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => {
@@ -298,12 +295,11 @@ export const useSessionCommands = (input: {
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
input.setActiveMessage(priorMessage)
},
- },
- {
+ }),
+ sessionCommand({
id: "session.redo",
title: input.language.t("command.session.redo"),
description: input.language.t("command.session.redo.description"),
- category: input.language.t("command.category.session"),
slash: "redo",
disabled: !input.params.id || !input.info()?.revert?.messageID,
onSelect: async () => {
@@ -323,12 +319,11 @@ export const useSessionCommands = (input: {
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
input.setActiveMessage(priorMsg)
},
- },
- {
+ }),
+ sessionCommand({
id: "session.compact",
title: input.language.t("command.session.compact"),
description: input.language.t("command.session.compact.description"),
- category: input.language.t("command.category.session"),
slash: "compact",
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => {
@@ -348,22 +343,21 @@ export const useSessionCommands = (input: {
providerID: model.provider.id,
})
},
- },
- {
+ }),
+ sessionCommand({
id: "session.fork",
title: input.language.t("command.session.fork"),
description: input.language.t("command.session.fork.description"),
- category: input.language.t("command.category.session"),
slash: "fork",
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: () => input.dialog.show(() => <DialogFork />),
- },
+ }),
])
const shareCommands = createMemo(() => {
if (input.sync.data.config.share === "disabled") return []
return [
- {
+ sessionCommand({
id: "session.share",
title: input.info()?.share?.url
? input.language.t("session.share.copy.copyLink")
@@ -371,7 +365,6 @@ export const useSessionCommands = (input: {
description: input.info()?.share?.url
? input.language.t("toast.session.share.success.description")
: input.language.t("command.session.share.description"),
- category: input.language.t("command.category.session"),
slash: "share",
disabled: !input.params.id,
onSelect: async () => {
@@ -441,12 +434,11 @@ export const useSessionCommands = (input: {
await copy(url, false)
},
- },
- {
+ }),
+ sessionCommand({
id: "session.unshare",
title: input.language.t("command.session.unshare"),
description: input.language.t("command.session.unshare.description"),
- category: input.language.t("command.category.session"),
slash: "unshare",
disabled: !input.params.id || !input.info()?.share?.url,
onSelect: async () => {
@@ -468,7 +460,7 @@ export const useSessionCommands = (input: {
}),
)
},
- },
+ }),
]
})