feat(chat): add collapsible prompt index and polish magazine layout
Add a toggle in the chat header to collapse messages into a compact prompt index showing user queries with content-type badges. Clicking a prompt surfaces the corresponding content panel. Also fixes magazine section extraction (### headers, emoji stripping, author regex) and improves tile layout with editorial rhythm. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2f27cb86c4
commit
aab55ef9ca
@@ -15,7 +15,7 @@
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<!-- Query context -->
|
||||
<div v-if="headlineText" class="px-5 pt-4 pb-1">
|
||||
<div v-if="headlineText" class="px-5 pt-4 pb-3">
|
||||
<p class="text-[9px] uppercase tracking-[0.3em] font-medium"
|
||||
:class="isDark ? 'text-white/30' : 'text-black/40'">
|
||||
In response to
|
||||
@@ -24,18 +24,19 @@
|
||||
:class="isDark ? 'text-white/60' : 'text-black/50'">
|
||||
{{ headlineText }}
|
||||
</p>
|
||||
<div class="mt-3 h-px" :class="isDark ? 'bg-white/8' : 'bg-black/6'" />
|
||||
</div>
|
||||
|
||||
<!-- Tile grid -->
|
||||
<div class="px-3 py-3">
|
||||
<div class="grid grid-cols-2 gap-[1px]"
|
||||
:class="isDark ? 'bg-white/8' : 'bg-black/8'">
|
||||
<div class="px-3 pt-2 pb-8">
|
||||
<div class="grid grid-cols-2 gap-px"
|
||||
:class="isDark ? 'bg-white/12' : 'bg-black/10'">
|
||||
<template v-for="(tile, i) in tiles" :key="i">
|
||||
<!-- Banner tile: full width with icon -->
|
||||
<div v-if="tile.type === 'banner'"
|
||||
class="col-span-2 flex flex-col items-center justify-center py-6 px-5"
|
||||
class="col-span-2 flex flex-col items-center justify-center py-8 px-5"
|
||||
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'">
|
||||
<svg class="w-6 h-6 mb-2" :class="isDark ? 'text-white/20' : 'text-black/15'"
|
||||
<svg class="w-5 h-5 mb-2.5" :class="isDark ? 'text-white/20' : 'text-black/15'"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path v-if="tile.icon === 'compass'" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 0v2m0 16v2m10-10h-2M4 12H2m15.07-5.07l-1.41 1.41M8.34 15.66l-1.41 1.41m0-11.14l1.41 1.41m7.32 7.32l1.41 1.41" />
|
||||
@@ -96,12 +97,16 @@
|
||||
:class="isDark ? 'text-white/25' : 'text-black/30'">
|
||||
{{ tile.label }}
|
||||
</p>
|
||||
<h3 class="font-serif text-sm font-bold leading-snug mb-1"
|
||||
<h3 v-if="tile.title"
|
||||
class="font-serif text-sm font-bold leading-snug mb-1"
|
||||
:class="isDark ? 'text-white/90' : 'text-black/85'">
|
||||
{{ tile.title }}
|
||||
</h3>
|
||||
<p class="font-serif text-xs leading-relaxed flex-1"
|
||||
:class="isDark ? 'text-white/55' : 'text-black/50'">
|
||||
:class="[
|
||||
isDark ? 'text-white/55' : 'text-black/50',
|
||||
!tile.title ? 'italic' : ''
|
||||
]">
|
||||
{{ tile.text }}
|
||||
</p>
|
||||
</button>
|
||||
@@ -186,6 +191,10 @@ const tiles = computed<Tile[]>(() => {
|
||||
for (const c of (props.query || 'brief')) seed = ((seed << 5) - seed + c.charCodeAt(0)) | 0
|
||||
const rand = () => { seed = (seed * 16807 + 0) % 2147483647; return (seed & 0x7fffffff) / 2147483647 }
|
||||
|
||||
// Layout rhythm: wide → half pair → banner → wide → half pair → ...
|
||||
// This creates the New Yorker editorial cadence
|
||||
let layoutPhase = 0 // 0=wide, 1=half-pair, 2=banner
|
||||
|
||||
secs.forEach((section, i) => {
|
||||
const bullets = splitIntoBullets(section.content)
|
||||
|
||||
@@ -199,11 +208,12 @@ const tiles = computed<Tile[]>(() => {
|
||||
author: section.author,
|
||||
section,
|
||||
})
|
||||
layoutPhase = 1
|
||||
return
|
||||
}
|
||||
|
||||
// Insert a banner between groups to break up content
|
||||
if (i % 3 === 0 && i > 0) {
|
||||
// Insert banner to break up content
|
||||
if (layoutPhase === 2) {
|
||||
const bIdx = Math.floor(rand() * bannerIcons.length)
|
||||
result.push({
|
||||
type: 'banner',
|
||||
@@ -212,11 +222,11 @@ const tiles = computed<Tile[]>(() => {
|
||||
icon: bannerIcons[bIdx],
|
||||
label: bannerLabels[bIdx],
|
||||
})
|
||||
layoutPhase = 0
|
||||
}
|
||||
|
||||
// If the section has multiple bullets, split into individual tiles
|
||||
if (bullets.length >= 2) {
|
||||
// Section header as wide tile
|
||||
if (bullets.length >= 3) {
|
||||
// Multi-bullet section: wide header + half tiles for bullets
|
||||
result.push({
|
||||
type: 'wide',
|
||||
title: section.title,
|
||||
@@ -224,35 +234,79 @@ const tiles = computed<Tile[]>(() => {
|
||||
label: section.author ? `By ${section.author}` : undefined,
|
||||
section,
|
||||
})
|
||||
// Remaining bullets as half-width tiles in pairs
|
||||
for (let b = 1; b < bullets.length; b++) {
|
||||
const isOdd = rand() > 0.7
|
||||
const variant = rand() > 0.6 ? 'dark' : 'half'
|
||||
result.push({
|
||||
type: isOdd ? 'dark' : 'half',
|
||||
type: variant,
|
||||
title: extractBulletTitle(bullets[b]) || section.title,
|
||||
text: truncate(cleanBulletTitle(bullets[b]), 100),
|
||||
section,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Single-content section: alternate between wide and half
|
||||
const useWide = rand() > 0.5 || section.content.length > 200
|
||||
layoutPhase = 2
|
||||
} else if (layoutPhase === 0) {
|
||||
// Wide tile phase
|
||||
result.push({
|
||||
type: useWide ? 'wide' : 'half',
|
||||
type: 'wide',
|
||||
title: section.title,
|
||||
text: truncate(section.content, useWide ? 200 : 100),
|
||||
text: truncate(section.content, 180),
|
||||
author: section.author,
|
||||
section,
|
||||
})
|
||||
// If half, add a companion dark tile for visual pairing
|
||||
if (!useWide) {
|
||||
layoutPhase = 1
|
||||
} else {
|
||||
// Half-width tile phase: split content at a sentence boundary
|
||||
const cleaned = cleanText(section.content)
|
||||
|
||||
// Only split if content is long enough for two meaningful tiles
|
||||
if (cleaned.length < 120) {
|
||||
// Too short to split — use as a half tile with a decorative companion
|
||||
result.push({
|
||||
type: 'half',
|
||||
title: section.title,
|
||||
text: truncate(section.content, 120),
|
||||
section,
|
||||
})
|
||||
result.push({
|
||||
type: 'dark',
|
||||
title: '',
|
||||
text: '',
|
||||
})
|
||||
} else {
|
||||
// Find a sentence boundary (. or — or ;) after first ~40% of content
|
||||
const target = Math.floor(cleaned.length * 0.4)
|
||||
let splitAt = -1
|
||||
for (const sep of ['. ', ' — ', '; ']) {
|
||||
const idx = cleaned.indexOf(sep, target)
|
||||
if (idx > 0 && idx < cleaned.length * 0.7) {
|
||||
splitAt = idx + sep.length
|
||||
break
|
||||
}
|
||||
}
|
||||
// Fallback: find a word boundary near the middle
|
||||
if (splitAt < 0) {
|
||||
const mid = Math.floor(cleaned.length / 2)
|
||||
const spaceIdx = cleaned.indexOf(' ', mid)
|
||||
splitAt = spaceIdx > 0 ? spaceIdx + 1 : mid
|
||||
}
|
||||
|
||||
const firstHalf = cleaned.slice(0, splitAt).trim()
|
||||
const secondHalf = cleaned.slice(splitAt).trim()
|
||||
|
||||
result.push({
|
||||
type: 'half',
|
||||
title: section.title,
|
||||
text: truncate(section.content.slice(100), 80),
|
||||
text: firstHalf.length > 110 ? firstHalf.slice(0, 107) + '\u2009...' : firstHalf,
|
||||
section,
|
||||
})
|
||||
result.push({
|
||||
type: 'dark',
|
||||
title: '',
|
||||
text: secondHalf.length > 110 ? secondHalf.slice(0, 107) + '\u2009...' : secondHalf,
|
||||
section,
|
||||
})
|
||||
}
|
||||
layoutPhase = 2
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user