|
|
@@ -1,4 +1,17 @@
|
|
|
-import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
|
|
|
+import {
|
|
|
+ createEffect,
|
|
|
+ createMemo,
|
|
|
+ createSignal,
|
|
|
+ For,
|
|
|
+ Match,
|
|
|
+ onCleanup,
|
|
|
+ onMount,
|
|
|
+ ParentProps,
|
|
|
+ Show,
|
|
|
+ Switch,
|
|
|
+ untrack,
|
|
|
+ type JSX,
|
|
|
+} from "solid-js"
|
|
|
import { DateTime } from "luxon"
|
|
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
|
|
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
|
|
|
@@ -28,23 +41,45 @@ import {
|
|
|
} from "@thisbeyond/solid-dnd"
|
|
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
|
|
import { useProviders } from "@/hooks/use-providers"
|
|
|
-import { showToast, Toast } from "@opencode-ai/ui/toast"
|
|
|
+import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
|
|
import { useGlobalSDK } from "@/context/global-sdk"
|
|
|
import { useNotification } from "@/context/notification"
|
|
|
import { Binary } from "@opencode-ai/util/binary"
|
|
|
import { Header } from "@/components/header"
|
|
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
|
+import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
|
|
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
|
|
-import { useCommand } from "@/context/command"
|
|
|
+import { DialogEditProject } from "@/components/dialog-edit-project"
|
|
|
+import { useCommand, type CommandOption } from "@/context/command"
|
|
|
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
|
|
|
|
|
export default function Layout(props: ParentProps) {
|
|
|
const [store, setStore] = createStore({
|
|
|
lastSession: {} as { [directory: string]: string },
|
|
|
activeDraggable: undefined as string | undefined,
|
|
|
+ mobileSidebarOpen: false,
|
|
|
+ mobileProjectsExpanded: {} as Record<string, boolean>,
|
|
|
})
|
|
|
|
|
|
+ const mobileSidebar = {
|
|
|
+ open: () => store.mobileSidebarOpen,
|
|
|
+ show: () => setStore("mobileSidebarOpen", true),
|
|
|
+ hide: () => setStore("mobileSidebarOpen", false),
|
|
|
+ toggle: () => setStore("mobileSidebarOpen", (x) => !x),
|
|
|
+ }
|
|
|
+
|
|
|
+ const mobileProjects = {
|
|
|
+ expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
|
|
|
+ expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
|
|
|
+ collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
|
|
|
+ }
|
|
|
+
|
|
|
let scrollContainerRef: HTMLDivElement | undefined
|
|
|
+ const xlQuery = window.matchMedia("(min-width: 1280px)")
|
|
|
+ const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
|
|
|
+ const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
|
|
|
+ xlQuery.addEventListener("change", handleViewportChange)
|
|
|
+ onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
|
|
|
|
|
|
const params = useParams()
|
|
|
const globalSDK = useGlobalSDK()
|
|
|
@@ -56,6 +91,41 @@ export default function Layout(props: ParentProps) {
|
|
|
const providers = useProviders()
|
|
|
const dialog = useDialog()
|
|
|
const command = useCommand()
|
|
|
+ const theme = useTheme()
|
|
|
+ const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
|
|
+ const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
|
|
+ const colorSchemeLabel: Record<ColorScheme, string> = {
|
|
|
+ system: "System",
|
|
|
+ light: "Light",
|
|
|
+ dark: "Dark",
|
|
|
+ }
|
|
|
+
|
|
|
+ function cycleTheme(direction = 1) {
|
|
|
+ const ids = availableThemeEntries().map(([id]) => id)
|
|
|
+ if (ids.length === 0) return
|
|
|
+ const currentIndex = ids.indexOf(theme.themeId())
|
|
|
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
|
|
|
+ const nextThemeId = ids[nextIndex]
|
|
|
+ theme.setTheme(nextThemeId)
|
|
|
+ const nextTheme = theme.themes()[nextThemeId]
|
|
|
+ showToast({
|
|
|
+ title: "Theme switched",
|
|
|
+ description: nextTheme?.name ?? nextThemeId,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ function cycleColorScheme(direction = 1) {
|
|
|
+ const current = theme.colorScheme()
|
|
|
+ const currentIndex = colorSchemeOrder.indexOf(current)
|
|
|
+ const nextIndex =
|
|
|
+ currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
|
|
|
+ const next = colorSchemeOrder[nextIndex]
|
|
|
+ theme.setColorScheme(next)
|
|
|
+ showToast({
|
|
|
+ title: "Color scheme",
|
|
|
+ description: colorSchemeLabel[next],
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
onMount(async () => {
|
|
|
if (platform.checkUpdate && platform.update && platform.restart) {
|
|
|
@@ -84,42 +154,96 @@ export default function Layout(props: ParentProps) {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- function flattenSessions(sessions: Session[]): Session[] {
|
|
|
- const childrenMap = new Map<string, Session[]>()
|
|
|
- for (const session of sessions) {
|
|
|
- if (session.parentID) {
|
|
|
- const children = childrenMap.get(session.parentID) ?? []
|
|
|
- children.push(session)
|
|
|
- childrenMap.set(session.parentID, children)
|
|
|
+ onMount(() => {
|
|
|
+ const seenSessions = new Set<string>()
|
|
|
+ const toastBySession = new Map<string, number>()
|
|
|
+ const unsub = globalSDK.event.listen((e) => {
|
|
|
+ if (e.details?.type !== "permission.updated") return
|
|
|
+ const directory = e.name
|
|
|
+ const permission = e.details.properties
|
|
|
+ const sessionKey = `${directory}:${permission.sessionID}`
|
|
|
+ if (seenSessions.has(sessionKey)) return
|
|
|
+ seenSessions.add(sessionKey)
|
|
|
+ const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
|
|
+ const currentSession = params.id
|
|
|
+ if (directory === currentDir && permission.sessionID === currentSession) return
|
|
|
+ const [store] = globalSync.child(directory)
|
|
|
+ const session = store.session.find((s) => s.id === permission.sessionID)
|
|
|
+ if (directory === currentDir && session?.parentID === currentSession) return
|
|
|
+ const sessionTitle = session?.title ?? "New session"
|
|
|
+ const projectName = getFilename(directory)
|
|
|
+ const toastId = showToast({
|
|
|
+ persistent: true,
|
|
|
+ icon: "checklist",
|
|
|
+ title: "Permission required",
|
|
|
+ description: `${sessionTitle} in ${projectName} needs permission`,
|
|
|
+ actions: [
|
|
|
+ {
|
|
|
+ label: "Go to session",
|
|
|
+ onClick: () => {
|
|
|
+ navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`)
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: "Dismiss",
|
|
|
+ onClick: "dismiss",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ })
|
|
|
+ toastBySession.set(sessionKey, toastId)
|
|
|
+ })
|
|
|
+ onCleanup(unsub)
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
|
|
+ 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)
|
|
|
+ seenSessions.delete(sessionKey)
|
|
|
}
|
|
|
- }
|
|
|
- const result: Session[] = []
|
|
|
- function visit(session: Session) {
|
|
|
- result.push(session)
|
|
|
- for (const child of childrenMap.get(session.id) ?? []) {
|
|
|
- visit(child)
|
|
|
+ const [store] = globalSync.child(currentDir)
|
|
|
+ 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)
|
|
|
+ seenSessions.delete(childKey)
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
- for (const session of sessions) {
|
|
|
- if (!session.parentID) visit(session)
|
|
|
- }
|
|
|
- return result
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ function sortSessions(a: Session, b: Session) {
|
|
|
+ const now = Date.now()
|
|
|
+ const oneMinuteAgo = now - 60 * 1000
|
|
|
+ const aUpdated = a.time.updated ?? a.time.created
|
|
|
+ const bUpdated = b.time.updated ?? b.time.created
|
|
|
+ const aRecent = aUpdated > oneMinuteAgo
|
|
|
+ const bRecent = bUpdated > oneMinuteAgo
|
|
|
+ if (aRecent && bRecent) return a.id.localeCompare(b.id)
|
|
|
+ if (aRecent && !bRecent) return -1
|
|
|
+ if (!aRecent && bRecent) return 1
|
|
|
+ return bUpdated - aUpdated
|
|
|
}
|
|
|
|
|
|
function scrollToSession(sessionId: string) {
|
|
|
if (!scrollContainerRef) return
|
|
|
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
|
|
|
if (element) {
|
|
|
- element.scrollIntoView({ block: "center", behavior: "smooth" })
|
|
|
+ element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function projectSessions(directory: string) {
|
|
|
if (!directory) return []
|
|
|
- const sessions = globalSync
|
|
|
- .child(directory)[0]
|
|
|
- .session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
|
|
- return flattenSessions(sessions ?? [])
|
|
|
+ const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
|
|
|
+ return (sessions ?? []).filter((s) => !s.parentID)
|
|
|
}
|
|
|
|
|
|
const currentSessions = createMemo(() => {
|
|
|
@@ -199,57 +323,102 @@ export default function Layout(props: ParentProps) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- command.register(() => [
|
|
|
- {
|
|
|
- id: "sidebar.toggle",
|
|
|
- title: "Toggle sidebar",
|
|
|
- category: "View",
|
|
|
- keybind: "mod+b",
|
|
|
- onSelect: () => layout.sidebar.toggle(),
|
|
|
- },
|
|
|
- ...(platform.openDirectoryPickerDialog
|
|
|
- ? [
|
|
|
- {
|
|
|
- id: "project.open",
|
|
|
- title: "Open project",
|
|
|
- category: "Project",
|
|
|
- keybind: "mod+o",
|
|
|
- onSelect: () => chooseProject(),
|
|
|
- },
|
|
|
- ]
|
|
|
- : []),
|
|
|
- {
|
|
|
- id: "provider.connect",
|
|
|
- title: "Connect provider",
|
|
|
- category: "Provider",
|
|
|
- onSelect: () => connectProvider(),
|
|
|
- },
|
|
|
- {
|
|
|
- id: "session.previous",
|
|
|
- title: "Previous session",
|
|
|
- category: "Session",
|
|
|
- keybind: "alt+arrowup",
|
|
|
- onSelect: () => navigateSessionByOffset(-1),
|
|
|
- },
|
|
|
- {
|
|
|
- id: "session.next",
|
|
|
- title: "Next session",
|
|
|
- category: "Session",
|
|
|
- keybind: "alt+arrowdown",
|
|
|
- onSelect: () => navigateSessionByOffset(1),
|
|
|
- },
|
|
|
- {
|
|
|
- id: "session.archive",
|
|
|
- title: "Archive session",
|
|
|
- category: "Session",
|
|
|
- keybind: "mod+shift+backspace",
|
|
|
- disabled: !params.dir || !params.id,
|
|
|
- onSelect: () => {
|
|
|
- const session = currentSessions().find((s) => s.id === params.id)
|
|
|
- if (session) archiveSession(session)
|
|
|
+ command.register(() => {
|
|
|
+ const commands: CommandOption[] = [
|
|
|
+ {
|
|
|
+ id: "sidebar.toggle",
|
|
|
+ title: "Toggle sidebar",
|
|
|
+ category: "View",
|
|
|
+ keybind: "mod+b",
|
|
|
+ onSelect: () => layout.sidebar.toggle(),
|
|
|
},
|
|
|
- },
|
|
|
- ])
|
|
|
+ ...(platform.openDirectoryPickerDialog
|
|
|
+ ? [
|
|
|
+ {
|
|
|
+ id: "project.open",
|
|
|
+ title: "Open project",
|
|
|
+ category: "Project",
|
|
|
+ keybind: "mod+o",
|
|
|
+ onSelect: () => chooseProject(),
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ : []),
|
|
|
+ {
|
|
|
+ id: "provider.connect",
|
|
|
+ title: "Connect provider",
|
|
|
+ category: "Provider",
|
|
|
+ onSelect: () => connectProvider(),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "session.previous",
|
|
|
+ title: "Previous session",
|
|
|
+ category: "Session",
|
|
|
+ keybind: "alt+arrowup",
|
|
|
+ onSelect: () => navigateSessionByOffset(-1),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "session.next",
|
|
|
+ title: "Next session",
|
|
|
+ category: "Session",
|
|
|
+ keybind: "alt+arrowdown",
|
|
|
+ onSelect: () => navigateSessionByOffset(1),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "session.archive",
|
|
|
+ title: "Archive session",
|
|
|
+ category: "Session",
|
|
|
+ keybind: "mod+shift+backspace",
|
|
|
+ disabled: !params.dir || !params.id,
|
|
|
+ onSelect: () => {
|
|
|
+ const session = currentSessions().find((s) => s.id === params.id)
|
|
|
+ if (session) archiveSession(session)
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "theme.cycle",
|
|
|
+ title: "Cycle theme",
|
|
|
+ category: "Theme",
|
|
|
+ keybind: "mod+shift+t",
|
|
|
+ onSelect: () => cycleTheme(1),
|
|
|
+ },
|
|
|
+ ]
|
|
|
+
|
|
|
+ for (const [id, definition] of availableThemeEntries()) {
|
|
|
+ commands.push({
|
|
|
+ id: `theme.set.${id}`,
|
|
|
+ title: `Use theme: ${definition.name ?? id}`,
|
|
|
+ category: "Theme",
|
|
|
+ onSelect: () => theme.commitPreview(),
|
|
|
+ onHighlight: () => {
|
|
|
+ theme.previewTheme(id)
|
|
|
+ return () => theme.cancelPreview()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ commands.push({
|
|
|
+ id: "theme.scheme.cycle",
|
|
|
+ title: "Cycle color scheme",
|
|
|
+ category: "Theme",
|
|
|
+ keybind: "mod+shift+s",
|
|
|
+ onSelect: () => cycleColorScheme(1),
|
|
|
+ })
|
|
|
+
|
|
|
+ for (const scheme of colorSchemeOrder) {
|
|
|
+ commands.push({
|
|
|
+ id: `theme.scheme.${scheme}`,
|
|
|
+ title: `Use color scheme: ${colorSchemeLabel[scheme]}`,
|
|
|
+ category: "Theme",
|
|
|
+ onSelect: () => theme.commitPreview(),
|
|
|
+ onHighlight: () => {
|
|
|
+ theme.previewColorScheme(scheme)
|
|
|
+ return () => theme.cancelPreview()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return commands
|
|
|
+ })
|
|
|
|
|
|
function connectProvider() {
|
|
|
dialog.show(() => <DialogSelectProvider />)
|
|
|
@@ -259,11 +428,13 @@ export default function Layout(props: ParentProps) {
|
|
|
if (!directory) return
|
|
|
const lastSession = store.lastSession[directory]
|
|
|
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
|
|
+ mobileSidebar.hide()
|
|
|
}
|
|
|
|
|
|
function navigateToSession(session: Session | undefined) {
|
|
|
if (!session) return
|
|
|
navigate(`/${params.dir}/session/${session?.id}`)
|
|
|
+ mobileSidebar.hide()
|
|
|
}
|
|
|
|
|
|
function openProject(directory: string, navigate = true) {
|
|
|
@@ -297,13 +468,20 @@ export default function Layout(props: ParentProps) {
|
|
|
createEffect(() => {
|
|
|
if (!params.dir || !params.id) return
|
|
|
const directory = base64Decode(params.dir)
|
|
|
- setStore("lastSession", directory, params.id)
|
|
|
- notification.session.markViewed(params.id)
|
|
|
+ const id = params.id
|
|
|
+ setStore("lastSession", directory, id)
|
|
|
+ notification.session.markViewed(id)
|
|
|
+ untrack(() => layout.projects.expand(directory))
|
|
|
+ requestAnimationFrame(() => scrollToSession(id))
|
|
|
})
|
|
|
|
|
|
createEffect(() => {
|
|
|
- const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
|
|
- document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
|
|
+ if (isLargeViewport()) {
|
|
|
+ const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
|
|
+ document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
|
|
+ } else {
|
|
|
+ document.documentElement.style.setProperty("--dialog-left-margin", "0px")
|
|
|
+ }
|
|
|
})
|
|
|
|
|
|
function getDraggableId(event: unknown): string | undefined {
|
|
|
@@ -345,7 +523,7 @@ export default function Layout(props: ParentProps) {
|
|
|
const notification = useNotification()
|
|
|
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
|
|
|
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
|
|
- const name = createMemo(() => getFilename(props.project.worktree))
|
|
|
+ const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
|
|
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
|
|
|
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
|
|
|
|
|
@@ -381,7 +559,7 @@ export default function Layout(props: ParentProps) {
|
|
|
}
|
|
|
|
|
|
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
|
|
|
- const name = createMemo(() => getFilename(props.project.worktree))
|
|
|
+ const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
|
|
const current = createMemo(() => base64Decode(params.dir ?? ""))
|
|
|
return (
|
|
|
<Switch>
|
|
|
@@ -417,17 +595,26 @@ export default function Layout(props: ParentProps) {
|
|
|
session: Session
|
|
|
slug: string
|
|
|
project: LocalProject
|
|
|
- depth?: number
|
|
|
- childrenMap: Map<string, Session[]>
|
|
|
+ mobile?: boolean
|
|
|
}): JSX.Element => {
|
|
|
const notification = useNotification()
|
|
|
- const depth = props.depth ?? 0
|
|
|
- const children = createMemo(() => props.childrenMap.get(props.session.id) ?? [])
|
|
|
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
|
|
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
|
|
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
|
|
+ const hasPermissions = createMemo(() => {
|
|
|
+ const store = globalSync.child(props.project.worktree)[0]
|
|
|
+ const permissions = store.permission?.[props.session.id] ?? []
|
|
|
+ if (permissions.length > 0) return true
|
|
|
+ const childSessions = store.session.filter((s) => s.parentID === props.session.id)
|
|
|
+ for (const child of childSessions) {
|
|
|
+ const childPermissions = store.permission?.[child.id] ?? []
|
|
|
+ if (childPermissions.length > 0) return true
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ })
|
|
|
const isWorking = createMemo(() => {
|
|
|
if (props.session.id === params.id) return false
|
|
|
+ if (hasPermissions()) return false
|
|
|
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
|
|
|
return status?.type === "busy" || status?.type === "retry"
|
|
|
})
|
|
|
@@ -437,15 +624,20 @@ export default function Layout(props: ParentProps) {
|
|
|
data-session-id={props.session.id}
|
|
|
class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
|
|
|
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
|
|
|
- style={{ "padding-left": `${16 + depth * 12}px` }}
|
|
|
+ style={{ "padding-left": "16px" }}
|
|
|
>
|
|
|
- <Tooltip placement="right" value={props.session.title} gutter={10}>
|
|
|
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
|
|
<A
|
|
|
href={`${props.slug}/session/${props.session.id}`}
|
|
|
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
|
|
|
>
|
|
|
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
|
|
- <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
|
|
+ <span
|
|
|
+ classList={{
|
|
|
+ "text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
|
|
|
+ "animate-pulse": isWorking(),
|
|
|
+ }}
|
|
|
+ >
|
|
|
{props.session.title}
|
|
|
</span>
|
|
|
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
|
|
@@ -453,6 +645,9 @@ export default function Layout(props: ParentProps) {
|
|
|
<Match when={isWorking()}>
|
|
|
<Spinner class="size-2.5 mr-0.5" />
|
|
|
</Match>
|
|
|
+ <Match when={hasPermissions()}>
|
|
|
+ <div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
|
|
|
+ </Match>
|
|
|
<Match when={hasError()}>
|
|
|
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
|
|
|
</Match>
|
|
|
@@ -486,66 +681,54 @@ export default function Layout(props: ParentProps) {
|
|
|
</A>
|
|
|
</Tooltip>
|
|
|
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
|
|
- <Tooltip placement="right" value="Archive session">
|
|
|
+ <Tooltip
|
|
|
+ placement={props.mobile ? "bottom" : "right"}
|
|
|
+ value={
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span>Archive session</span>
|
|
|
+ <span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ >
|
|
|
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
|
|
|
</Tooltip>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <For each={children()}>
|
|
|
- {(child) => (
|
|
|
- <SessionItem
|
|
|
- session={child}
|
|
|
- slug={props.slug}
|
|
|
- project={props.project}
|
|
|
- depth={depth + 1}
|
|
|
- childrenMap={props.childrenMap}
|
|
|
- />
|
|
|
- )}
|
|
|
- </For>
|
|
|
</>
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- const SortableProject = (props: { project: LocalProject }): JSX.Element => {
|
|
|
+ const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
|
|
const sortable = createSortable(props.project.worktree)
|
|
|
+ const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
|
|
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
|
|
- const name = createMemo(() => getFilename(props.project.worktree))
|
|
|
+ const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
|
|
const [store, setProjectStore] = globalSync.child(props.project.worktree)
|
|
|
- const sessions = createMemo(() =>
|
|
|
- store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
|
|
|
- )
|
|
|
+ const sessions = createMemo(() => store.session.toSorted(sortSessions))
|
|
|
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
|
|
- const childSessionsByParent = createMemo(() => {
|
|
|
- const map = new Map<string, Session[]>()
|
|
|
- for (const session of sessions()) {
|
|
|
- if (session.parentID) {
|
|
|
- const children = map.get(session.parentID) ?? []
|
|
|
- children.push(session)
|
|
|
- map.set(session.parentID, children)
|
|
|
- }
|
|
|
- }
|
|
|
- return map
|
|
|
- })
|
|
|
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
|
|
|
const loadMoreSessions = async () => {
|
|
|
setProjectStore("limit", (limit) => limit + 5)
|
|
|
await globalSync.project.loadSessions(props.project.worktree)
|
|
|
}
|
|
|
+ const isExpanded = createMemo(() =>
|
|
|
+ props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
|
|
|
+ )
|
|
|
const handleOpenChange = (open: boolean) => {
|
|
|
- if (open) layout.projects.expand(props.project.worktree)
|
|
|
- else layout.projects.collapse(props.project.worktree)
|
|
|
+ if (props.mobile) {
|
|
|
+ if (open) mobileProjects.expand(props.project.worktree)
|
|
|
+ else mobileProjects.collapse(props.project.worktree)
|
|
|
+ } else {
|
|
|
+ if (open) layout.projects.expand(props.project.worktree)
|
|
|
+ else layout.projects.collapse(props.project.worktree)
|
|
|
+ }
|
|
|
}
|
|
|
return (
|
|
|
// @ts-ignore
|
|
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
|
|
<Switch>
|
|
|
- <Match when={layout.sidebar.opened()}>
|
|
|
- <Collapsible
|
|
|
- variant="ghost"
|
|
|
- open={props.project.expanded}
|
|
|
- class="gap-2 shrink-0"
|
|
|
- onOpenChange={handleOpenChange}
|
|
|
- >
|
|
|
+ <Match when={showExpanded()}>
|
|
|
+ <Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
|
|
|
<Button
|
|
|
as={"div"}
|
|
|
variant="ghost"
|
|
|
@@ -556,7 +739,7 @@ export default function Layout(props: ParentProps) {
|
|
|
project={props.project}
|
|
|
class="group-hover/session:hidden"
|
|
|
expandable
|
|
|
- notify={!props.project.expanded}
|
|
|
+ notify={!isExpanded()}
|
|
|
/>
|
|
|
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
|
|
</Collapsible.Trigger>
|
|
|
@@ -565,13 +748,26 @@ export default function Layout(props: ParentProps) {
|
|
|
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
|
|
|
<DropdownMenu.Portal>
|
|
|
<DropdownMenu.Content>
|
|
|
+ <DropdownMenu.Item
|
|
|
+ onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
|
|
|
+ >
|
|
|
+ <DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
|
|
|
+ </DropdownMenu.Item>
|
|
|
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
|
|
|
- <DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
|
|
|
+ <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
|
|
|
</DropdownMenu.Item>
|
|
|
</DropdownMenu.Content>
|
|
|
</DropdownMenu.Portal>
|
|
|
</DropdownMenu>
|
|
|
- <Tooltip placement="top" value="New session">
|
|
|
+ <Tooltip
|
|
|
+ placement="top"
|
|
|
+ value={
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span>New session</span>
|
|
|
+ <span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ >
|
|
|
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
|
|
|
</Tooltip>
|
|
|
</div>
|
|
|
@@ -580,12 +776,7 @@ export default function Layout(props: ParentProps) {
|
|
|
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
|
|
|
<For each={rootSessions()}>
|
|
|
{(session) => (
|
|
|
- <SessionItem
|
|
|
- session={session}
|
|
|
- slug={slug()}
|
|
|
- project={props.project}
|
|
|
- childrenMap={childSessionsByParent()}
|
|
|
- />
|
|
|
+ <SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} />
|
|
|
)}
|
|
|
</For>
|
|
|
<Show when={rootSessions().length === 0}>
|
|
|
@@ -595,7 +786,7 @@ export default function Layout(props: ParentProps) {
|
|
|
>
|
|
|
<div class="flex items-center self-stretch w-full">
|
|
|
<div class="flex-1 min-w-0">
|
|
|
- <Tooltip placement="right" value="New session">
|
|
|
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
|
|
|
<A
|
|
|
href={`${slug()}/session`}
|
|
|
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
|
|
|
@@ -650,30 +841,12 @@ export default function Layout(props: ParentProps) {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- return (
|
|
|
- <div class="relative flex-1 min-h-0 flex flex-col">
|
|
|
- <Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
|
|
|
- <div class="flex-1 min-h-0 flex">
|
|
|
- <div
|
|
|
- classList={{
|
|
|
- "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
|
|
|
- "flex flex-col gap-5.5 items-start self-stretch justify-between": true,
|
|
|
- "border-r border-border-weak-base contain-strict": true,
|
|
|
- }}
|
|
|
- style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
|
|
- >
|
|
|
- <Show when={layout.sidebar.opened()}>
|
|
|
- <ResizeHandle
|
|
|
- direction="horizontal"
|
|
|
- size={layout.sidebar.width()}
|
|
|
- min={150}
|
|
|
- max={window.innerWidth * 0.3}
|
|
|
- collapseThreshold={80}
|
|
|
- onResize={layout.sidebar.resize}
|
|
|
- onCollapse={layout.sidebar.close}
|
|
|
- />
|
|
|
- </Show>
|
|
|
- <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
|
|
+ const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
|
|
|
+ const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
|
|
+ <Show when={!sidebarProps.mobile}>
|
|
|
<Tooltip
|
|
|
class="shrink-0"
|
|
|
placement="right"
|
|
|
@@ -683,7 +856,7 @@ export default function Layout(props: ParentProps) {
|
|
|
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
|
|
|
</div>
|
|
|
}
|
|
|
- inactive={layout.sidebar.opened()}
|
|
|
+ inactive={expanded()}
|
|
|
>
|
|
|
<Button
|
|
|
variant="ghost"
|
|
|
@@ -715,110 +888,162 @@ export default function Layout(props: ParentProps) {
|
|
|
</Show>
|
|
|
</Button>
|
|
|
</Tooltip>
|
|
|
- <DragDropProvider
|
|
|
- onDragStart={handleDragStart}
|
|
|
- onDragEnd={handleDragEnd}
|
|
|
- onDragOver={handleDragOver}
|
|
|
- collisionDetector={closestCenter}
|
|
|
+ </Show>
|
|
|
+ <DragDropProvider
|
|
|
+ onDragStart={handleDragStart}
|
|
|
+ onDragEnd={handleDragEnd}
|
|
|
+ onDragOver={handleDragOver}
|
|
|
+ collisionDetector={closestCenter}
|
|
|
+ >
|
|
|
+ <DragDropSensors />
|
|
|
+ <ConstrainDragXAxis />
|
|
|
+ <div
|
|
|
+ ref={(el) => {
|
|
|
+ if (!sidebarProps.mobile) scrollContainerRef = el
|
|
|
+ }}
|
|
|
+ class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
|
|
|
>
|
|
|
- <DragDropSensors />
|
|
|
- <ConstrainDragXAxis />
|
|
|
- <div
|
|
|
- ref={scrollContainerRef}
|
|
|
- class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
|
|
|
- >
|
|
|
- <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
|
|
|
- <For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For>
|
|
|
- </SortableProvider>
|
|
|
- </div>
|
|
|
- <DragOverlay>
|
|
|
- <ProjectDragOverlay />
|
|
|
- </DragOverlay>
|
|
|
- </DragDropProvider>
|
|
|
- </div>
|
|
|
- <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
|
|
- <Switch>
|
|
|
- <Match when={!providers.paid().length && layout.sidebar.opened()}>
|
|
|
- <div class="rounded-md bg-background-stronger shadow-xs-border-base">
|
|
|
- <div class="p-3 flex flex-col gap-2">
|
|
|
- <div class="text-12-medium text-text-strong">Getting started</div>
|
|
|
- <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
|
|
|
- <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
|
|
|
- </div>
|
|
|
- <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
|
|
|
- <Button
|
|
|
- class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
|
|
|
- size="large"
|
|
|
- icon="plus"
|
|
|
- onClick={connectProvider}
|
|
|
- >
|
|
|
- <Show when={layout.sidebar.opened()}>Connect provider</Show>
|
|
|
- </Button>
|
|
|
- </Tooltip>
|
|
|
+ <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
|
|
|
+ <For each={layout.projects.list()}>
|
|
|
+ {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
|
|
|
+ </For>
|
|
|
+ </SortableProvider>
|
|
|
+ </div>
|
|
|
+ <DragOverlay>
|
|
|
+ <ProjectDragOverlay />
|
|
|
+ </DragOverlay>
|
|
|
+ </DragDropProvider>
|
|
|
+ </div>
|
|
|
+ <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
|
|
+ <Switch>
|
|
|
+ <Match when={!providers.paid().length && expanded()}>
|
|
|
+ <div class="rounded-md bg-background-stronger shadow-xs-border-base">
|
|
|
+ <div class="p-3 flex flex-col gap-2">
|
|
|
+ <div class="text-12-medium text-text-strong">Getting started</div>
|
|
|
+ <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
|
|
|
+ <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
|
|
|
</div>
|
|
|
- </Match>
|
|
|
- <Match when={true}>
|
|
|
- <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
|
|
|
+ <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
|
|
<Button
|
|
|
- class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
|
|
- variant="ghost"
|
|
|
+ class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
|
|
|
size="large"
|
|
|
icon="plus"
|
|
|
onClick={connectProvider}
|
|
|
>
|
|
|
- <Show when={layout.sidebar.opened()}>Connect provider</Show>
|
|
|
+ Connect provider
|
|
|
</Button>
|
|
|
</Tooltip>
|
|
|
- </Match>
|
|
|
- </Switch>
|
|
|
- <Show when={platform.openDirectoryPickerDialog}>
|
|
|
- <Tooltip
|
|
|
- placement="right"
|
|
|
- value={
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <span>Open project</span>
|
|
|
- <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
|
|
|
- </div>
|
|
|
- }
|
|
|
- inactive={layout.sidebar.opened()}
|
|
|
- >
|
|
|
+ </div>
|
|
|
+ </Match>
|
|
|
+ <Match when={true}>
|
|
|
+ <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
|
|
<Button
|
|
|
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
|
|
variant="ghost"
|
|
|
size="large"
|
|
|
- icon="folder-add-left"
|
|
|
- onClick={chooseProject}
|
|
|
+ icon="plus"
|
|
|
+ onClick={connectProvider}
|
|
|
>
|
|
|
- <Show when={layout.sidebar.opened()}>Open project</Show>
|
|
|
+ <Show when={expanded()}>Connect provider</Show>
|
|
|
</Button>
|
|
|
</Tooltip>
|
|
|
- </Show>
|
|
|
- {/* <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> */}
|
|
|
- {/* <Button */}
|
|
|
- {/* disabled */}
|
|
|
- {/* class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" */}
|
|
|
- {/* variant="ghost" */}
|
|
|
- {/* size="large" */}
|
|
|
- {/* icon="settings-gear" */}
|
|
|
- {/* > */}
|
|
|
- {/* <Show when={layout.sidebar.opened()}>Settings</Show> */}
|
|
|
- {/* </Button> */}
|
|
|
- {/* </Tooltip> */}
|
|
|
- <Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
+ <Show when={platform.openDirectoryPickerDialog}>
|
|
|
+ <Tooltip
|
|
|
+ placement="right"
|
|
|
+ value={
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span>Open project</span>
|
|
|
+ <Show when={!sidebarProps.mobile}>
|
|
|
+ <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ inactive={expanded()}
|
|
|
+ >
|
|
|
<Button
|
|
|
- as={"a"}
|
|
|
- href="https://opencode.ai/desktop-feedback"
|
|
|
- target="_blank"
|
|
|
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
|
|
variant="ghost"
|
|
|
size="large"
|
|
|
- icon="bubble-5"
|
|
|
+ icon="folder-add-left"
|
|
|
+ onClick={chooseProject}
|
|
|
>
|
|
|
- <Show when={layout.sidebar.opened()}>Share feedback</Show>
|
|
|
+ <Show when={expanded()}>Open project</Show>
|
|
|
</Button>
|
|
|
</Tooltip>
|
|
|
+ </Show>
|
|
|
+ <Tooltip placement="right" value="Share feedback" inactive={expanded()}>
|
|
|
+ <Button
|
|
|
+ as={"a"}
|
|
|
+ href="https://opencode.ai/desktop-feedback"
|
|
|
+ target="_blank"
|
|
|
+ class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
|
|
+ variant="ghost"
|
|
|
+ size="large"
|
|
|
+ icon="bubble-5"
|
|
|
+ >
|
|
|
+ <Show when={expanded()}>Share feedback</Show>
|
|
|
+ </Button>
|
|
|
+ </Tooltip>
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div class="relative flex-1 min-h-0 flex flex-col">
|
|
|
+ <Header
|
|
|
+ navigateToProject={navigateToProject}
|
|
|
+ navigateToSession={navigateToSession}
|
|
|
+ onMobileMenuToggle={mobileSidebar.toggle}
|
|
|
+ />
|
|
|
+ <div class="flex-1 min-h-0 flex">
|
|
|
+ <div
|
|
|
+ classList={{
|
|
|
+ "hidden xl:flex": true,
|
|
|
+ "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
|
|
|
+ "flex-col gap-5.5 items-start self-stretch justify-between": true,
|
|
|
+ "border-r border-border-weak-base contain-strict": true,
|
|
|
+ }}
|
|
|
+ style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
|
|
+ >
|
|
|
+ <Show when={layout.sidebar.opened()}>
|
|
|
+ <ResizeHandle
|
|
|
+ direction="horizontal"
|
|
|
+ size={layout.sidebar.width()}
|
|
|
+ min={150}
|
|
|
+ max={window.innerWidth * 0.3}
|
|
|
+ collapseThreshold={80}
|
|
|
+ onResize={layout.sidebar.resize}
|
|
|
+ onCollapse={layout.sidebar.close}
|
|
|
+ />
|
|
|
+ </Show>
|
|
|
+ <SidebarContent />
|
|
|
+ </div>
|
|
|
+ <div class="xl:hidden">
|
|
|
+ <div
|
|
|
+ classList={{
|
|
|
+ "fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
|
|
|
+ "opacity-100 pointer-events-auto": mobileSidebar.open(),
|
|
|
+ "opacity-0 pointer-events-none": !mobileSidebar.open(),
|
|
|
+ }}
|
|
|
+ onClick={(e) => {
|
|
|
+ if (e.target === e.currentTarget) mobileSidebar.hide()
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <div
|
|
|
+ classList={{
|
|
|
+ "@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
|
|
|
+ "translate-x-0": mobileSidebar.open(),
|
|
|
+ "-translate-x-full": !mobileSidebar.open(),
|
|
|
+ }}
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
+ >
|
|
|
+ <SidebarContent mobile />
|
|
|
</div>
|
|
|
</div>
|
|
|
+
|
|
|
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
|
|
|
</div>
|
|
|
<Toast.Region />
|