Browse Source

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

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Aarav Sareen 2 months ago
parent
commit
d24848359d

+ 4 - 25
packages/app/src/components/titlebar.tsx

@@ -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

+ 7 - 17
packages/app/src/pages/home.tsx

@@ -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>
   )
 }

+ 33 - 0
packages/app/src/pages/layout/session-tab-avatar.tsx

@@ -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>
+  )
+}

+ 0 - 23
packages/ui/src/v2/components/project-avatar-v2.css

@@ -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);

+ 0 - 17
packages/ui/src/v2/components/project-avatar-v2.stories.tsx

@@ -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,
-  },
-}

+ 1 - 15
packages/ui/src/v2/components/project-avatar-v2.tsx

@@ -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" />

+ 425 - 0
packages/ui/src/v2/components/session-progress-indicator-v2.css

@@ -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;
+  }
+}

+ 66 - 0
packages/ui/src/v2/components/session-progress-indicator-v2.stories.tsx

@@ -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>
+  ),
+}

+ 36 - 0
packages/ui/src/v2/components/session-progress-indicator-v2.tsx

@@ -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>
+  )
+}