Newer
Older
navi-1 / webclient / src / views / SharedChatView.vue
<template>
  <div class="shared-shell">
    <header class="shared-header">
      <div class="shared-heading">
        <h1 class="shared-title">{{ title }}</h1>
        <div class="shared-meta">
          <span v-if="meta?.profile_name" class="shared-profile">{{ meta.profile_name }}</span>
          <span v-if="meta" class="shared-date">{{ dateLabel }}</span>
          <span class="shared-badge">
            <i class="ph ph-eye"></i>
            {{ t('share.readOnly') }}
          </span>
        </div>
      </div>
      <span class="shared-brand">Navi</span>
    </header>

    <div v-if="loading" class="shared-loading">
      <i class="ph ph-spinner animate-spin"></i>
    </div>

    <div v-else-if="error" class="shared-error">
      <i class="ph ph-link-break"></i>
      <p class="shared-error-title">{{ t('share.notFoundTitle') }}</p>
      <p class="shared-error-text">{{ t('share.notFoundText') }}</p>
    </div>

    <div v-else ref="scroller" class="shared-scroller" @scroll.passive="onScroll">
      <button
        v-if="hasMore"
        type="button"
        class="shared-load-older"
        :disabled="loadingOlder"
        @click="loadOlder"
      >
        <i :class="loadingOlder ? 'ph ph-spinner animate-spin' : 'ph ph-caret-up'"></i>
        {{ t('share.loadOlder') }}
      </button>

      <div
        v-for="item in messages"
        :key="item.id"
        class="shared-row"
      >
        <component :is="componentFor(item)" :msg="item" readonly />
      </div>

      <p v-if="!messages.length" class="shared-empty">{{ t('share.empty') }}</p>
    </div>

    <footer v-if="!loading && !error" class="shared-footer">
      {{ t('share.footer') }}
    </footer>
  </div>
</template>

<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { getSharedChat, getSharedHistory } from '@/api/index.js'
import { useChatStore } from '@/stores/chat.js'
import { locale, t } from '@/i18n/index.js'
import UserMessage from '@/components/messages/UserMessage.vue'
import AssistantMessage from '@/components/messages/AssistantMessage.vue'
import SummaryCard from '@/components/messages/SummaryCard.vue'
import CompressionNotice from '@/components/messages/CompressionNotice.vue'
import SynapseEventNotice from '@/components/messages/SynapseEventNotice.vue'
import ErrorMessage from '@/components/messages/ErrorMessage.vue'

const props = defineProps({
  token: { type: String, required: true },
})

// Read-only page: a fresh share link has an empty chat store, and the message
// builder is the one piece of it worth reusing — it turns raw history rows
// into the same cards the owner sees.
const chat = useChatStore()

const raw = ref([])
const meta = ref(null)
const loading = ref(true)
const error = ref(false)
const hasMore = ref(false)
const nextBefore = ref(null)
const loadingOlder = ref(false)
const scroller = ref(null)

const title = computed(() => meta.value?.name || t('share.untitled'))
const dateLabel = computed(() => {
  const ts = meta.value?.created_at
  if (!ts) return ''
  try {
    return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium' }).format(new Date(ts))
  } catch {
    return ''
  }
})

function stripInteractive(msg) {
  // UI components are live widgets (forms, proposals) — a shared page must not
  // offer anything that writes, so only their absence is safe here.
  if (!msg.tools?.length) return msg
  return { ...msg, tools: msg.tools.filter(item => item.kind !== 'ui_component') }
}

const messages = computed(() => chat.buildMessageList(raw.value).map(stripInteractive))

function componentFor(msg) {
  if (msg.role === 'user') return UserMessage
  if (msg.type === 'summary') return SummaryCard
  if (msg.type === 'compression_notice' || msg.type === 'compression_pending') return CompressionNotice
  if (msg.type === 'synapse_event') return SynapseEventNotice
  if (msg.type === 'error') return ErrorMessage
  return AssistantMessage
}

function scrollToBottom() {
  const el = scroller.value
  if (el) el.scrollTop = el.scrollHeight
}

async function load() {
  loading.value = true
  error.value = false
  try {
    const data = await getSharedChat(props.token)
    meta.value = data
    raw.value = data.messages ?? []
    hasMore.value = Boolean(data.has_more)
    nextBefore.value = data.next_before_seq ?? null
    document.title = data.name || 'Navi'
    await nextTick()
    scrollToBottom()
  } catch {
    error.value = true
  } finally {
    loading.value = false
  }
}

async function loadOlder() {
  if (!hasMore.value || loadingOlder.value) return
  loadingOlder.value = true
  const el = scroller.value
  const previousHeight = el?.scrollHeight ?? 0
  try {
    const data = await getSharedHistory(props.token, nextBefore.value)
    raw.value = [...(data.messages ?? []), ...raw.value]
    hasMore.value = Boolean(data.has_more)
    nextBefore.value = data.next_before_seq ?? null
    await nextTick()
    // Keep the reader where they were: the list grew above the viewport.
    if (el) el.scrollTop += el.scrollHeight - previousHeight
  } catch {
    // An older page failing to load leaves the readable history intact.
  } finally {
    loadingOlder.value = false
  }
}

function onScroll() {
  const el = scroller.value
  if (el && el.scrollTop < 120 && hasMore.value && !loadingOlder.value) loadOlder()
}

onMounted(load)
</script>

<style scoped>
.shared-shell {
  display: flex;
  flex-direction: column;
  flex: 1 1 0;
  min-height: 0;
  min-width: 0;
  background: var(--gn-surface-base);
  color: var(--gn-color-text);
}

.shared-header {
  display: flex;
  align-items: flex-start;
  gap: 16px;
  padding: 16px 24px;
  border-bottom: 1px solid var(--gn-border-color-muted);
  background: var(--gn-surface-panel);
}

.shared-heading {
  flex: 1 1 auto;
  min-width: 0;
}

.shared-title {
  margin: 0;
  font-size: 1.05rem;
  font-weight: 600;
  line-height: 1.35;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.shared-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 6px;
  font-size: 0.8rem;
  color: var(--gn-color-text-light);
}

.shared-profile {
  font-weight: 500;
}

.shared-badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 2px 9px;
  border: 1px solid var(--gn-border-color-muted);
  border-radius: 999px;
  font-size: 0.75rem;
}

.shared-brand {
  flex: 0 0 auto;
  font-weight: 700;
  letter-spacing: 0.02em;
  color: var(--gn-color-text-light);
  user-select: none;
}

.shared-scroller {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 20px 24px 40px;
}

.shared-row {
  width: 100%;
  max-width: 900px;
  margin: 0 auto;
}

.shared-load-older {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 auto 18px;
  padding: 7px 16px;
  border: 1px solid var(--gn-border-color-muted);
  border-radius: 999px;
  background: var(--gn-surface-panel);
  color: var(--gn-color-text-light);
  font-size: 0.82rem;
  cursor: pointer;
}

.shared-load-older:disabled {
  cursor: default;
  opacity: 0.6;
}

.shared-loading {
  display: flex;
  flex: 1 1 auto;
  align-items: center;
  justify-content: center;
  font-size: 1.6rem;
  color: var(--gn-color-text-light);
}

.shared-error {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 24px;
  text-align: center;
  color: var(--gn-color-text-light);
}

.shared-error > i {
  font-size: 2rem;
}

.shared-error-title {
  margin: 4px 0 0;
  font-weight: 600;
  color: var(--gn-color-text);
}

.shared-error-text {
  margin: 0;
  max-width: 420px;
  font-size: 0.9rem;
}

.shared-empty {
  margin: 40px auto;
  text-align: center;
  color: var(--gn-color-text-light);
}

.shared-footer {
  flex: 0 0 auto;
  padding: 10px 24px;
  border-top: 1px solid var(--gn-border-color-muted);
  font-size: 0.75rem;
  text-align: center;
  color: var(--gn-color-text-light);
}

@media (max-width: 640px) {
  .shared-header {
    padding: 12px 16px;
  }
  .shared-scroller {
    padding: 14px 16px 32px;
  }
  .shared-brand {
    display: none;
  }
}
</style>