diff options
| author | Adam <[email protected]> | 2026-02-12 09:49:14 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-12 09:49:14 -0600 |
| commit | ff4414bb152acfddb5c0eb073c38bedc1df4ae14 (patch) | |
| tree | 78381c67d21ef6f089647f6b19e7aa2976840dbc /packages/app/src/pages | |
| parent | 56ad2db02055955f926fda0e4a89055b22ead6f9 (diff) | |
| download | opencode-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')
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: { }), ) }, - }, + }), ] }) |
