diff options
Diffstat (limited to 'packages/desktop')
| -rw-r--r-- | packages/desktop/index.html | 4 | ||||
| -rw-r--r-- | packages/desktop/package.json | 1 | ||||
| -rw-r--r-- | packages/desktop/src/app.tsx | 41 | ||||
| -rw-r--r-- | packages/desktop/src/components/header.tsx | 113 | ||||
| -rw-r--r-- | packages/desktop/src/context/global-sync.tsx | 78 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 24 | ||||
| -rw-r--r-- | packages/desktop/src/context/notification.tsx | 106 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 9 | ||||
| -rw-r--r-- | packages/desktop/src/pages/home.tsx | 5 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 307 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 2 |
11 files changed, 469 insertions, 221 deletions
diff --git a/packages/desktop/index.html b/packages/desktop/index.html index b9d3e5351..9803517a0 100644 --- a/packages/desktop/index.html +++ b/packages/desktop/index.html @@ -14,7 +14,7 @@ <meta property="og:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" /> </head> - <body class="antialiased overscroll-none select-none text-12-regular"> + <body class="antialiased overscroll-none select-none text-12-regular overflow-hidden"> <script> ;(function () { const savedTheme = localStorage.getItem("theme") || "oc-1" @@ -22,7 +22,7 @@ })() </script> <noscript>You need to enable JavaScript to run this app.</noscript> - <div id="root"></div> + <div id="root" class="flex flex-col h-screen"></div> <script src="/src/entry.tsx" type="module"></script> </body> </html> diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1d12a9cb9..a2b995a4a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -35,6 +35,7 @@ "@opencode-ai/util": "workspace:*", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", + "@solid-primitives/audio": "1.4.2", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index a1ff90d26..bf9dfd3b7 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -14,6 +14,7 @@ import { LayoutProvider } from "./context/layout" import { GlobalSDKProvider } from "./context/global-sdk" import { SessionProvider } from "./context/session" import { Show } from "solid-js" +import { NotificationProvider } from "./context/notification" declare global { interface Window { @@ -37,25 +38,27 @@ export function App() { <GlobalSDKProvider url={url}> <GlobalSyncProvider> <LayoutProvider> - <MetaProvider> - <Font /> - <Router root={Layout}> - <Route path="/" component={Home} /> - <Route path="/:dir" component={DirectoryLayout}> - <Route path="/" component={() => <Navigate href="session" />} /> - <Route - path="/session/:id?" - component={(p) => ( - <Show when={p.params.id || true} keyed> - <SessionProvider> - <Session /> - </SessionProvider> - </Show> - )} - /> - </Route> - </Router> - </MetaProvider> + <NotificationProvider> + <MetaProvider> + <Font /> + <Router root={Layout}> + <Route path="/" component={Home} /> + <Route path="/:dir" component={DirectoryLayout}> + <Route path="/" component={() => <Navigate href="session" />} /> + <Route + path="/session/:id?" + component={(p) => ( + <Show when={p.params.id || true} keyed> + <SessionProvider> + <Session /> + </SessionProvider> + </Show> + )} + /> + </Route> + </Router> + </MetaProvider> + </NotificationProvider> </LayoutProvider> </GlobalSyncProvider> </GlobalSDKProvider> diff --git a/packages/desktop/src/components/header.tsx b/packages/desktop/src/components/header.tsx new file mode 100644 index 000000000..cc4d01816 --- /dev/null +++ b/packages/desktop/src/components/header.tsx @@ -0,0 +1,113 @@ +import { useGlobalSync } from "@/context/global-sync" +import { useLayout } from "@/context/layout" +import { Session } from "@opencode-ai/sdk/v2/client" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { Mark } from "@opencode-ai/ui/logo" +import { Select } from "@opencode-ai/ui/select" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { base64Decode } from "@opencode-ai/util/encode" +import { getFilename } from "@opencode-ai/util/path" +import { A, useParams } from "@solidjs/router" +import { createMemo, Show } from "solid-js" + +export function Header(props: { + navigateToProject: (directory: string) => void + navigateToSession: (session: Session | undefined) => void +}) { + const globalSync = useGlobalSync() + const layout = useLayout() + const params = useParams() + const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const store = createMemo(() => globalSync.child(currentDirectory())[0]) + const sessions = createMemo(() => store().session ?? []) + const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) + + return ( + <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region> + <A + href="/" + classList={{ + "w-12 shrink-0 px-4 py-3.5": true, + "flex items-center justify-start self-stretch": true, + "border-r border-border-weak-base": true, + }} + style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }} + data-tauri-drag-region + > + <Mark class="shrink-0" /> + </A> + <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full"> + <Show when={params.dir && layout.projects.list().length > 0}> + <div class="flex items-center gap-3"> + <div class="flex items-center gap-2"> + <Select + options={layout.projects.list().map((project) => project.worktree)} + current={currentDirectory()} + label={(x) => getFilename(x)} + onSelect={(x) => (x ? props.navigateToProject(x) : undefined)} + class="text-14-regular text-text-base" + variant="ghost" + > + {/* @ts-ignore */} + {(i) => ( + <div class="flex items-center gap-2"> + <Icon name="folder" size="small" /> + <div class="text-text-strong">{getFilename(i)}</div> + </div> + )} + </Select> + <div class="text-text-weaker">/</div> + <Select + options={sessions()} + current={currentSession()} + placeholder="New session" + label={(x) => x.title} + value={(x) => x.id} + onSelect={props.navigateToSession} + class="text-14-regular text-text-base max-w-md" + variant="ghost" + /> + </div> + <Show when={currentSession()}> + <Button as={A} href={`/${params.dir}/session`} icon="plus-small"> + New session + </Button> + </Show> + </div> + <div class="flex items-center gap-4"> + <Tooltip + class="shrink-0" + value={ + <div class="flex items-center gap-2"> + <span>Toggle terminal</span> + <span class="text-icon-base text-12-medium">Ctrl `</span> + </div> + } + > + <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}> + <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> + <Icon + size="small" + name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"} + class="group-hover/terminal-toggle:hidden" + /> + <Icon + size="small" + name="layout-bottom-partial" + class="hidden group-hover/terminal-toggle:inline-block" + /> + <Icon + size="small" + name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"} + class="hidden group-active/terminal-toggle:inline-block" + /> + </div> + </Button> + </Tooltip> + </div> + </Show> + </div> + </header> + ) +} diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 2a24a845c..8151a2c6f 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -55,45 +55,20 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple const globalSDK = useGlobalSDK() const [globalStore, setGlobalStore] = createStore<{ ready: boolean + path: Path project: Project[] provider: ProviderListResponse provider_auth: ProviderAuthResponse children: Record<string, State> }>({ ready: false, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, project: [], provider: { all: [], connected: [], default: {} }, provider_auth: {}, children: {}, }) - async function bootstrapInstance(directory: string) { - const [store, setStore] = child(directory) - const sdk = createOpencodeClient({ - baseUrl: globalSDK.url, - directory, - }) - const load = { - project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)), - provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), - path: () => sdk.path.get().then((x) => setStore("path", x.data!)), - agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), - session: () => - sdk.session.list().then((x) => { - const sessions = (x.data ?? []) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, store.limit) - setStore("session", sessions) - }), - status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), - config: () => sdk.config.get().then((x) => setStore("config", x.data!)), - changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), - node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)), - } - await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) - } - const children: Record<string, ReturnType<typeof createStore<State>>> = {} function child(directory: string) { if (!children[directory]) { @@ -120,6 +95,38 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple return children[directory] } + async function loadSessions(directory: string) { + globalSDK.client.session.list({ directory }).then((x) => { + const sessions = (x.data ?? []) + .slice() + .filter((s) => !s.time.archived) + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, 5) + const [, setStore] = child(directory) + setStore("session", sessions) + }) + } + + async function bootstrapInstance(directory: string) { + const [, setStore] = child(directory) + const sdk = createOpencodeClient({ + baseUrl: globalSDK.url, + directory, + }) + const load = { + project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)), + provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), + path: () => sdk.path.get().then((x) => setStore("path", x.data!)), + agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + session: () => loadSessions(directory), + status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), + config: () => sdk.config.get().then((x) => setStore("config", x.data!)), + changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), + node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)), + } + await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) + } + globalSDK.event.listen((e) => { const directory = e.name const event = e.details @@ -156,6 +163,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple } case "session.updated": { const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) + if (event.properties.info.time.archived) { + if (result.found) { + setStore( + "session", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } if (result.found) { setStore("session", result.index, reconcile(event.properties.info)) break @@ -224,6 +242,9 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple async function bootstrap() { return Promise.all([ + globalSDK.client.path.get().then((x) => { + setGlobalStore("path", x.data!) + }), globalSDK.client.project.list().then(async (x) => { setGlobalStore( "project", @@ -252,6 +273,9 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple }, child, bootstrap, + project: { + loadSessions, + }, } }, }) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 9cafdce96..3d5cad761 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -7,15 +7,10 @@ import { useGlobalSDK } from "./global-sdk" import { Project } from "@opencode-ai/sdk/v2" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const - export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] -export function isAvatarColorKey(value: string): value is AvatarColorKey { - return AVATAR_COLOR_KEYS.includes(value as AvatarColorKey) -} - export function getAvatarColors(key?: string) { - if (key && isAvatarColorKey(key)) { + if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) { return { background: `var(--avatar-background-${key})`, foreground: `var(--avatar-text-${key})`, @@ -50,7 +45,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }), { - name: "default-layout.v7", + name: "layout.v1", }, ) const [ephemeral, setEphemeral] = createStore<{ @@ -97,21 +92,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const enriched = createMemo(() => store.projects.flatMap(enrich)) const list = createMemo(() => enriched().flatMap(colorize)) - async function loadProjectSessions(directory: string) { - const [, setStore] = globalSync.child(directory) - globalSdk.client.session.list({ directory }).then((x) => { - const sessions = (x.data ?? []) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, 5) - setStore("session", sessions) - }) - } - onMount(() => { Promise.all( store.projects.map((project) => { - return loadProjectSessions(project.worktree) + return globalSync.project.loadSessions(project.worktree) }), ) }) @@ -121,7 +105,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( list, open(directory: string) { if (store.projects.find((x) => x.worktree === directory)) return - loadProjectSessions(directory) + globalSync.project.loadSessions(directory) setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x]) }, close(directory: string) { diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx new file mode 100644 index 000000000..744e4fdf3 --- /dev/null +++ b/packages/desktop/src/context/notification.tsx @@ -0,0 +1,106 @@ +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { makePersisted } from "@solid-primitives/storage" +import { useGlobalSDK } from "./global-sdk" +import { EventSessionError } from "@opencode-ai/sdk/v2" +import { makeAudioPlayer } from "@solid-primitives/audio" +import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" + +type NotificationBase = { + directory?: string + session?: string + metadata?: any + time: number + viewed: boolean +} + +type TurnCompleteNotification = NotificationBase & { + type: "turn-complete" +} + +type ErrorNotification = NotificationBase & { + type: "error" + error: EventSessionError["properties"]["error"] +} + +export type Notification = TurnCompleteNotification | ErrorNotification + +export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ + name: "Notification", + init: () => { + const idlePlayer = makeAudioPlayer(idleSound) + const globalSDK = useGlobalSDK() + + const [store, setStore] = makePersisted( + createStore({ + list: [] as Notification[], + }), + { + name: "notification.v1", + }, + ) + + // onMount(() => { + // const daysToKeep = 7 + // // setStore("list", (n) => n.filter((n) => !n.viewed && n.time + 1000 * 60 * 60 * 24 * daysToKeep < Date.now())) + // }) + + globalSDK.event.listen((e) => { + const directory = e.name + const event = e.details + const base = { + directory, + time: Date.now(), + viewed: false, + } + switch (event.type) { + case "session.idle": { + idlePlayer.play() + const session = event.properties.sessionID + setStore("list", store.list.length, { + ...base, + type: "turn-complete", + session, + }) + break + } + case "session.error": { + const session = event.properties.sessionID ?? "global" + // errorPlayer.play() + setStore("list", store.list.length, { + ...base, + type: "error", + session, + error: "error" in event.properties ? event.properties.error : undefined, + }) + break + } + } + }) + + return { + session: { + all(session: string) { + return store.list.filter((n) => n.session === session) + }, + unseen(session: string) { + return store.list.filter((n) => n.session === session && !n.viewed) + }, + markViewed(session: string) { + setStore("list", (n) => n.session === session, "viewed", true) + }, + }, + project: { + all(directory: string) { + return store.list.filter((n) => n.directory === directory) + }, + unseen(directory: string) { + return store.list.filter((n) => n.directory === directory && !n.viewed) + }, + markViewed(directory: string) { + setStore("list", (n) => n.directory === directory, "viewed", true) + }, + }, + } + }, +}) diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 85758c5b6..2ab54b3ae 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -65,6 +65,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }, more: createMemo(() => store.session.length >= store.limit), + archive: async (sessionID: string) => { + await sdk.client.session.update({ sessionID, time: { archived: Date.now() } }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + }, }, absolute, get directory() { diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx index 205ffd815..7cd2916e8 100644 --- a/packages/desktop/src/pages/home.tsx +++ b/packages/desktop/src/pages/home.tsx @@ -1,5 +1,5 @@ import { useGlobalSync } from "@/context/global-sync" -import { For, Match, Show, Switch } from "solid-js" +import { createMemo, For, Match, Show, Switch } from "solid-js" import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" import { useLayout } from "@/context/layout" @@ -14,6 +14,7 @@ export default function Home() { const layout = useLayout() const platform = usePlatform() const navigate = useNavigate() + const homedir = createMemo(() => sync.data.path.home) function openProject(directory: string) { layout.projects.open(directory) @@ -61,7 +62,7 @@ export default function Home() { class="text-14-mono text-left justify-between px-3" onClick={() => openProject(project.worktree)} > - {project.worktree} + {project.worktree.replace(homedir(), "~")} <div class="text-14-regular text-text-weak"> {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()} </div> diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index b997296fa..7da920c5f 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,10 +1,21 @@ -import { createEffect, createMemo, For, Match, onCleanup, onMount, ParentProps, Show, Switch, type JSX } from "solid-js" +import { + createEffect, + createMemo, + createSignal, + For, + Match, + onCleanup, + onMount, + ParentProps, + Show, + Switch, + type JSX, +} from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { base64Decode, base64Encode } from "@opencode-ai/util/encode" -import { Mark } from "@opencode-ai/ui/logo" import { Avatar } from "@opencode-ai/ui/avatar" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" @@ -15,7 +26,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" -import { Select } from "@opencode-ai/ui/select" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" @@ -42,6 +52,9 @@ import { TextField } from "@opencode-ai/ui/text-field" import { showToast, Toast } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { Spinner } from "@opencode-ai/ui/spinner" +import { useNotification } from "@/context/notification" +import { Binary } from "@opencode-ai/util/binary" +import { Header } from "@/components/header" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -54,10 +67,8 @@ export default function Layout(props: ParentProps) { const globalSync = useGlobalSync() const layout = useLayout() const platform = usePlatform() + const notification = useNotification() const navigate = useNavigate() - const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) - const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) - const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) const providers = useProviders() function navigateToProject(directory: string | undefined) { @@ -77,9 +88,11 @@ export default function Layout(props: ParentProps) { } function closeProject(directory: string) { + const index = layout.projects.list().findIndex((x) => x.worktree === directory) + const next = layout.projects.list()[index + 1] layout.projects.close(directory) - // TODO: more intelligent navigation - navigate("/") + if (next) navigateToProject(next.worktree) + else navigate("/") } async function chooseProject() { @@ -105,6 +118,7 @@ export default function Layout(props: ParentProps) { if (!params.dir || !params.id) return const directory = base64Decode(params.dir) setStore("lastSession", directory, params.id) + notification.session.markViewed(params.id) }) createEffect(() => { @@ -164,8 +178,51 @@ export default function Layout(props: ParentProps) { return <></> } + const ProjectAvatar = (props: { + project: Project + class?: string + expandable?: boolean + notify?: boolean + }): JSX.Element => { + 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 mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" + return ( + <div class="relative size-6 shrink-0"> + <Avatar + fallback={name()} + src={props.project.icon?.url} + {...getAvatarColors(props.project.icon?.color)} + class={`size-full ${props.class ?? ""}`} + style={ + notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined + } + /> + <Show when={props.expandable}> + <Icon + name="chevron-right" + size="large" + class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50" + /> + </Show> + <Show when={notifications().length > 0 && props.notify}> + <div + classList={{ + "absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true, + "bg-icon-critical-base": hasError(), + "bg-text-interactive-base": !hasError(), + }} + /> + </Show> + </div> + ) + } + const ProjectVisual = (props: { project: Project & { expanded: boolean }; class?: string }): JSX.Element => { const name = createMemo(() => getFilename(props.project.worktree)) + const current = createMemo(() => base64Decode(params.dir ?? "")) return ( <Switch> <Match when={layout.sidebar.opened()}> @@ -176,14 +233,7 @@ export default function Layout(props: ParentProps) { class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg" > <div class="flex items-center gap-3 p-0 text-left min-w-0 grow"> - <div class="size-6 shrink-0"> - <Avatar - fallback={name()} - src={props.project.icon?.url} - {...getAvatarColors(props.project.icon?.color)} - class="size-full" - /> - </div> + <ProjectAvatar project={props.project} /> <span class="truncate text-14-medium text-text-strong">{name()}</span> </div> </Button> @@ -193,17 +243,10 @@ export default function Layout(props: ParentProps) { variant="ghost" size="large" class="flex items-center justify-center p-0 aspect-square border-none rounded-lg" - data-selected={props.project.worktree === currentDirectory()} + data-selected={props.project.worktree === current()} onClick={() => navigateToProject(props.project.worktree)} > - <div class="size-6 shrink-0"> - <Avatar - fallback={name()} - src={props.project.icon?.url} - {...getAvatarColors(props.project.icon?.color)} - class="size-full" - /> - </div> + <ProjectAvatar project={props.project} notify /> </Button> </Match> </Switch> @@ -211,35 +254,31 @@ export default function Layout(props: ParentProps) { } const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { + const notification = useNotification() const sortable = createSortable(props.project.worktree) - const [projectStore] = globalSync.child(props.project.worktree) const slug = createMemo(() => base64Encode(props.project.worktree)) const name = createMemo(() => getFilename(props.project.worktree)) + const [store, setStore] = globalSync.child(props.project.worktree) + const sessions = createMemo(() => store.session ?? []) + const [expanded, setExpanded] = createSignal(true) return ( // @ts-ignore <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> <Switch> <Match when={layout.sidebar.opened()}> - <Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0"> + <Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0" onOpenChange={setExpanded}> <Button as={"div"} variant="ghost" class="group/session flex items-center justify-between gap-3 w-full px-1 self-stretch h-auto border-none rounded-lg" > <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none"> - <div class="size-6 shrink-0"> - <Avatar - fallback={name()} - src={props.project.icon?.url} - {...getAvatarColors(props.project.icon?.color)} - class="size-full group-hover/session:hidden" - /> - <Icon - name="chevron-right" - size="large" - class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50" - /> - </div> + <ProjectAvatar + project={props.project} + class="group-hover/session:hidden" + expandable + notify={!expanded()} + /> <span class="truncate text-14-medium text-text-strong">{name()}</span> </Collapsible.Trigger> <div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible"> @@ -260,50 +299,102 @@ export default function Layout(props: ParentProps) { </Button> <Collapsible.Content> <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5"> - <For each={projectStore.session}> + <For each={sessions()}> {(session) => { const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) + const notifications = createMemo(() => notification.session.unseen(session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + async function archive(session: Session) { + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + time: { archived: Date.now() }, + }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, session.id, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + } return ( <A - data-active={session.id === params.id} href={`${slug()}/session/${session.id}`} class="group/session focus:outline-none cursor-default" > <Tooltip placement="right" value={session.title}> <div - class="w-full pl-4 pr-2 py-1 rounded-md - group-data-[active=true]/session:bg-surface-raised-base-hover - group-hover/session:bg-surface-raised-base-hover - group-focus/session:bg-surface-raised-base-hover" + class="relative w-full pl-4 pr-1 py-1 rounded-md + group-[.active]/session:bg-surface-raised-base-hover + group-hover/session:bg-surface-raised-base-hover + group-focus/session:bg-surface-raised-base-hover" > <div class="flex items-center self-stretch gap-6 justify-between"> <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> {session.title} </span> - <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> - {Math.abs(updated().diffNow().as("seconds")) < 60 - ? "Now" - : updated() - .toRelative({ - style: "short", - unit: ["days", "hours", "minutes"], - }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} - </span> - </div> - <div class="hidden _flex justify-between items-center self-stretch"> - <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span> - <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> + <div class="shrink-0 group-hover/session:hidden mr-1"> + <Switch> + <Match when={hasError()}> + <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> + </Match> + <Match when={notifications().length > 0}> + <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" /> + </Match> + <Match when={true}> + <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> + {Math.abs(updated().diffNow().as("seconds")) < 60 + ? "Now" + : updated() + .toRelative({ + style: "short", + unit: ["days", "hours", "minutes"], + }) + ?.replace(" ago", "") + ?.replace(/ days?/, "d") + ?.replace(" min.", "m") + ?.replace(" hr.", "h")} + </span> + </Match> + </Switch> + </div> + <div class="hidden group-hover/session:flex group-active/session:flex text-text-base gap-1"> + {/* <IconButton icon="dot-grid" variant="ghost" /> */} + <Tooltip placement="right" value="Archive session"> + <IconButton icon="archive" variant="ghost" onClick={() => archive(session)} /> + </Tooltip> + </div> </div> + <Show when={session.summary?.files}> + <div class="flex justify-between items-center self-stretch"> + <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span> + <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> + </div> + </Show> </div> </Tooltip> </A> ) }} </For> + <Show when={sessions().length === 0}> + <A href={`${slug()}/session`} class="group/session focus:outline-none cursor-default"> + <Tooltip placement="right" value="New session"> + <div + class="relative w-full pl-4 pr-1 py-1 rounded-md + group-[.active]/session:bg-surface-raised-base-hover + group-hover/session:bg-surface-raised-base-hover + group-focus/session:bg-surface-raised-base-hover" + > + <div class="flex items-center self-stretch gap-6 justify-between"> + <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> + New session + </span> + </div> + </div> + </Tooltip> + </A> + </Show> </nav> </Collapsible.Content> </Collapsible> @@ -332,93 +423,9 @@ export default function Layout(props: ParentProps) { } return ( - <div class="relative h-screen flex flex-col"> - <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region> - <A - href="/" - classList={{ - "w-12 shrink-0 px-4 py-3.5": true, - "flex items-center justify-start self-stretch": true, - "border-r border-border-weak-base": true, - }} - style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }} - data-tauri-drag-region - > - <Mark class="shrink-0" /> - </A> - <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full"> - <Show when={params.dir && layout.projects.list().length > 0}> - <div class="flex items-center gap-3"> - <div class="flex items-center gap-2"> - <Select - options={layout.projects.list().map((project) => project.worktree)} - current={currentDirectory()} - label={(x) => getFilename(x)} - onSelect={(x) => (x ? navigateToProject(x) : undefined)} - class="text-14-regular text-text-base" - variant="ghost" - > - {/* @ts-ignore */} - {(i) => ( - <div class="flex items-center gap-2"> - <Icon name="folder" size="small" /> - <div class="text-text-strong">{getFilename(i)}</div> - </div> - )} - </Select> - <div class="text-text-weaker">/</div> - <Select - options={sessions()} - current={currentSession()} - placeholder="New session" - label={(x) => x.title} - value={(x) => x.id} - onSelect={navigateToSession} - class="text-14-regular text-text-base max-w-md" - variant="ghost" - /> - </div> - <Show when={currentSession()}> - <Button as={A} href={`/${params.dir}/session`} icon="plus-small"> - New session - </Button> - </Show> - </div> - <div class="flex items-center gap-4"> - <Tooltip - class="shrink-0" - value={ - <div class="flex items-center gap-2"> - <span>Toggle terminal</span> - <span class="text-icon-base text-12-medium">Ctrl `</span> - </div> - } - > - <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}> - <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - size="small" - name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"} - class="group-hover/terminal-toggle:hidden" - /> - <Icon - size="small" - name="layout-bottom-partial" - class="hidden group-hover/terminal-toggle:inline-block" - /> - <Icon - size="small" - name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"} - class="hidden group-active/terminal-toggle:inline-block" - /> - </div> - </Button> - </Tooltip> - </div> - </Show> - </div> - </header> - <div class="h-[calc(100vh-3rem)] flex"> + <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, diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 5dae4ce55..8ea9f87e1 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -675,7 +675,7 @@ export default function Page() { <For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For> </SortableProvider> <div class="h-full flex items-center justify-center"> - <Tooltip value="Open file" class="flex items-center"> + <Tooltip value="New Terminal" class="flex items-center"> <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} /> </Tooltip> </div> |
