diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss index 88c77d94..91d4727a 100644 --- a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss @@ -271,12 +271,12 @@ $content-max: 1480px; font-weight: $font-weight-medium; &--primary { - color: var(--color-accent-500); + color: var(--color-text-primary); background: color-mix(in srgb, var(--color-accent-500) 10%, var(--element-bg-soft)); &:hover:not(:disabled) { background: color-mix(in srgb, var(--color-accent-500) 16%, var(--element-bg-medium)); - color: var(--color-accent-400); + color: var(--color-text-primary); } } } diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 82db06e6..60b789ad 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -13,7 +13,7 @@ import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Plus, FolderOpen, FolderPlus, History, Check, BotMessageSquare, Users, Puzzle, Blocks, ChevronDown, Search } from 'lucide-react'; +import { Plus, FolderOpen, FolderPlus, History, Check, User, Users, Puzzle, Blocks, ChevronDown, Search } from 'lucide-react'; import { Tooltip } from '@/component-library'; import { useApp } from '../../hooks/useApp'; import { useSceneManager } from '../../hooks/useSceneManager'; @@ -438,7 +438,7 @@ const MainNav: React.FC = ({ aria-label={assistantTooltip} > {t('nav.items.persona')} diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss index c2a03d64..d6954e2f 100644 --- a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss @@ -71,8 +71,16 @@ } } - .search__input { + &.search .search__input { font-size: $font-size-sm; + + &::placeholder { + color: var(--color-text-disabled); + } + + &:focus::placeholder { + color: color-mix(in srgb, var(--color-text-disabled) 72%, transparent); + } } } @@ -223,6 +231,11 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + color: var(--color-text-disabled); + + .bitfun-nav-panel__search-trigger:hover & { + color: var(--color-text-secondary); + } } .bitfun-nav-panel__search-trigger__kbd { diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx index 75d7966f..04d8619f 100644 --- a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { FolderOpen, Bot, MessageSquare } from 'lucide-react'; +import { FolderOpen, User, MessageSquare } from 'lucide-react'; import { Search } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; @@ -239,7 +239,7 @@ const NavSearchDialog: React.FC = ({ open, onClose }) => { ) : ( <> {renderGroup(t('nav.search.groupWorkspaces'), workspaceItems, () => )} - {renderGroup(t('nav.search.groupAssistants'), assistantItems, () => )} + {renderGroup(t('nav.search.groupAssistants'), assistantItems, () => )} {renderGroup(t('nav.search.groupSessions'), sessionItems, () => )} )} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 9d90526c..4cbd5a69 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -96,13 +96,13 @@ &.is-child { min-height: 24px; font-size: 12px; - padding-left: calc(#{$size-gap-1} + 8px); + padding-left: calc(#{$size-gap-1} + 14px); position: relative; &::before { content: ''; position: absolute; - left: 6px; + left: 8px; top: 0; width: 1px; height: 50%; @@ -113,9 +113,9 @@ &::after { content: ''; position: absolute; - left: 6px; + left: 8px; top: 50%; - width: 6px; + width: 10px; height: 1px; background: color-mix(in srgb, var(--border-subtle) 92%, transparent); opacity: 0.95; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 2f44ab35..7545bdb2 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Pencil, Trash2, Check, X, Bot, Code2, Users, MoreHorizontal, Loader2 } from 'lucide-react'; +import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2 } from 'lucide-react'; import { IconButton, Input, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; @@ -380,9 +380,11 @@ const SessionsSection: React.FC = ({ ); const SessionIcon = sessionModeKey === 'cowork' - ? Users + ? ClipboardList : sessionModeKey === 'claw' - ? Bot + ? showAssistantInTooltip + ? Panda + : Bot : Code2; const isRunning = runningSessionIds.has(session.sessionId); const isRowActive = activeBtwSessionData?.childSessionId @@ -401,10 +403,10 @@ const SessionsSection: React.FC = ({ .join(' ')} onClick={() => handleSwitch(session.sessionId)} > - {showSessionModeIcon ? ( + {showSessionModeIcon && !isBtwChild ? ( isRunning ? ( = ({ /> ) : ( :first-child { - width: 260px; - } - } + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} - &__form-card { - border: 1px solid var(--border-subtle); - border-radius: $size-radius-lg; - background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); +// ══════════════════════════════════════════════════════════════════════════════ +// Two-column split +// ══════════════════════════════════════════════════════════════════════════════ + +.skills-split { + flex: 1; + min-height: 0; + display: flex; + overflow: hidden; + padding: 0 $skills-gutter; + gap: $skills-column-gap; + + // Left market block height (section + 2×card grid + pagination) — right frame = 2× this + --skills-market-card-h: clamp(140px, 13vh, 170px); + --skills-left-body-h: calc( + var(--skills-market-card-h) * 2 + + #{$size-gap-3} + + 52px + + #{$size-gap-4} + #{$size-gap-2} + + #{$size-gap-5} + 34px + #{$size-gap-3} + ); + + // ── LEFT column: search header + market, vertically centered ────────────── + &__left { + flex: 0 0 58%; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; } - &__path-input { + // ── RIGHT column: installed skills, vertically centered ─────────────────── + &__right { + flex: 1; + min-width: 0; display: flex; - align-items: flex-end; - gap: $size-gap-2; + flex-direction: column; + justify-content: center; + align-items: stretch; + overflow: hidden; + } +} - > :first-child { - flex: 1; - min-width: 0; - } +// Bordered panel: height = 2 × left market body block +.skills-split__right-frame { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + box-sizing: border-box; + height: calc(var(--skills-left-body-h) * 2); + max-height: calc(var(--skills-left-body-h) * 2); + border: 1px solid var(--border-medium); + border-radius: $size-radius-xl; + padding: $size-gap-3 $size-gap-4 $size-gap-2; + overflow: hidden; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// LEFT: sticky header +// ══════════════════════════════════════════════════════════════════════════════ + +.skills-split__left-header { + flex-shrink: 0; + padding: 0 0 $size-gap-5; + display: flex; + flex-direction: column; + gap: $size-gap-4; +} + +.skills-split__left-title-row { + display: flex; + align-items: flex-end; + gap: $size-gap-3; +} + +.skills-split__left-identity { + flex: 1; + min-width: 0; + text-align: center; +} + +.skills-split__title { + margin: 0 0 $size-gap-1; + font-size: clamp(28px, 3vw, 34px); + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + letter-spacing: -0.03em; + line-height: 1.12; +} + +.skills-split__subtitle { + margin: 0; + font-size: $font-size-xs; + color: var(--color-text-muted); + line-height: $line-height-relaxed; +} + +// ── Toolbar: component-library Search (capsule, scoped sizing) ───────────── + +.skills-split__toolbar { + display: flex; + align-items: center; + justify-content: center; +} + +.skills-split__search.search { + width: 100%; + max-width: min(600px, 100%); + margin-inline: auto; + + .search__wrapper { + border-radius: $size-radius-full; + min-height: 48px; + padding-inline: $size-gap-4; + padding-block: 10px; + background: color-mix(in srgb, var(--element-bg-soft) 92%, transparent); + border-color: var(--border-medium); + transition: + border-color $motion-fast $easing-standard, + background $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard; } - &__path-hint, - &__form-hint, - &__validating { - font-size: $font-size-xs; - color: var(--color-text-muted); + &.search--hovered:not(.search--disabled) .search__wrapper { + border-color: var(--border-strong, var(--border-medium)); + background: var(--element-bg-soft); } - &__validation { - padding: $size-gap-2 $size-gap-3; - border-radius: $size-radius-base; - font-size: $font-size-sm; + &.search--focused:not(.search--disabled) .search__wrapper { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent-500) 12%, transparent); + } +} - &.is-valid { - background: rgba($color-success, 0.08); - border: 1px solid rgba($color-success, 0.3); - color: var(--color-success); - } +.skills-split__add-btn { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 34px; + padding: 0 $size-gap-3; + border: none; + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--color-accent-500) 10%, var(--element-bg-soft)); + color: var(--color-text-primary); + font-size: $font-size-xs; + font-weight: $font-weight-medium; + cursor: pointer; + transition: + background $motion-fast $easing-standard, + color $motion-fast $easing-standard; + white-space: nowrap; + + &:hover { + background: color-mix(in srgb, var(--color-accent-500) 16%, var(--element-bg-medium)); + color: var(--color-text-primary); + } - &.is-invalid { - background: rgba($color-error, 0.08); - border: 1px solid rgba($color-error, 0.3); - color: var(--color-error); - } + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 2px; } +} - &__validation-name { - font-weight: $font-weight-medium; +// ══════════════════════════════════════════════════════════════════════════════ +// LEFT: body — fixed display, no scroll (4 cards shown) +// ══════════════════════════════════════════════════════════════════════════════ + +.skills-split__left-body { + // No flex-grow / overflow; content is always fully visible + padding-bottom: $size-gap-4; +} + +// Section head (market title + source link) +.skills-split__section-head { + display: flex; + align-items: center; + gap: $size-gap-2; + margin-bottom: $size-gap-4; + padding-bottom: $size-gap-2; + border-bottom: 1px solid var(--border-subtle); +} + +.skills-split__section-title { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + letter-spacing: -0.01em; +} + +.skills-split__section-sub { + flex: 1; + font-size: $font-size-xs; + color: var(--color-text-muted); + + a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; } +} - &__validation-desc { - margin-top: 2px; - font-size: $font-size-xs; - opacity: 0.85; +// Market cards — 3-column × 2-row grid (6 cards per page) +.skills-split__market-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: $size-gap-3; + align-content: start; + + .skill-card { + width: 100%; + height: var(--skills-market-card-h); } +} - &__validation-error { - font-weight: $font-weight-medium; +// ── Skeleton loading (market grid + installed list) ─────────────────────────── + +.skills-split__skeleton-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: $size-gap-3; + align-content: start; +} + +.skills-split__skeleton-card { + height: var(--skills-market-card-h); + border-radius: 15px; + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + position: relative; + overflow: hidden; + animation: skills-skeleton-card-in 0.28s $easing-decelerate both; + animation-delay: calc(var(--card-index, 0) * 60ms); + + &::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + var(--skills-sk-shimmer-0, rgba(255, 255, 255, 0)) 0%, + var(--skills-sk-shimmer-peak, rgba(255, 255, 255, 0.1)) 50%, + var(--skills-sk-shimmer-0, rgba(255, 255, 255, 0)) 100% + ); + animation: skills-skeleton-shimmer 1.6s ease-in-out calc(var(--card-index, 0) * 0.12s) infinite; } +} - &__grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: $size-gap-2; - align-content: start; +.skills-split__skeleton-list { + display: flex; + flex-direction: column; + gap: $size-gap-1; +} - &--skeleton { - --skeleton-shimmer-0: rgba(255, 255, 255, 0); - --skeleton-shimmer-peak: rgba(255, 255, 255, 0.1); - } +.skills-split__skeleton-row { + display: flex; + align-items: center; + gap: $size-gap-3; + padding: $size-gap-3 $size-gap-2; + border-radius: $size-radius-lg; + animation: skills-skeleton-row-in 0.22s $easing-decelerate both; + animation-delay: calc(var(--row-index, 0) * 40ms); +} + +.skills-split__skeleton-row-avatar { + flex-shrink: 0; + width: 30px; + height: 30px; + border-radius: $size-radius-base; + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + var(--skills-sk-shimmer-0, rgba(255, 255, 255, 0)) 0%, + var(--skills-sk-shimmer-peak, rgba(255, 255, 255, 0.1)) 50%, + var(--skills-sk-shimmer-0, rgba(255, 255, 255, 0)) 100% + ); + animation: skills-skeleton-shimmer 1.6s ease-in-out calc(var(--row-index, 0) * 0.1s) infinite; } +} - &__skeleton-card { - height: 140px; - border-radius: $size-radius-lg; - background: var(--element-bg-subtle); - border: 1px solid var(--border-subtle); - position: relative; - overflow: hidden; - animation: bitfun-skills-scene-item-in 0.28s $easing-decelerate both; - animation-delay: calc(var(--card-index, 0) * 60ms); +.skills-split__skeleton-row-lines { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.skills-split__skeleton-line { + border-radius: 4px; + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + var(--skills-sk-shimmer-0, rgba(255, 255, 255, 0)) 0%, + var(--skills-sk-shimmer-peak, rgba(255, 255, 255, 0.1)) 50%, + var(--skills-sk-shimmer-0, rgba(255, 255, 255, 0)) 100% + ); + animation: skills-skeleton-shimmer 1.6s ease-in-out calc(var(--row-index, 0) * 0.1s + 0.15s) infinite; + } + + &--title { + width: 58%; + height: 11px; + } + + &--desc { + width: 88%; + height: 8px; + opacity: 0.85; &::after { - content: ''; - position: absolute; - inset: 0; - transform: translateX(-100%); - background: linear-gradient( - 90deg, - var(--skeleton-shimmer-0) 0%, - var(--skeleton-shimmer-peak) 50%, - var(--skeleton-shimmer-0) 100% - ); - animation: bitfun-skills-scene-shimmer 1.6s ease-in-out calc(var(--card-index, 0) * 0.12s) infinite; + animation-delay: calc(var(--row-index, 0) * 0.1s + 0.28s); } } +} - &__empty { - min-height: 180px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: $size-gap-3; - padding: $size-gap-8 $size-gap-6; - font-size: $font-size-sm; - color: var(--color-text-muted); - text-align: center; +.skills-split__skeleton-row-tail { + display: flex; + align-items: center; + gap: $size-gap-2; + flex-shrink: 0; +} - &--error { - color: var(--color-error); - } +.skills-split__skeleton-pill { + width: 44px; + height: 22px; + border-radius: $size-radius-full; + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + var(--skills-sk-shimmer-0, rgba(255, 255, 255, 0)) 0%, + var(--skills-sk-shimmer-peak, rgba(255, 255, 255, 0.1)) 50%, + var(--skills-sk-shimmer-0, rgba(255, 255, 255, 0)) 100% + ); + animation: skills-skeleton-shimmer 1.6s ease-in-out calc(var(--row-index, 0) * 0.1s + 0.2s) infinite; + } +} + +.skills-split__skeleton-icon { + width: 26px; + height: 26px; + border-radius: $size-radius-base; + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + var(--skills-sk-shimmer-0, rgba(255, 255, 255, 0)) 0%, + var(--skills-sk-shimmer-peak, rgba(255, 255, 255, 0.1)) 50%, + var(--skills-sk-shimmer-0, rgba(255, 255, 255, 0)) 100% + ); + animation: skills-skeleton-shimmer 1.6s ease-in-out calc(var(--row-index, 0) * 0.1s + 0.32s) infinite; + } +} + +:root[data-theme-type='light'] .skills-split__skeleton-card, +:root[data-theme-type='light'] .skills-split__skeleton-row-avatar, +:root[data-theme-type='light'] .skills-split__skeleton-line, +:root[data-theme-type='light'] .skills-split__skeleton-pill, +:root[data-theme-type='light'] .skills-split__skeleton-icon { + --skills-sk-shimmer-0: rgba(0, 0, 0, 0); + --skills-sk-shimmer-peak: rgba(0, 0, 0, 0.07); +} + +// Pagination row +.skills-split__pagination { + display: flex; + align-items: center; + justify-content: center; + gap: $size-gap-3; + padding: $size-gap-5 0 $size-gap-3; +} + +.skills-split__page-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 1px solid var(--border-medium); + border-radius: $size-radius-base; + background: var(--element-bg-soft); + color: var(--color-text-secondary); + cursor: pointer; + transition: + background $motion-fast $easing-standard, + color $motion-fast $easing-standard; + + &:hover:not(:disabled) { + background: var(--element-bg-medium); + color: var(--color-text-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.skills-split__page-info { + min-width: 52px; + text-align: center; + font-size: $font-size-sm; + color: var(--color-text-secondary); +} + +// Shared loading / empty states +.skills-split__loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 140px; + color: var(--color-text-muted); +} + +.skills-split__spinner { + animation: skills-spin 0.8s linear infinite; +} + +.skills-split__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $size-gap-3; + min-height: 140px; + padding: $size-gap-8 $size-gap-6; + font-size: $font-size-sm; + color: var(--color-text-muted); + text-align: center; + + &--error { + color: var(--color-error); + } +} + +// ══════════════════════════════════════════════════════════════════════════════ +// RIGHT: installed skills panel +// ══════════════════════════════════════════════════════════════════════════════ + +.skills-split__right-header { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: $size-gap-2; + padding-bottom: $size-gap-3; + margin-bottom: $size-gap-1; + border-bottom: 1px solid var(--border-subtle); +} + +.skills-split__right-title { + font-size: clamp(16px, 1.6vw, 18px); + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + letter-spacing: -0.02em; + min-width: 0; +} + +// Filters + total count + add on one row (wraps on narrow width) +.skills-split__right-toolbar { + display: flex; + align-items: center; + gap: $size-gap-2; + flex-wrap: wrap; +} + +.skills-split__filter-bar { + display: flex; + align-items: center; + gap: $size-gap-1; + flex-wrap: wrap; + flex: 1; + min-width: 0; +} + +.skills-split__right-toolbar .skills-split__add-btn { + flex-shrink: 0; + margin-left: auto; +} + +.skills-split__filter-chip { + display: inline-flex; + align-items: center; + gap: $size-gap-1; + height: 24px; + padding: 0 $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-full; + background: transparent; + color: var(--color-text-muted); + font-size: $font-size-xs; + font-weight: $font-weight-medium; + cursor: pointer; + transition: + background $motion-fast $easing-standard, + color $motion-fast $easing-standard, + border-color $motion-fast $easing-standard; + + &:hover { + color: var(--color-text-primary); + background: var(--element-bg-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: -1px; + } + + &.is-active { + color: var(--color-accent-500); + background: var(--color-accent-100); + border-color: var(--color-accent-300); + } +} + +.skills-split__filter-count { + display: inline-flex; + align-items: center; + justify-content: center; + height: 16px; + min-width: 16px; + padding: 0 4px; + border-radius: $size-radius-full; + background: var(--element-bg-medium); + color: var(--color-text-muted); + font-size: 10px; + transition: inherit; + + .skills-split__filter-chip.is-active & { + background: color-mix(in srgb, var(--color-accent-500) 15%, transparent); + color: var(--color-accent-500); + } +} + +// Fills remaining space inside framed panel (below header, above pagination) +.skills-split__right-body { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: $size-gap-1 $size-gap-1 $size-gap-2 0; + + &::-webkit-scrollbar { width: 4px; } + &::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 2px; + } +} + +.skills-split__pagination--installed { + flex-shrink: 0; + padding: $size-gap-2 0 0; + margin-top: auto; +} + +// Installed skill row +.skills-split__installed-row { + display: flex; + align-items: center; + gap: $size-gap-3; + padding: $size-gap-3 $size-gap-2; + border-radius: $size-radius-lg; + cursor: pointer; + animation: skills-row-in 0.2s $easing-decelerate both; + animation-delay: calc(var(--row-index, 0) * 25ms); + transition: + background $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: -2px; + } +} + +.skills-split__row-icon { + flex-shrink: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: $size-radius-base; + background: var(--element-bg-medium); + border: 1px solid var(--border-subtle); + color: var(--color-text-secondary); +} + +.skills-split__row-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.skills-split__row-name { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: var(--color-text-primary); + line-height: $line-height-tight; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.skills-split__row-desc { + font-size: $font-size-xs; + color: var(--color-text-muted); + opacity: 0.72; + line-height: $line-height-relaxed; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.skills-split__row-end { + display: flex; + align-items: center; + gap: $size-gap-2; + flex-shrink: 0; +} + +.skills-split__row-delete { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: $size-radius-base; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + opacity: 0; + transition: + background $motion-fast $easing-standard, + color $motion-fast $easing-standard, + opacity $motion-fast $easing-standard; + + .skills-split__installed-row:hover & { + opacity: 1; } + &:hover { + background: color-mix(in srgb, var(--color-error) 10%, var(--element-bg-medium)); + color: var(--color-error); + } + + &:focus-visible { + outline: 2px solid var(--color-error); + outline-offset: 1px; + opacity: 1; + } +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Shared detail / form styles (kept from original) +// ══════════════════════════════════════════════════════════════════════════════ + +.bitfun-skills-scene { &__market-meta { display: inline-flex; align-items: center; @@ -142,9 +747,7 @@ cursor: pointer; transition: color $motion-fast $easing-standard; - &:hover { - color: var(--color-accent-600); - } + &:hover { color: var(--color-accent-600); } &:focus-visible { outline: 2px solid var(--color-accent-500); @@ -177,21 +780,46 @@ text-underline-offset: 2px; } - &__pagination { + &__path-input { display: flex; - align-items: center; - justify-content: center; - gap: $size-gap-3; - padding-top: $size-gap-3; + align-items: flex-end; + gap: $size-gap-2; + + > :first-child { + flex: 1; + min-width: 0; + } + } + + &__path-hint, + &__form-hint, + &__validating { + font-size: $font-size-xs; + color: var(--color-text-muted); } - &__pagination-info { - min-width: 52px; - text-align: center; + &__validation { + padding: $size-gap-2 $size-gap-3; + border-radius: $size-radius-base; font-size: $font-size-sm; - color: var(--color-text-secondary); + + &.is-valid { + background: rgba($color-success, 0.08); + border: 1px solid rgba($color-success, 0.3); + color: var(--color-success); + } + + &.is-invalid { + background: rgba($color-error, 0.08); + border: 1px solid rgba($color-error, 0.3); + color: var(--color-error); + } } + &__validation-name { font-weight: $font-weight-medium; } + &__validation-desc { margin-top: 2px; font-size: $font-size-xs; opacity: 0.85; } + &__validation-error { font-weight: $font-weight-medium; } + &__modal-form { display: flex; flex-direction: column; @@ -209,23 +837,127 @@ } } -@media (max-width: 720px) { - .bitfun-skills-scene { - .gallery-page-header__actions { - width: 100%; +// ══════════════════════════════════════════════════════════════════════════════ +// Animations +// ══════════════════════════════════════════════════════════════════════════════ + +@keyframes skills-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes skills-skeleton-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(200%); } +} - > :first-child { - flex: 1; - width: auto; - } +@keyframes skills-skeleton-card-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes skills-skeleton-row-in { + from { + opacity: 0; + transform: translateX(6px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes skills-row-in { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Responsive +// ══════════════════════════════════════════════════════════════════════════════ + +@media (max-width: 860px) { + .skills-split { + flex-direction: column; + gap: $size-gap-6; + overflow-y: auto; + padding-bottom: $size-gap-8; + + &::-webkit-scrollbar { width: 4px; } + &::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 2px; + } + + &__left, + &__right { + flex: none; + overflow: visible; + padding-top: 0; } + + &__market-grid { + grid-template-columns: repeat(2, 1fr); + } + } + + .skills-split__skeleton-grid { + grid-template-columns: repeat(2, 1fr); + } + + .skills-split__right-frame { + height: auto; + max-height: none; + min-height: calc(var(--skills-left-body-h) * 2); + } + + .skills-split__right-body { + max-height: min(52vh, 480px); + } +} + +@media (max-width: 640px) { + .skills-split { + padding: 0 $size-gap-4; + } + + .skills-split__market-grid { + grid-template-columns: 1fr; + } + + .skills-split__skeleton-grid { + grid-template-columns: 1fr; } } @media (prefers-reduced-motion: reduce) { - .bitfun-skills-scene { - .bitfun-skills-scene__form-card { - transition: none; - } + .skills-split__installed-row, + .skills-split__spinner, + .skills-split__search .search__wrapper, + .skills-split__add-btn, + .skills-split__filter-chip, + .skills-split__page-btn, + .skills-split__row-delete, + .skills-split__skeleton-card, + .skills-split__skeleton-row, + .skills-split__skeleton-card::after, + .skills-split__skeleton-row-avatar::after, + .skills-split__skeleton-line::after, + .skills-split__skeleton-pill::after, + .skills-split__skeleton-icon::after { + animation: none; + transition: none; } } diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx index 46b4809f..81010b5d 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx @@ -1,15 +1,13 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { CheckCircle2, ChevronLeft, ChevronRight, Download, FolderOpen, - Loader2, Package, Plus, Puzzle, - Search as SearchIcon, Sparkles, Store, Trash2, @@ -17,15 +15,7 @@ import { } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Badge, Button, ConfirmDialog, Input, Modal, Search, Select } from '@/component-library'; -import { - GalleryDetailModal, - GalleryEmpty, - GalleryGrid, - GalleryLayout, - GalleryPageHeader, - GallerySkeleton, - GalleryZone, -} from '@/app/components'; +import { GalleryDetailModal } from '@/app/components'; import type { SkillInfo, SkillLevel, SkillMarketItem } from '@/infrastructure/config/types'; import { workspaceAPI } from '@/infrastructure/api'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; @@ -44,6 +34,8 @@ const log = createLogger('SkillsScene'); const SKILLS_SOURCE_URL = 'https://skills.sh'; +const INSTALLED_PAGE_SIZE = 10; + const SkillsScene: React.FC = () => { const { t } = useTranslation('scenes/skills'); const notification = useNotification(); @@ -60,6 +52,7 @@ const SkillsScene: React.FC = () => { } = useSkillsSceneStore(); const [deleteTarget, setDeleteTarget] = useState(null); + const [installedListPage, setInstalledListPage] = useState(0); const [selectedDetail, setSelectedDetail] = useState< | { type: 'installed'; skill: SkillInfo } | { type: 'market'; skill: SkillMarketItem } @@ -79,6 +72,7 @@ const SkillsScene: React.FC = () => { const market = useSkillMarket({ searchQuery: marketQuery, installedSkillNames, + pageSize: 6, onInstalledChanged: async () => { await installed.loadSkills(true); }, @@ -118,247 +112,346 @@ const SkillsScene: React.FC = () => { } }; - const renderInstalledCard = (skill: SkillInfo, index: number) => ( - - {skill.level === 'user' ? t('list.item.user') : t('list.item.project')} - - )} - actions={[ - { - id: 'delete', - icon: , - ariaLabel: t('list.item.deleteTooltip'), - title: t('list.item.deleteTooltip'), - tone: 'danger', - onClick: () => setDeleteTarget(skill), - }, - ]} - onOpenDetails={() => setSelectedDetail({ type: 'installed', skill })} - /> - ); - - const renderMarketCard = (skill: SkillMarketItem, index: number) => { - const isInstalled = installedSkillNames.has(skill.name); - const isDownloading = market.downloadingPackage === skill.installId; - - return ( - - - {t('market.item.installed')} - - ) : null} - meta={( - - - {skill.installs ?? 0} - - )} - actions={[ - { - id: 'download', - icon: isInstalled ? : , - ariaLabel: isInstalled ? t('market.item.installed') : t('market.item.downloadProject'), - title: isDownloading - ? t('market.item.downloading') - : (isInstalled ? t('market.item.installedTooltip') : t('market.item.downloadProject')), - disabled: isDownloading || !market.hasWorkspace || isInstalled, - tone: isInstalled ? 'success' : 'primary', - onClick: () => market.handleDownload(skill), - }, - ]} - onOpenDetails={() => setSelectedDetail({ type: 'market', skill })} - /> - ); - }; - const selectedInstalledSkill = selectedDetail?.type === 'installed' ? selectedDetail.skill : null; const selectedMarketSkill = selectedDetail?.type === 'market' ? selectedDetail.skill : null; + const installedFiltered = installed.filteredSkills; + const installedTotalPages = Math.max( + 1, + Math.ceil(installedFiltered.length / INSTALLED_PAGE_SIZE), + ); + const currentInstalledPage = Math.min(installedListPage, installedTotalPages - 1); + const pagedInstalledSkills = installedFiltered.slice( + currentInstalledPage * INSTALLED_PAGE_SIZE, + (currentInstalledPage + 1) * INSTALLED_PAGE_SIZE, + ); + + useEffect(() => { + setInstalledListPage(0); + }, [installedFilter, searchDraft]); + + useEffect(() => { + setInstalledListPage((p) => Math.min(p, Math.max(0, installedTotalPages - 1))); + }, [installedTotalPages]); + + const marketSkeletonGrid = (keyPrefix: string) => ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ); + return ( - - - } - suffixContent={( +
+ {/* ── Two-column split layout ── */} +
+ + {/* ══ LEFT: market skills ══ */} +
+ {/* Sticky header */} +
+
+
+

{t('page.title')}

+

{t('page.subtitle')}

+
+
+ +
+ +
+
+ + {/* Market body — fixed display, no scroll */} +
+
+ {t('market.title')} + + {t('market.subtitlePrefix')} + {' '} + skills.sh + {t('market.subtitleSuffix')} + +
+ + {/* Market loading — skeleton grid */} + {market.marketLoading && marketSkeletonGrid('mkt-init')} + + {/* Market error */} + {!market.marketLoading && market.marketError && ( +
+ + {market.marketError} +
+ )} + + {/* Pagination fetch — same skeleton as initial load */} + {!market.marketLoading && !market.marketError && market.loadingMore && marketSkeletonGrid('mkt-page')} + + {/* Market empty */} + {!market.marketLoading && !market.marketError && !market.loadingMore && market.marketSkills.length === 0 && ( +
+ + {marketQuery ? t('market.empty.noMatch') : t('market.empty.noSkills')} +
+ )} + + {/* Market cards grid — 3×2, 6 per page */} + {!market.marketLoading && !market.marketError && !market.loadingMore && market.marketSkills.length > 0 && ( +
+ {market.marketSkills.map((skill, index) => { + const isInstalled = installedSkillNames.has(skill.name); + const isDownloading = market.downloadingPackage === skill.installId; + return ( + + + {t('market.item.installed')} + + ) : null} + meta={( + + + {skill.installs ?? 0} + + )} + actions={[ + { + id: 'download', + icon: isInstalled ? : , + ariaLabel: isInstalled ? t('market.item.installed') : t('market.item.downloadProject'), + title: isDownloading + ? t('market.item.downloading') + : (isInstalled ? t('market.item.installedTooltip') : t('market.item.downloadProject')), + disabled: isDownloading || !market.hasWorkspace || isInstalled, + tone: isInstalled ? 'success' : 'primary', + onClick: () => market.handleDownload(skill), + }, + ]} + onOpenDetails={() => setSelectedDetail({ type: 'market', skill })} + /> + ); + })} +
+ )} + + {/* Pagination */} + {!market.marketLoading && !market.marketError && (market.totalPages > 1 || market.hasMore) && ( +
+ + {market.hasMore + ? t('market.pagination.infoMore', { current: market.currentPage + 1 }) + : t('market.pagination.info', { current: market.currentPage + 1, total: market.totalPages })} + + +
+ )} +
+
+ + {/* ══ RIGHT: installed skills ══ */} +
+
+ {/* Right header */} +
+ {t('installed.titleAll')} +
+
+ {([ + ['all', installed.counts.all], + ['user', installed.counts.user], + ['project', installed.counts.project], + ] as const).map(([filter, count]) => ( + + ))} +
+ +
+
+ + {/* Scrollable installed body */} +
+ {/* Loading — row skeletons */} + {installed.loading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Error */} + {!installed.loading && installed.error && ( +
+ + {installed.error} +
+ )} + + {/* Empty */} + {!installed.loading && !installed.error && installedFiltered.length === 0 && ( +
+ + + {installed.skills.length === 0 + ? t('list.empty.noSkills') + : t('list.empty.noMatch')} + +
)} - /> - - - )} - /> -
- -
- {([ - ['all', installed.counts.all], - ['user', installed.counts.user], - ['project', installed.counts.project], - ] as const).map(([filter, count]) => ( + {/* Installed rows */} + {!installed.loading && !installed.error && pagedInstalledSkills.map((skill, index) => ( +
setSelectedDetail({ type: 'installed', skill })} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedDetail({ type: 'installed', skill }); + } + }} + aria-label={skill.name} + > +
+ +
+
+ {skill.name} + {skill.description?.trim() && ( + {skill.description} + )} +
+
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + {skill.level === 'user' ? t('list.item.user') : t('list.item.project')} + - ))} +
- {installed.filteredSkills.length} - - )} - > - {installed.loading ? : null} - - {!installed.loading && installed.error ? ( - } - message={installed.error} - isError - /> - ) : null} - - {!installed.loading && !installed.error && installed.filteredSkills.length === 0 ? ( - } - message={installed.skills.length === 0 ? t('list.empty.noSkills') : t('list.empty.noMatch')} - /> - ) : null} - - {!installed.loading && !installed.error && installed.filteredSkills.length > 0 ? ( - - {installed.filteredSkills.map(renderInstalledCard)} - - ) : null} - - - - {t('market.subtitlePrefix')} - {' '} - - skills.sh - - {t('market.subtitleSuffix')} - - )} - tools={{market.totalLoaded}} - > - {market.marketLoading ? : null} - - {!market.marketLoading && market.loadingMore && market.marketSkills.length === 0 - ? - : null} - - {!market.marketLoading && market.marketError ? ( - } - message={market.marketError} - isError - /> - ) : null} - - {!market.marketLoading && !market.loadingMore && !market.marketError && market.marketSkills.length === 0 ? ( - } - message={marketQuery ? t('market.empty.noMatch') : t('market.empty.noSkills')} - /> - ) : null} - - {!market.marketLoading && !market.marketError && market.marketSkills.length > 0 ? ( - - {market.marketSkills.map(renderMarketCard)} - - ) : null} + ))} +
- {!market.marketLoading && !market.marketError && (market.totalPages > 1 || market.hasMore) ? ( -
- - - {market.hasMore - ? t('market.pagination.infoMore', { current: market.currentPage + 1 }) - : t('market.pagination.info', { - current: market.currentPage + 1, - total: market.totalPages, + {!installed.loading && !installed.error && installedFiltered.length > 0 && installedTotalPages > 1 && ( +
+ + + {t('market.pagination.info', { + current: currentInstalledPage + 1, + total: installedTotalPages, })} - - -
- ) : null} - +
+ +
+ )} +
+
+ {/* ── Detail modal ── */} setSelectedDetail(null)} @@ -463,6 +556,7 @@ const SkillsScene: React.FC = () => { ) : null} + {/* ── Add skill modal ── */} { @@ -566,6 +660,7 @@ const SkillsScene: React.FC = () => {
+ {/* ── Delete confirm ── */} setDeleteTarget(null)} @@ -585,7 +680,7 @@ const SkillsScene: React.FC = () => { confirmText={t('deleteModal.delete')} cancelText={t('deleteModal.cancel')} /> - +
); }; diff --git a/src/web-ui/src/app/scenes/skills/components/SkillCard.scss b/src/web-ui/src/app/scenes/skills/components/SkillCard.scss index d54f419e..f00c3fc3 100644 --- a/src/web-ui/src/app/scenes/skills/components/SkillCard.scss +++ b/src/web-ui/src/app/scenes/skills/components/SkillCard.scss @@ -77,9 +77,9 @@ display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; - border-radius: 10px; + width: 32px; + height: 32px; + border-radius: 8px; background: rgba(255, 255, 255, 0.12); backdrop-filter: blur(8px); } @@ -109,34 +109,48 @@ z-index: 1; } + &__title-row { + display: flex; + align-items: center; + gap: $size-gap-2; + min-width: 0; + } + &__name { - font-size: 1.2em; - font-weight: 900; + font-size: 0.92em; + font-weight: $font-weight-semibold; color: var(--color-text-primary); line-height: $line-height-base; - word-break: break-word; + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } &__desc { margin: 0; - font-size: 0.85em; - font-weight: 300; - color: rgba(var(--color-text-secondary), 0.85); + font-size: 0.78em; + font-weight: $font-weight-normal; + color: var(--color-text-muted); + opacity: 0.72; line-height: $line-height-relaxed; display: -webkit-box; - -webkit-line-clamp: 2; -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; overflow: hidden; + text-overflow: ellipsis; word-break: break-word; } &__meta { - display: flex; + display: inline-flex; align-items: center; - gap: $size-gap-1; - flex-wrap: wrap; - margin-top: auto; - padding-top: $size-gap-2; + gap: 4px; + flex-shrink: 0; + margin: 0; + padding: 0; color: var(--color-text-muted); font-size: $font-size-xs; line-height: $line-height-base; diff --git a/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx b/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx index 4344f83d..b1683f4b 100644 --- a/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx +++ b/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx @@ -69,21 +69,23 @@ const SkillCard: React.FC = ({ {badges &&
{badges}
}
- {/* Body: name + description + meta */} + {/* Body: name + trend (meta) on one row, then description */}
- {name} +
+ {name} + {meta ? ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {meta} +
+ ) : null} +
{description?.trim() && (

{description.trim()}

)} - {meta ? ( -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - {meta} -
- ) : null}
{/* Footer: action buttons */} diff --git a/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts b/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts index e7e62a08..eb4d3680 100644 --- a/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts +++ b/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts @@ -8,19 +8,21 @@ import { createLogger } from '@/shared/utils/logger'; const log = createLogger('SkillsScene:useSkillMarket'); -const PAGE_SIZE = 10; +const DEFAULT_PAGE_SIZE = 10; const MAX_TOTAL_SKILLS = 500; interface UseSkillMarketOptions { searchQuery: string; installedSkillNames: Set; onInstalledChanged?: () => Promise | void; + pageSize?: number; } export function useSkillMarket({ searchQuery, installedSkillNames, onInstalledChanged, + pageSize = DEFAULT_PAGE_SIZE, }: UseSkillMarketOptions) { const { t } = useTranslation('scenes/skills'); const notification = useNotification(); @@ -46,9 +48,9 @@ export function useSkillMarket({ setMarketError(null); setCurrentPage(0); try { - const skillList = await fetchSkills(query, PAGE_SIZE); + const skillList = await fetchSkills(query, pageSize); setMarketSkills(skillList); - setHasMore(skillList.length >= PAGE_SIZE); + setHasMore(skillList.length >= pageSize); } catch (err) { log.error('Failed to load skill market', err); setMarketError(err instanceof Error ? err.message : String(err)); @@ -86,13 +88,13 @@ export function useSkillMarket({ return entries.map((entry) => entry.skill); }, [installedSkillNames, marketSkills]); - const loadedPages = Math.ceil(displayMarketSkills.length / PAGE_SIZE); + const loadedPages = Math.ceil(displayMarketSkills.length / pageSize); const totalPages = hasMore ? loadedPages + 1 : Math.max(1, loadedPages); const paginatedSkills = useMemo(() => displayMarketSkills.slice( - currentPage * PAGE_SIZE, - (currentPage + 1) * PAGE_SIZE, - ), [currentPage, displayMarketSkills]); + currentPage * pageSize, + (currentPage + 1) * pageSize, + ), [currentPage, displayMarketSkills, pageSize]); const goToPrevPage = useCallback(() => { setCurrentPage((page) => Math.max(0, page - 1)); @@ -100,7 +102,7 @@ export function useSkillMarket({ const goToNextPage = useCallback(async () => { const nextPage = currentPage + 1; - const neededCount = Math.min((nextPage + 1) * PAGE_SIZE, MAX_TOTAL_SKILLS); + const neededCount = Math.min((nextPage + 1) * pageSize, MAX_TOTAL_SKILLS); if (displayMarketSkills.length >= neededCount) { setCurrentPage(nextPage);