feat(app): new session progress indicator (#32662)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
Aarav Sareen 2026-06-23 15:57:07 +05:30 committed by GitHub
parent a21e74773f
commit d24848359d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 572 additions and 97 deletions

View File

@ -22,7 +22,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
import { LayoutRoute, useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
@ -30,9 +30,8 @@ import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMediaQuery } from "@solid-primitives/media"
@ -868,7 +867,7 @@ function TabNavItem(props: {
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<span data-slot="project-avatar-slot">
<ProjectTabAvatar
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
@ -904,26 +903,6 @@ function TabNavItem(props: {
)
}
function ProjectTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function DraftTabItem(props: {
ref?: HTMLDivElement
href: string

View File

@ -38,7 +38,7 @@ import {
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global"
@ -755,21 +755,6 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
)
}
function HomeSessionAvatar(props: { project: LocalProject; session: Session; activeServer: boolean }) {
const directory = () => props.session.directory
const sessionId = () => props.session.id
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function HomeSessionLeading(props: {
project: LocalProject
session: Session
@ -787,7 +772,12 @@ function HomeSessionLeading(props: {
style={{ right: "calc(100% + 12px)" }}
/>
</Show>
<HomeSessionAvatar project={props.project} session={props.session} activeServer={props.activeServer} />
<SessionTabAvatar
project={props.project}
directory={props.session.directory}
sessionId={props.session.id}
activeServer={props.activeServer}
/>
</div>
)
}

View File

@ -0,0 +1,33 @@
import type { LocalProject } from "@/context/layout"
import { getProjectAvatarVariant } from "@/context/layout"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { SessionProgressIndicatorV2 } from "@opencode-ai/ui/v2/session-progress-indicator-v2"
import { Show } from "solid-js"
export function SessionTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<Show
when={state.loading()}
fallback={
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/>
}
>
<SessionProgressIndicatorV2 class="shrink-0" />
</Show>
)
}

View File

@ -98,29 +98,6 @@
-webkit-user-drag: none;
}
[data-slot="project-avatar-surface"] [data-slot="project-avatar-loader"] {
position: absolute;
inset: -3px;
z-index: 2;
border-radius: 0;
background: conic-gradient(
from 180deg at 50% 50%,
var(--v2-grey-100) 0deg,
var(--v2-grey-1200) 0.04deg,
var(--v2-alpha-dark-50) 90deg,
var(--v2-grey-100) 360deg
);
mix-blend-mode: soft-light;
pointer-events: none;
animation: project-avatar-v2-loader-spin 1.2s linear infinite;
}
@keyframes project-avatar-v2-loader-spin {
to {
transform: rotate(360deg);
}
}
[data-component="project-avatar-v2"][data-unread] [data-slot="project-avatar-surface"] {
-webkit-mask-image: radial-gradient(circle 4.5px at calc(100% - 1px) 1px, transparent 4.5px, black 4.5px);
mask-image: radial-gradient(circle 4.5px at calc(100% - 1px) 1px, transparent 4.5px, black 4.5px);

View File

@ -79,20 +79,3 @@ export const AllVariantsUnread = {
</div>
),
}
export const Loading = {
args: {
fallback: "O",
variant: "orange",
loading: true,
},
}
export const LoadingAndUnread = {
args: {
fallback: "O",
variant: "blue",
loading: true,
unread: true,
},
}

View File

@ -31,20 +31,10 @@ export interface ProjectAvatarProps extends ComponentProps<"div"> {
src?: string
variant?: ProjectAvatarVariant
unread?: boolean
loading?: boolean
}
export function ProjectAvatar(props: ProjectAvatarProps) {
const [split, rest] = splitProps(props, [
"fallback",
"src",
"variant",
"unread",
"loading",
"class",
"classList",
"style",
])
const [split, rest] = splitProps(props, ["fallback", "src", "variant", "unread", "class", "classList", "style"])
const src = split.src
return (
<div
@ -61,14 +51,10 @@ export function ProjectAvatar(props: ProjectAvatarProps) {
data-slot="project-avatar-surface"
data-variant={split.variant ?? "gray"}
data-has-image={src ? "" : undefined}
data-loading={split.loading ? "" : undefined}
>
<Show when={src} fallback={first(split.fallback)}>
{(value) => <img src={value()} draggable={false} data-slot="project-avatar-image" />}
</Show>
<Show when={split.loading}>
<span data-slot="project-avatar-loader" aria-hidden="true" />
</Show>
</div>
<Show when={split.unread}>
<span data-slot="project-avatar-unread-dot" aria-hidden="true" />

View File

@ -0,0 +1,425 @@
[data-component="session-progress-indicator-v2"] {
--_duration: 1200ms;
display: block;
flex-shrink: 0;
color: var(--v2-icon-icon-muted, #808080);
}
[data-component="session-progress-indicator-v2"] [data-dot] {
fill: currentColor;
opacity: 0.2;
animation-duration: var(--_duration);
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
@keyframes session-progress-indicator-v2-dot-0 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 1; }
50% { opacity: 0.5; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="0"] {
animation-name: session-progress-indicator-v2-dot-0;
}
@keyframes session-progress-indicator-v2-dot-5 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.75; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="5"] {
animation-name: session-progress-indicator-v2-dot-5;
}
@keyframes session-progress-indicator-v2-dot-10 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 1; }
37.5% { opacity: 0.5; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="10"] {
animation-name: session-progress-indicator-v2-dot-10;
}
@keyframes session-progress-indicator-v2-dot-15 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.75; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="15"] {
animation-name: session-progress-indicator-v2-dot-15;
}
@keyframes session-progress-indicator-v2-dot-20 {
0% { opacity: 0.2; }
12.5% { opacity: 1; }
25% { opacity: 0.5; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="20"] {
animation-name: session-progress-indicator-v2-dot-20;
}
@keyframes session-progress-indicator-v2-dot-1 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.75; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="1"] {
animation-name: session-progress-indicator-v2-dot-1;
}
@keyframes session-progress-indicator-v2-dot-6 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 1; }
50% { opacity: 0.5; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="6"] {
animation-name: session-progress-indicator-v2-dot-6;
}
@keyframes session-progress-indicator-v2-dot-11 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 1; }
37.5% { opacity: 0.75; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="11"] {
animation-name: session-progress-indicator-v2-dot-11;
}
@keyframes session-progress-indicator-v2-dot-16 {
0% { opacity: 0.2; }
12.5% { opacity: 1; }
25% { opacity: 0.5; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="16"] {
animation-name: session-progress-indicator-v2-dot-16;
}
@keyframes session-progress-indicator-v2-dot-21 {
0% { opacity: 0.2; }
12.5% { opacity: 0.75; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="21"] {
animation-name: session-progress-indicator-v2-dot-21;
}
@keyframes session-progress-indicator-v2-dot-2 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 1; }
62.5% { opacity: 0.5; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="2"] {
animation-name: session-progress-indicator-v2-dot-2;
}
@keyframes session-progress-indicator-v2-dot-7 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 1; }
62.5% { opacity: 0.75; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="7"] {
animation-name: session-progress-indicator-v2-dot-7;
}
@keyframes session-progress-indicator-v2-dot-12 {
0% { opacity: 1; }
12.5% { opacity: 1; }
25% { opacity: 1; }
37.5% { opacity: 1; }
50% { opacity: 1; }
62.5% { opacity: 1; }
75% { opacity: 1; }
87.5% { opacity: 1; }
100% { opacity: 1; }
}
[data-component="session-progress-indicator-v2"] [data-dot="12"] {
animation-name: session-progress-indicator-v2-dot-12;
}
@keyframes session-progress-indicator-v2-dot-17 {
0% { opacity: 1; }
12.5% { opacity: 0.75; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 1; }
}
[data-component="session-progress-indicator-v2"] [data-dot="17"] {
animation-name: session-progress-indicator-v2-dot-17;
}
@keyframes session-progress-indicator-v2-dot-22 {
0% { opacity: 1; }
12.5% { opacity: 0.5; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 1; }
}
[data-component="session-progress-indicator-v2"] [data-dot="22"] {
animation-name: session-progress-indicator-v2-dot-22;
}
@keyframes session-progress-indicator-v2-dot-3 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.75; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="3"] {
animation-name: session-progress-indicator-v2-dot-3;
}
@keyframes session-progress-indicator-v2-dot-8 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 1; }
75% { opacity: 0.75; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="8"] {
animation-name: session-progress-indicator-v2-dot-8;
}
@keyframes session-progress-indicator-v2-dot-13 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 1; }
87.5% { opacity: 1; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="13"] {
animation-name: session-progress-indicator-v2-dot-13;
}
@keyframes session-progress-indicator-v2-dot-18 {
0% { opacity: 0.5; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.75; }
100% { opacity: 0.5; }
}
[data-component="session-progress-indicator-v2"] [data-dot="18"] {
animation-name: session-progress-indicator-v2-dot-18;
}
@keyframes session-progress-indicator-v2-dot-23 {
0% { opacity: 0.75; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.2; }
100% { opacity: 0.75; }
}
[data-component="session-progress-indicator-v2"] [data-dot="23"] {
animation-name: session-progress-indicator-v2-dot-23;
}
@keyframes session-progress-indicator-v2-dot-4 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 1; }
75% { opacity: 0.5; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="4"] {
animation-name: session-progress-indicator-v2-dot-4;
}
@keyframes session-progress-indicator-v2-dot-9 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.75; }
87.5% { opacity: 0.2; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="9"] {
animation-name: session-progress-indicator-v2-dot-9;
}
@keyframes session-progress-indicator-v2-dot-14 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 1; }
87.5% { opacity: 1; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="14"] {
animation-name: session-progress-indicator-v2-dot-14;
}
@keyframes session-progress-indicator-v2-dot-19 {
0% { opacity: 0.2; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.75; }
100% { opacity: 0.2; }
}
[data-component="session-progress-indicator-v2"] [data-dot="19"] {
animation-name: session-progress-indicator-v2-dot-19;
}
@keyframes session-progress-indicator-v2-dot-24 {
0% { opacity: 0.5; }
12.5% { opacity: 0.2; }
25% { opacity: 0.2; }
37.5% { opacity: 0.2; }
50% { opacity: 0.2; }
62.5% { opacity: 0.2; }
75% { opacity: 0.2; }
87.5% { opacity: 0.5; }
100% { opacity: 0.5; }
}
[data-component="session-progress-indicator-v2"] [data-dot="24"] {
animation-name: session-progress-indicator-v2-dot-24;
}
@media (prefers-reduced-motion: reduce) {
[data-component="session-progress-indicator-v2"] [data-dot] {
animation: none;
}
[data-component="session-progress-indicator-v2"] [data-dot="12"] {
opacity: 1;
}
}

View File

@ -0,0 +1,66 @@
// @ts-nocheck
import { SessionProgressIndicatorV2 } from "./session-progress-indicator-v2"
const docs = `### Overview
Animated 5×5 dot grid loader for in-progress session state.
Derived from Figma \`_sessionProgressIndicator\` with 8-frame rotation.
### API
- Accepts standard SVG props.
### Behavior
- CSS keyframes drive per-dot opacity across 8 frames (1.2s loop).
- Center dot stays at full opacity throughout the cycle.
### Accessibility
- Sets \`aria-hidden="true"\` by default.
### Theming
- Uses \`currentColor\` via \`--v2-icon-icon-muted\`.
`
export default {
title: "UI V2/SessionProgressIndicator",
id: "components-session-progress-indicator-v2",
component: SessionProgressIndicatorV2,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => <SessionProgressIndicatorV2 />,
}
export const Sizes = {
render: () => (
<div style={{ display: "flex", gap: "16px", "align-items": "center" }}>
<SessionProgressIndicatorV2 width={12} height={12} />
<SessionProgressIndicatorV2 />
<SessionProgressIndicatorV2 width={24} height={24} />
</div>
),
}
export const OnDark = {
render: () => (
<div
style={{
display: "flex",
gap: "16px",
"align-items": "center",
padding: "16px",
"background-color": "#171717",
color: "#c7c7c7",
}}
>
<SessionProgressIndicatorV2 />
</div>
),
}

View File

@ -0,0 +1,36 @@
import { For, splitProps, type ComponentProps } from "solid-js"
import "./session-progress-indicator-v2.css"
const grid = 5
const dot = 2
const gap = 1
const origin = 1.5
const dots = Array.from({ length: grid * grid }, (_, index) => ({
index,
x: origin + (index % grid) * (dot + gap),
y: origin + Math.floor(index / grid) * (dot + gap),
}))
export function SessionProgressIndicatorV2(props: ComponentProps<"svg">) {
const [local, rest] = splitProps(props, ["class", "classList", "width", "height"])
return (
<svg
{...rest}
class={local.class}
classList={local.classList}
width={local.width ?? 16}
height={local.height ?? 16}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-component="session-progress-indicator-v2"
aria-hidden={rest["aria-hidden"] ?? "true"}
>
<For each={dots}>
{(cell) => (
<rect data-dot={cell.index} x={cell.x} y={cell.y} width={dot} height={dot} />
)}
</For>
</svg>
)
}