summaryrefslogtreecommitdiffhomepage
path: root/packages/app
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2026-03-13 14:19:02 +0000
committerGitHub <[email protected]>2026-03-13 09:19:02 -0500
commitf5f07310e026215b9f6809b196c8eddbdedd77df (patch)
tree84ffe00ed4e55b84c39bd8caafeda2141476485d /packages/app
parentc9e9dbeee1ceb5af3d1b1ce292317390286fe7a0 (diff)
downloadopencode-f5f07310e026215b9f6809b196c8eddbdedd77df.tar.gz
opencode-f5f07310e026215b9f6809b196c8eddbdedd77df.zip
fix(app): sidebar spacing + session list spinner transition (#17355)
Diffstat (limited to 'packages/app')
-rw-r--r--packages/app/src/pages/layout.tsx10
-rw-r--r--packages/app/src/pages/layout/sidebar-items.tsx143
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx4
3 files changed, 108 insertions, 49 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index bc04f9ecf..8ee5b3710 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -1961,7 +1961,7 @@ export default function Layout(props: ParentProps) {
return (
<div
classList={{
- "flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": true,
+ "flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-3": true,
"border border-b-0 border-border-weak-base": !merged(),
"border-l border-t border-border-weaker-base": merged(),
"bg-background-base": merged() || hover(),
@@ -1976,8 +1976,8 @@ export default function Layout(props: ParentProps) {
<Show when={panelProps.project}>
{(p) => (
<>
- <div class="shrink-0 px-2 py-1">
- <div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
+ <div class="shrink-0 pl-1 py-1">
+ <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
@@ -2063,7 +2063,7 @@ export default function Layout(props: ParentProps) {
when={workspacesEnabled()}
fallback={
<>
- <div class="shrink-0 py-4 px-3">
+ <div class="shrink-0 py-4">
<Button
size="large"
icon="plus-small"
@@ -2086,7 +2086,7 @@ export default function Layout(props: ParentProps) {
}
>
<>
- <div class="shrink-0 py-4 px-3">
+ <div class="shrink-0 py-4">
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index 17572923e..04d898134 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -9,7 +9,8 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router"
-import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
+import { type Accessor, createEffect, createMemo, For, type JSX, on, onCleanup, Show } from "solid-js"
+import { createStore } from "solid-js/store"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
@@ -101,46 +102,94 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => 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"}`}
- onPointerDown={props.warmPress}
- onPointerEnter={props.warmHover}
- onPointerLeave={props.cancelHoverPrefetch}
- onFocus={props.warmFocus}
- 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>
+}): JSX.Element => {
+ const [slot, setSlot] = createStore({
+ open: false,
+ show: false,
+ fade: false,
+ })
+
+ let f: number | undefined
+ const clear = () => {
+ if (f !== undefined) window.clearTimeout(f)
+ f = undefined
+ }
+
+ onCleanup(clear)
+ createEffect(
+ on(
+ () => props.isWorking(),
+ (on, prev) => {
+ clear()
+ if (on) {
+ setSlot({ open: true, show: true, fade: false })
+ return
+ }
+ if (prev) {
+ setSlot({ open: false, show: true, fade: true })
+ f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
+ return
+ }
+ setSlot({ open: false, show: false, fade: false })
+ },
+ { defer: true },
+ ),
+ )
+
+ return (
+ <A
+ href={`/${props.slug}/session/${props.session.id}`}
+ class={`relative flex items-center 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"}`}
+ onPointerDown={props.warmPress}
+ onPointerEnter={props.warmHover}
+ onPointerLeave={props.cancelHoverPrefetch}
+ onFocus={props.warmFocus}
+ onClick={() => {
+ props.setHoverSession(undefined)
+ if (props.sidebarOpened()) return
+ props.clearHoverProjectSoon()
+ }}
+ >
+ <Show when={!props.isWorking() && (props.hasPermissions() || props.hasError() || props.unseenCount() > 0)}>
+ <div
+ classList={{
+ "absolute left-0 top-1/2 -translate-y-1/2 size-1.5 rounded-full": true,
+ "bg-surface-warning-strong": props.hasPermissions(),
+ "bg-text-diff-delete-base": !props.hasPermissions() && props.hasError(),
+ "bg-text-interactive-base": !props.hasPermissions() && !props.hasError() && props.unseenCount() > 0,
+ }}
+ aria-hidden="true"
+ />
+ </Show>
+
+ <div class="flex items-center min-w-0 grow-1">
+ <div
+ class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
+ style={{
+ width: slot.open ? "16px" : "0px",
+ "margin-right": slot.open ? "8px" : "0px",
+ }}
+ aria-hidden="true"
+ >
+ <Show when={slot.show}>
+ <div
+ class="transition-opacity duration-200 ease-out"
+ classList={{
+ "opacity-0": slot.fade,
+ }}
+ >
+ <Spinner class="size-4" style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} />
+ </div>
+ </Show>
+ </div>
+
+ <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
+ {props.session.title}
+ </span>
</div>
- <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
- {props.session.title}
- </span>
- </div>
- </A>
-)
+ </A>
+ )
+}
const SessionHoverPreview = (props: {
mobile?: boolean
@@ -204,8 +253,18 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
})
const isWorking = createMemo(() => {
if (hasPermissions()) return false
+ const pending = (sessionStore.message[props.session.id] ?? []).findLast(
+ (message) =>
+ message.role === "assistant" &&
+ typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number",
+ )
const status = sessionStore.session_status[props.session.id]
- return status?.type === "busy" || status?.type === "retry"
+ return (
+ pending !== undefined ||
+ status?.type === "busy" ||
+ status?.type === "retry" ||
+ (status !== undefined && status.type !== "idle")
+ )
})
const tint = createMemo(() => {
@@ -300,7 +359,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return (
<div
data-session-id={props.session.id}
- class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
+ class="group/session relative w-full rounded-md cursor-default pl-3 pr-3 transition-colors
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<Show
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index 48c63e547..73d3d2141 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: {
loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
- <nav class="flex flex-col gap-1 px-3">
+ <nav class="flex flex-col gap-1">
<Show when={props.showNew()}>
<NewSessionItem
slug={props.slug()}
@@ -382,7 +382,7 @@ export const SortableWorkspace = (props: {
}}
>
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
- <div class="px-2 py-1">
+ <div class="py-1">
<div
class="group/workspace relative"
data-component="workspace-item"