feat(frontend): add calendar click to switch conversation by date

- Add selected date state and conversation mapping in useSidebarPlan
- Connect calendar cells to conversation switching logic
- Add conversation indicator dot on dates with sessions
- Only clickable dates show hand cursor (today + dates with conversations)
- Add .selected styling for non-today dates, today keeps blue
- Fix hover effect to only apply to non-today dates
- Add daily doc for session date mapping feature

BREAKING: Calendar click now switches sessions by date
This commit is contained in:
2026-04-07 10:28:31 +08:00
parent 3bff9b3b93
commit 721ddbeef9
5 changed files with 198 additions and 45 deletions

View File

@@ -616,8 +616,8 @@
/* ── Conversation Sidebar ── */
.conv-sidebar {
width: 280px;
min-width: 280px;
width: 320px;
min-width: 320px;
background: var(--bg-panel);
border-right: 1px solid rgba(0, 245, 212, 0.15);
display: flex;
@@ -948,18 +948,28 @@
cursor: not-allowed;
}
.chat-model {
.chat-model-display {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.1em;
letter-spacing: 0.06em;
color: var(--accent-amber);
padding: 3px 10px;
border: 1px solid rgba(249, 168, 37, 0.2);
border-radius: 20px;
background: var(--accent-amber-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.chat-model-display span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Messages ── */
@@ -1775,6 +1785,7 @@
padding: 3px 0;
border-radius: 2px;
color: rgba(0, 243, 255, 0.4);
position: relative;
}
.calendar-day.active {
@@ -2279,6 +2290,7 @@
border: 1px solid rgba(73, 208, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
color: rgba(208, 240, 252, 0.68);
transition: none;
}
.calendar-day.muted {
@@ -2304,6 +2316,37 @@
border-color: rgba(125, 211, 252, 0.78);
font-weight: 700;
box-shadow: 0 0 16px rgba(56, 189, 248, 0.22);
cursor: pointer;
}
.calendar-day.selected:not(.active) {
border-color: rgba(245, 158, 11, 0.6);
box-shadow: 0 0 12px rgba(245, 158, 11, 0.4);
background: rgba(245, 158, 11, 0.15);
}
.calendar-day.clickable {
cursor: pointer;
}
.calendar-day.clickable:hover:not(.active) {
border-color: rgba(0, 243, 255, 0.4);
background: rgba(0, 243, 255, 0.08);
}
.calendar-day.active:hover {
/* Keep original blue, no hover effect */
}
.conv-indicator {
position: absolute;
top: 3px;
right: 3px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: 0 0 6px rgba(0, 243, 255, 0.6);
}
.calendar-day.active.busy::after {

View File

@@ -729,6 +729,7 @@ export function useChatView() {
sendMessage,
selectConversation,
newConversation,
loadConversations,
deleteConversation,
formatTime,
formatConvDate,

View File

@@ -1,6 +1,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch, toRef } from 'vue'
import { CornerDownLeft, Database, Sparkles, Sun, ListTodo } from 'lucide-vue-next'
import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
import type { Conversation } from '@/api/conversation'
export interface SidebarFocusItem {
id: string; label: string; title: string; meta: string; tone: 'done' | 'doing' | 'pending'
@@ -30,9 +31,20 @@ function formatMonthKey(date: Date) {
return `${year}-${month}`
}
export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn: () => void) {
export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn: () => void, conversationsRef: Conversation[] = []) {
const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null)
const monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
const selectedDate = ref<string | null>(null)
// Build a map of date -> has conversation
const conversationDateMap = computed(() => {
const map = new Map<string, boolean>()
conversationsRef.forEach((conv) => {
const dateKey = formatDateKey(new Date(conv.updated_at))
map.set(dateKey, true)
})
return map
})
const todayDateKey = computed(() => formatDateKey(clientTimeRef.value))
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
@@ -42,19 +54,20 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
const month = clientTimeRef.value.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7
const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean }> = []
const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean; selected: boolean; hasConversation: boolean }> = []
for (let index = 0; index < firstDayOffset; index += 1) {
cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false })
cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
}
for (let day = 1; day <= daysInMonth; day += 1) {
const monthDate = new Date(year, month, day)
const dateKey = formatDateKey(monthDate)
const summary = monthPlanSummaryMap.value.get(dateKey)
const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0)
cells.push({ key: dateKey, value: day, active: day === clientTimeRef.value.getDate(), busy })
const hasConv = conversationDateMap.value.get(dateKey) || false
cells.push({ key: dateKey, value: day, active: day === clientTimeRef.value.getDate(), busy, selected: dateKey === selectedDate.value, hasConversation: hasConv })
}
while (cells.length % 7 !== 0) {
cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false })
cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
}
return cells
})
@@ -201,6 +214,10 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
void loadSidebarPlanSnapshot(clientTimeRef.value)
})
function selectCalendarDate(dateKey: string) {
selectedDate.value = dateKey
}
onMounted(() => {
void loadDailyDigestFn()
void loadSidebarPlanSnapshot(new Date())
@@ -211,6 +228,7 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
calendarCells, calendarYear, calendarMonth, todayPlanCounters, monthReviewStats,
sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown,
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
sidebarFeedItems, topbarFeedItems, loadSidebarPlanSnapshot, sidebarCollapsedModules
sidebarFeedItems, topbarFeedItems, loadSidebarPlanSnapshot, sidebarCollapsedModules,
selectedDate, selectCalendarDate, conversationDateMap
}
}

View File

@@ -50,6 +50,9 @@ const {
systemTelemetry,
sessionTelemetry,
sendMessage,
selectConversation,
newConversation,
loadConversations,
formatTime,
autoResize,
handleFileSelect,
@@ -74,8 +77,9 @@ const {
calendarCells, calendarYear, calendarMonth, todayPlanCounters,
sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown,
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules
} = useSidebarPlan(clientTime, loadDailyDigest)
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules,
selectedDate, selectCalendarDate
} = useSidebarPlan(clientTime, loadDailyDigest, store.conversations)
// --- Local UI state ---
const sidebarCollapsed = ref(false)
@@ -111,6 +115,33 @@ function closeKnowledgeHud() { knowledgeHudOpen.value = false }
function handleSelectFolder(folder: any) { selectedFolder.value = folder }
function handleOpenPreview(doc: any) { previewDoc.value = doc }
function handleCalendarDateSelect(dateKey: string) {
selectCalendarDate(dateKey)
// Reload conversations to get latest data
loadConversations().then(() => {
// Find conversation that matches the selected date (by updated_at)
const targetDate = new Date(dateKey)
const targetDateStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate())
const targetDateEnd = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate() + 1)
// Find conversation that falls on the selected date
const conversation = store.conversations.find((conv) => {
const convDate = new Date(conv.updated_at)
return convDate >= targetDateStart && convDate < targetDateEnd
})
if (conversation) {
selectConversation(conversation.id)
} else {
// No conversation for this date, create a new one
newConversation()
}
}).catch((err) => {
console.error('[Calendar] Error loading conversations:', err)
})
}
// --- Message rendering utilities (kept inline for clarity) ---
function formatUptime(seconds: number) {
const days = Math.floor(seconds / 86400)
@@ -269,9 +300,11 @@ function renderMarkdown(content: string) {
v-for="cell in calendarCells"
:key="cell.key"
class="calendar-day"
:class="{ active: cell.active, busy: cell.busy, muted: cell.value === null }"
:class="{ active: cell.active, busy: cell.busy, muted: cell.value === null, selected: cell.selected, clickable: cell.hasConversation || cell.active }"
@click="(cell.hasConversation || cell.active) && handleCalendarDateSelect(cell.key)"
>
{{ cell.value ?? '' }}
<span v-if="cell.hasConversation" class="conv-indicator"></span>
</span>
</div>
</div>
@@ -355,24 +388,11 @@ function renderMarkdown(content: string) {
<div class="chat-model-panel">
<label class="chat-model-label" for="chat-model-select">
<Sparkles :size="12" />
<span>CHAT MODEL</span>
<span>MODEL</span>
</label>
<select
id="chat-model-select"
v-model="selectedModelName"
class="chat-model-select"
:disabled="isSending || isLoadingModels || chatModels.length === 0"
>
<option v-if="chatModels.length === 0" value="">
{{ isLoadingModels ? 'Loading models...' : 'No chat models' }}
</option>
<option v-for="model in chatModels" :key="model.name" :value="model.name">
{{ model.name }}
</option>
</select>
<div v-if="selectedModel" class="chat-model">
<div class="chat-model-display">
<Sparkles :size="12" />
<span>{{ selectedModel.model }}</span>
<span>{{ selectedModel?.model || selectedModelName || 'Default' }}</span>
</div>
</div>
</div>
@@ -578,21 +598,6 @@ function renderMarkdown(content: string) {
<div class="section-label">// RUNTIME STATUS</div>
<div class="runtime-meta-panel runtime-meta-panel-merged">
<div class="runtime-panel-title">SYSTEM</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">DATE</span>
<span class="runtime-meta-value">{{ formatClientDate(clientTime) }}</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">TIME</span>
<span class="runtime-meta-value">{{ formatClientClock(clientTime) }}</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">WEATHER</span>
<span class="runtime-meta-value weather-inline">
<i :class="['wi', weatherIcon]"></i>
<span>{{ weatherSummary }}</span>
</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">OS</span>
<span class="runtime-meta-value">{{ systemMeta.systemName }}</span>