Newer
Older
AI_Lab_SCRUME_Co / desktop-plugins / session-time / plugin.js
/**
 * session-time — счётчик времени текущей сессии в строке управления композера,
 * слева от чипа Polza.ai (composer.actions рендерится перед пилюлей модели,
 * а `order` меньше — значит левее).
 *
 * Время берётся из истинного старта сессии, а не из «прикрепления окна»:
 * RPC `session.list` отдаёт строку с `started_at` (epoch-СЕКУНДЫ), плагин
 * находит в ней сфокусированную сессию и считает разницу. Раз в минуту список
 * перезапрашивается, раз в секунду пересчитывается только цифра.
 *
 * Почему не `$sessionStartedAt`/`runtimeStartedAt`: эти атомы плагинам НЕ
 * отданы — `host.state` их не содержит (проверено по sdk/index.ts). Доступны
 * `focusedStoredSessionId`, `profile`, `focusedUsage` и прочие, но не время.
 *
 * Композер виден в ОБОИХ режимах интерфейса: SIMPLE_POLICY прячет статусбар,
 * но не строку контролов. Поэтому чип работает и в простом режиме — в отличие
 * от элемента `session-timer` в статусбаре.
 */

import { COMPOSER_AREAS, cn, host, Tip, useQuery, useValue } from '@hermes/plugin-sdk'
import { useEffect, useState } from 'react'
import { jsx, jsxs } from 'react/jsx-runtime'

const ID = 'session-time'

const QUERY_KEY = [ID, 'sessions']
const REFRESH_MS = 60_000
const LIST_LIMIT = 50

/** `started_at` приходит в epoch-СЕКУНДАХ; миллисекундные значения не трогаем. */
function toMs(value) {
  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
    return null
  }

  return value < 1e12 ? value * 1000 : value
}

const pad = n => String(n).padStart(2, '0')

/** 1:05:32, а до часа — 5:32: короткая форма для узкого места в композере. */
function formatElapsed(ms) {
  const total = Math.max(0, Math.floor(ms / 1000))
  const h = Math.floor(total / 3600)
  const m = Math.floor((total % 3600) / 60)
  const s = total % 60

  return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
}

function formatClock(ms) {
  const d = new Date(ms)

  return `${pad(d.getHours())}:${pad(d.getMinutes())}`
}

function formatDate(ms) {
  const d = new Date(ms)

  return `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${formatClock(ms)}`
}

function findRow(rows, storedId) {
  if (!storedId) {
    return null
  }

  return (
    rows.find(row => row && (row.id === storedId || row.resolved_id === storedId)) ?? null
  )
}

function SessionTimeChip() {
  const storedId = useValue(host.state.focusedStoredSessionId)
  const profile = useValue(host.state.profile)

  const { data } = useQuery({
    queryFn: async () => host.request('session.list', { limit: LIST_LIMIT, profile }),
    queryKey: [...QUERY_KEY, profile],
    refetchInterval: REFRESH_MS,
    staleTime: REFRESH_MS
  })

  const rows = (data && data.sessions) || []
  const row = findRow(rows, storedId)
  const startedAt = toMs(row && row.started_at)

  const [now, setNow] = useState(() => Date.now())

  useEffect(() => {
    const timer = setInterval(() => setNow(Date.now()), 1000)

    return () => clearInterval(timer)
  }, [])

  // Свежий черновик (сессии ещё нет) — чипа нет вовсе, а не «0:00».
  if (!startedAt) {
    return null
  }

  const elapsed = now - startedAt

  return jsx(Tip, {
    label: jsxs('div', {
      style: { display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px', width: '190px' },
      children: [
        jsx('span', { children: row?.title || 'Текущая сессия' }),
        jsx('span', {
          style: { color: 'var(--ui-text-tertiary)' },
          children: `начата ${formatDate(startedAt)}`
        }),
        jsx('span', {
          style: { color: 'var(--ui-text-tertiary)' },
          children: `идёт ${formatElapsed(elapsed)}`
        })
      ]
    }),
    placement: 'control',
    children: jsx('span', {
      className: cn(
        'flex h-(--composer-control-size) items-center gap-1 rounded-md px-1.5 text-xs',
        'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)'
      ),
      'data-slot': 'session-time-chip',
      style: { fontVariantNumeric: 'tabular-nums' },
      children: [
        jsx('i', { 'aria-hidden': 'true', className: 'codicon codicon-clock' }),
        jsx('span', { children: formatElapsed(elapsed) })
      ]
    })
  })
}

export default {
  id: ID,
  name: 'Session time',
  defaultEnabled: true,
  register(ctx) {
    ctx.register({
      id: 'chip',
      area: COMPOSER_AREAS.actions,
      order: 5,
      render: () => jsx(SessionTimeChip, {})
    })
  }
}