fix(app): sidebar spacing + session list spinner transition (#17355)

This commit is contained in:
David Hill
2026-03-13 14:19:02 +00:00
committed by GitHub
parent c9e9dbeee1
commit f5f07310e0
3 changed files with 108 additions and 49 deletions

View File

@@ -1961,7 +1961,7 @@ export default function Layout(props: ParentProps) {
return ( return (
<div <div
classList={{ 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 border-b-0 border-border-weak-base": !merged(),
"border-l border-t border-border-weaker-base": merged(), "border-l border-t border-border-weaker-base": merged(),
"bg-background-base": merged() || hover(), "bg-background-base": merged() || hover(),
@@ -1976,8 +1976,8 @@ export default function Layout(props: ParentProps) {
<Show when={panelProps.project}> <Show when={panelProps.project}>
{(p) => ( {(p) => (
<> <>
<div class="shrink-0 px-2 py-1"> <div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 p-2 pr-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"> <div class="flex flex-col min-w-0">
<InlineEditor <InlineEditor
id={`project:${projectId()}`} id={`project:${projectId()}`}
@@ -2063,7 +2063,7 @@ export default function Layout(props: ParentProps) {
when={workspacesEnabled()} when={workspacesEnabled()}
fallback={ fallback={
<> <>
<div class="shrink-0 py-4 px-3"> <div class="shrink-0 py-4">
<Button <Button
size="large" size="large"
icon="plus-small" 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())}> <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")} {language.t("workspace.new")}
</Button> </Button>

View File

@@ -9,7 +9,8 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router" 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 { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
@@ -101,46 +102,94 @@ const SessionRow = (props: {
warmPress: () => void warmPress: () => void
warmFocus: () => void warmFocus: () => void
cancelHoverPrefetch: () => void cancelHoverPrefetch: () => void
}): JSX.Element => ( }): JSX.Element => {
<A const [slot, setSlot] = createStore({
href={`/${props.slug}/session/${props.session.id}`} open: false,
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"}`} show: false,
onPointerDown={props.warmPress} fade: false,
onPointerEnter={props.warmHover} })
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus} let f: number | undefined
onClick={() => { const clear = () => {
props.setHoverSession(undefined) if (f !== undefined) window.clearTimeout(f)
if (props.sidebarOpened()) return f = undefined
props.clearHoverProjectSoon() }
}}
> onCleanup(clear)
<div class="flex items-center gap-1 w-full"> createEffect(
<div on(
class="shrink-0 size-6 flex items-center justify-center" () => props.isWorking(),
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} (on, prev) => {
> clear()
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> if (on) {
<Match when={props.isWorking()}> setSlot({ open: true, show: true, fade: false })
<Spinner class="size-[15px]" /> return
</Match> }
<Match when={props.hasPermissions()}> if (prev) {
<div class="size-1.5 rounded-full bg-surface-warning-strong" /> setSlot({ open: false, show: true, fade: true })
</Match> f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
<Match when={props.hasError()}> return
<div class="size-1.5 rounded-full bg-text-diff-delete-base" /> }
</Match> setSlot({ open: false, show: false, fade: false })
<Match when={props.unseenCount() > 0}> },
<div class="size-1.5 rounded-full bg-text-interactive-base" /> { defer: true },
</Match> ),
</Switch> )
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> </div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> </A>
{props.session.title} )
</span> }
</div>
</A>
)
const SessionHoverPreview = (props: { const SessionHoverPreview = (props: {
mobile?: boolean mobile?: boolean
@@ -204,8 +253,18 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
}) })
const isWorking = createMemo(() => { const isWorking = createMemo(() => {
if (hasPermissions()) return false 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] 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(() => { const tint = createMemo(() => {
@@ -300,7 +359,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return ( return (
<div <div
data-session-id={props.session.id} 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" 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 <Show

View File

@@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: {
loadMore: () => Promise<void> loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}): JSX.Element => ( }): JSX.Element => (
<nav class="flex flex-col gap-1 px-3"> <nav class="flex flex-col gap-1">
<Show when={props.showNew()}> <Show when={props.showNew()}>
<NewSessionItem <NewSessionItem
slug={props.slug()} slug={props.slug()}
@@ -382,7 +382,7 @@ export const SortableWorkspace = (props: {
}} }}
> >
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}> <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
<div class="px-2 py-1"> <div class="py-1">
<div <div
class="group/workspace relative" class="group/workspace relative"
data-component="workspace-item" data-component="workspace-item"