Redesign dashboard: chat-first layout, two-column task/notes grid

- Remove page title; move chat widget (quick actions + input) to top full-width
- Remove recent chats section beneath the widget
- Inline streaming response stays full-width between widget and grid
- Two-column grid below (3fr tasks / 2fr notes, collapses to 1 col on mobile)
- Left column: all active tasks categorized by urgency — Overdue, Due Today,
  Due This Week, High Priority, In Progress, then Other (capped at 10)
- Other section: broad fetch of all non-done tasks deduped against shown
  sections; sorted due-dated items first (asc), then undated by priority
- Right column: recent notes bumped from 5 to 8
- Max-width increased from 1200px to 1400px
- Section labels styled as small uppercase headings; overdue/high-priority
  retain colored left-border treatment
- Single "See all" link per column header instead of per-section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 22:31:21 -05:00
parent b146b0a494
commit 18fc6280a2
+264 -264
View File
@@ -3,7 +3,7 @@ import { ref, computed, onMounted, nextTick } from "vue";
import { apiGet } from "@/api/client";
import type { Note, NoteListResponse } from "@/types/note";
import type { Task, TaskListResponse } from "@/types/task";
import type { Conversation, ToolCallRecord, Message } from "@/types/chat";
import type { ToolCallRecord, Message } from "@/types/chat";
import NoteCard from "@/components/NoteCard.vue";
import TaskCard from "@/components/TaskCard.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
@@ -18,7 +18,7 @@ const dueTodayTasks = ref<Task[]>([]);
const dueThisWeekTasks = ref<Task[]>([]);
const highPriorityTasks = ref<Task[]>([]);
const inProgressTasks = ref<Task[]>([]);
const recentChats = ref<Conversation[]>([]);
const otherTasks = ref<Task[]>([]);
const loading = ref(true);
const tasksStore = useTasksStore();
@@ -46,7 +46,6 @@ function sortByPriorityThenDate(tasks: Task[]): Task[] {
const pa = PRIORITY_ORDER[a.priority ?? "none"] ?? 0;
const pb = PRIORITY_ORDER[b.priority ?? "none"] ?? 0;
if (pb !== pa) return pb - pa;
// Earlier due dates first; null due dates last
if (a.due_date && b.due_date) return a.due_date.localeCompare(b.due_date);
if (a.due_date) return -1;
if (b.due_date) return 1;
@@ -54,13 +53,27 @@ function sortByPriorityThenDate(tasks: Task[]): Task[] {
});
}
function sortOtherTasks(tasks: Task[]): Task[] {
const withDate = tasks
.filter((t) => t.due_date)
.sort((a, b) => a.due_date!.localeCompare(b.due_date!));
const withoutDate = tasks
.filter((t) => !t.due_date)
.sort((a, b) => {
const pa = PRIORITY_ORDER[a.priority ?? "none"] ?? 0;
const pb = PRIORITY_ORDER[b.priority ?? "none"] ?? 0;
return pb - pa;
});
return [...withDate, ...withoutDate].slice(0, 10);
}
onMounted(async () => {
const today = dateStr(0);
const nextWeek = dateStr(7);
const [notesRes, overdueRes, dueSoonRes, highPriRes, inProgressRes, chatsRes] =
const [notesRes, overdueRes, dueSoonRes, highPriRes, inProgressRes, otherRes] =
await Promise.allSettled([
apiGet<NoteListResponse>("/api/notes?sort=updated_at&order=desc&limit=5"),
apiGet<NoteListResponse>("/api/notes?sort=updated_at&order=desc&limit=8"),
apiGet<TaskListResponse>(
`/api/tasks?due_before=${today}&sort=due_date&order=asc&limit=20`
),
@@ -73,22 +86,18 @@ onMounted(async () => {
apiGet<TaskListResponse>(
`/api/tasks?status=in_progress&sort=updated_at&order=desc&limit=10`
),
apiGet<{ conversations: Conversation[]; total: number }>(
"/api/chat/conversations?limit=3&offset=0"
apiGet<TaskListResponse>(
`/api/tasks?sort=updated_at&order=desc&limit=50`
),
]);
if (notesRes.status === "fulfilled") {
recentNotes.value = notesRes.value.notes;
}
if (chatsRes.status === "fulfilled") {
recentChats.value = chatsRes.value.conversations;
}
// Build seen-set incrementally so each section deduplicates against all above it
const seen = new Set<number>();
// Overdue: past due, not done
if (overdueRes.status === "fulfilled") {
overdueTasks.value = sortByPriorityThenDate(
overdueRes.value.tasks.filter((t) => t.status !== "done")
@@ -96,7 +105,6 @@ onMounted(async () => {
for (const t of overdueTasks.value) seen.add(t.id);
}
// Due soon: split into "today" and "this week", exclude done
if (dueSoonRes.status === "fulfilled") {
const notDone = dueSoonRes.value.tasks.filter(
(t) => t.status !== "done" && !seen.has(t.id)
@@ -116,7 +124,6 @@ onMounted(async () => {
for (const t of dueThisWeekTasks.value) seen.add(t.id);
}
// High priority: not done, deduplicated
if (highPriRes.status === "fulfilled") {
highPriorityTasks.value = highPriRes.value.tasks.filter(
(t) => t.status !== "done" && !seen.has(t.id)
@@ -124,23 +131,41 @@ onMounted(async () => {
for (const t of highPriorityTasks.value) seen.add(t.id);
}
// In progress: deduplicated against all above
if (inProgressRes.status === "fulfilled") {
inProgressTasks.value = inProgressRes.value.tasks.filter(
(t) => !seen.has(t.id)
);
for (const t of inProgressTasks.value) seen.add(t.id);
}
if (otherRes.status === "fulfilled") {
const remaining = otherRes.value.tasks.filter(
(t) => t.status !== "done" && !seen.has(t.id)
);
otherTasks.value = sortOtherTasks(remaining);
}
loading.value = false;
nextTick(() => chatInputRef.value?.focus());
});
const noTasks = computed(
() =>
overdueTasks.value.length === 0 &&
dueTodayTasks.value.length === 0 &&
dueThisWeekTasks.value.length === 0 &&
highPriorityTasks.value.length === 0 &&
inProgressTasks.value.length === 0 &&
otherTasks.value.length === 0
);
const allTaskLists = [
() => overdueTasks,
() => dueTodayTasks,
() => dueThisWeekTasks,
() => highPriorityTasks,
() => inProgressTasks,
() => otherTasks,
];
function removeFromAllLists(id: number) {
@@ -168,24 +193,20 @@ function onStatusToggle(id: number, status: TaskStatus) {
}
const chatInputRef = ref<{ focus: () => void } | null>(null);
const chatStore = useChatStore();
// Warm default model after status fetch
chatStore.fetchStatus().then(() => {
if (chatStore.defaultModel) {
chatStore.warmModel(chatStore.defaultModel);
}
});
// Dashboard inline response state
const dashboardConvId = ref<number | null>(null);
const dashboardDone = ref(false);
const dashboardQuery = ref("");
const dashboardFinalContent = ref("");
const dashboardFinalToolCalls = ref<ToolCallRecord[]>([]);
// True when the response is pure text with no tool actions — suggest continuing in chat
const isConversational = computed(
() => dashboardDone.value && dashboardFinalToolCalls.value.length === 0
);
@@ -208,10 +229,8 @@ async function onChatSubmit(payload: { content: string; contextNoteId?: number }
await chatStore.fetchConversation(conv.id);
dashboardConvId.value = conv.id;
// Stream inline — sendMessage awaits until SSE is complete
await chatStore.sendMessage(payload.content, payload.contextNoteId);
// Capture final response from the message pushed by the store's done handler
const msgs = chatStore.currentConversation?.messages ?? [];
const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === "assistant");
dashboardFinalContent.value = lastAssistant?.content ?? "";
@@ -234,176 +253,165 @@ function clearDashboardResponse() {
<template>
<main class="home">
<h1>Fabled Assistant</h1>
<!-- Chat widget full width at top -->
<section class="chat-section">
<div class="quick-actions">
<button
v-for="q in QUICK_ACTIONS"
:key="q"
class="quick-action-chip"
:disabled="chatStore.streaming || !chatStore.chatReady"
@click="onQuickAction(q)"
>
{{ q }}
</button>
</div>
<DashboardChatInput ref="chatInputRef" @submit="onChatSubmit" />
</section>
<!-- Inline response full width -->
<div v-if="dashboardConvId" class="dashboard-response">
<div class="dashboard-response-query">{{ dashboardQuery }}</div>
<div
v-if="chatStore.streaming && chatStore.streamingToolCalls.length"
class="dashboard-tool-calls"
>
<ToolCallCard
v-for="(tc, i) in chatStore.streamingToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<div v-else-if="dashboardDone && dashboardFinalToolCalls.length" class="dashboard-tool-calls">
<ToolCallCard
v-for="(tc, i) in dashboardFinalToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<div v-if="chatStore.streaming" class="dashboard-response-text streaming">
<div v-if="chatStore.streamingStatus && !chatStore.streamingContent" class="dashboard-status-line">
<span class="dashboard-status-dot"></span>
{{ chatStore.streamingStatus }}
</div>
<span v-else-if="chatStore.streamingContent">{{ chatStore.streamingContent }}</span>
<span v-else class="thinking-dots">...</span>
</div>
<div v-else-if="dashboardDone && dashboardFinalContent" class="dashboard-response-text">
{{ dashboardFinalContent }}
</div>
<div class="dashboard-response-actions" :class="{ conversational: isConversational }">
<router-link
:to="`/chat/${dashboardConvId}`"
class="btn-open-chat"
:class="{ prominent: isConversational }"
>
{{ isConversational ? 'Continue the conversation →' : 'Think it through in Chat →' }}
</router-link>
<button class="btn-clear-response" @click="clearDashboardResponse">Clear</button>
</div>
</div>
<p v-if="loading" class="loading">Loading...</p>
<template v-else>
<section v-if="overdueTasks.length" class="section section-overdue">
<div class="section-header">
<h2>Overdue</h2>
<!-- Two-column grid -->
<div v-else class="dashboard-grid">
<!-- Left: Tasks -->
<div class="col-tasks">
<div class="col-header">
<h2>Tasks</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in overdueTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="dueTodayTasks.length" class="section">
<div class="section-header">
<h2>Due Today</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in dueTodayTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="dueThisWeekTasks.length" class="section">
<div class="section-header">
<h2>Due This Week</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in dueThisWeekTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="highPriorityTasks.length" class="section section-high-priority">
<div class="section-header">
<h2>High Priority</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in highPriorityTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="inProgressTasks.length" class="section">
<div class="section-header">
<h2>In Progress</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in inProgressTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Ask Fable</h2>
<router-link to="/chat" class="see-all">All chats</router-link>
<div v-if="noTasks" class="empty-state">
<p class="empty-text">No active tasks.</p>
<router-link to="/tasks/new" class="btn-cta">+ New Task</router-link>
</div>
<!-- Quick action chips -->
<div class="quick-actions">
<button
v-for="q in QUICK_ACTIONS"
:key="q"
class="quick-action-chip"
:disabled="chatStore.streaming || !chatStore.chatReady"
@click="onQuickAction(q)"
>
{{ q }}
</button>
</div>
<DashboardChatInput ref="chatInputRef" @submit="onChatSubmit" />
<!-- Inline streaming response -->
<div v-if="dashboardConvId" class="dashboard-response">
<div class="dashboard-response-query">{{ dashboardQuery }}</div>
<!-- Tool calls show live during streaming, final after done -->
<div
v-if="chatStore.streaming && chatStore.streamingToolCalls.length"
class="dashboard-tool-calls"
>
<ToolCallCard
v-for="(tc, i) in chatStore.streamingToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<div v-else-if="dashboardDone && dashboardFinalToolCalls.length" class="dashboard-tool-calls">
<ToolCallCard
v-for="(tc, i) in dashboardFinalToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<!-- Streaming text -->
<div v-if="chatStore.streaming" class="dashboard-response-text streaming">
<div v-if="chatStore.streamingStatus && !chatStore.streamingContent" class="dashboard-status-line">
<span class="dashboard-status-dot"></span>
{{ chatStore.streamingStatus }}
<template v-else>
<section v-if="overdueTasks.length" class="section section-overdue">
<h3 class="section-title">Overdue</h3>
<div class="cards">
<TaskCard
v-for="task in overdueTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
<span v-else-if="chatStore.streamingContent">{{ chatStore.streamingContent }}</span>
<span v-else class="thinking-dots">...</span>
</div>
<!-- Final text -->
<div v-else-if="dashboardDone && dashboardFinalContent" class="dashboard-response-text">
{{ dashboardFinalContent }}
</div>
</section>
<div class="dashboard-response-actions" :class="{ conversational: isConversational }">
<router-link
:to="`/chat/${dashboardConvId}`"
class="btn-open-chat"
:class="{ prominent: isConversational }"
>
{{ isConversational ? 'Continue the conversation →' : 'Think it through in Chat →' }}
</router-link>
<button class="btn-clear" @click="clearDashboardResponse">Clear</button>
</div>
</div>
<section v-if="dueTodayTasks.length" class="section">
<h3 class="section-title">Due Today</h3>
<div class="cards">
<TaskCard
v-for="task in dueTodayTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<!-- Recent chats -->
<div v-if="recentChats.length" class="recent-chats">
<div class="recent-chats-label">Recent</div>
<div class="cards">
<router-link
v-for="chat in recentChats"
:key="chat.id"
:to="`/chat/${chat.id}`"
class="chat-card"
>
<span class="chat-card-title">{{ chat.title || "Untitled" }}</span>
<span class="chat-card-meta">{{ chat.message_count }} messages</span>
</router-link>
</div>
</div>
</section>
<section v-if="dueThisWeekTasks.length" class="section">
<h3 class="section-title">Due This Week</h3>
<div class="cards">
<TaskCard
v-for="task in dueThisWeekTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Recently Edited</h2>
<section v-if="highPriorityTasks.length" class="section section-high-priority">
<h3 class="section-title">High Priority</h3>
<div class="cards">
<TaskCard
v-for="task in highPriorityTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="inProgressTasks.length" class="section">
<h3 class="section-title">In Progress</h3>
<div class="cards">
<TaskCard
v-for="task in inProgressTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="otherTasks.length" class="section">
<h3 class="section-title">Other</h3>
<div class="cards">
<TaskCard
v-for="task in otherTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
</template>
</div>
<!-- Right: Notes -->
<div class="col-notes">
<div class="col-header">
<h2>Recent Notes</h2>
<router-link to="/notes" class="see-all">See all</router-link>
</div>
<div v-if="recentNotes.length" class="cards">
@@ -417,87 +425,23 @@ function clearDashboardResponse() {
<p class="empty-text">No notes yet.</p>
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</div>
</section>
</template>
</div>
</div>
</main>
</template>
<style scoped>
.home {
max-width: 1200px;
max-width: 1400px;
margin: 2rem auto;
padding: 0 1rem;
}
.home h1 {
margin: 0 0 1.5rem;
/* Chat section */
.chat-section {
margin-bottom: 1rem;
}
.loading {
color: var(--color-text-secondary);
}
.section {
margin-bottom: 2rem;
}
.section-overdue {
border-left: 3px solid var(--color-overdue, #e74c3c);
padding-left: 0.75rem;
}
.section-high-priority {
border-left: 3px solid var(--color-warning, #f59e0b);
padding-left: 0.75rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.section-header h2 {
margin: 0;
font-size: 1.15rem;
}
.see-all {
color: var(--color-primary);
text-decoration: none;
font-size: 0.9rem;
}
.see-all:hover {
text-decoration: underline;
}
.cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chat-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
text-decoration: none;
color: var(--color-text);
transition: border-color 0.15s;
}
.chat-card:hover {
border-color: var(--color-primary);
}
.chat-card-title {
font-size: 0.95rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 1rem;
}
.chat-card-meta {
font-size: 0.8rem;
color: var(--color-text-muted);
white-space: nowrap;
}
/* Quick actions */
.quick-actions {
display: flex;
flex-wrap: wrap;
@@ -525,7 +469,7 @@ function clearDashboardResponse() {
/* Inline response */
.dashboard-response {
margin-top: 0.75rem;
margin-bottom: 1.5rem;
padding: 0.75rem 1rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
@@ -604,7 +548,7 @@ function clearDashboardResponse() {
text-decoration: none;
opacity: 0.9;
}
.btn-clear {
.btn-clear-response {
font-size: 0.8rem;
color: var(--color-text-muted);
background: none;
@@ -612,23 +556,72 @@ function clearDashboardResponse() {
cursor: pointer;
padding: 0;
}
.btn-clear:hover {
.btn-clear-response:hover {
color: var(--color-text);
}
/* Recent chats below */
.recent-chats {
margin-top: 1rem;
}
.recent-chats-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
/* Two-column grid */
.dashboard-grid {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 2rem;
align-items: start;
}
/* Column headers */
.col-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.col-header h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.see-all {
color: var(--color-primary);
text-decoration: none;
font-size: 0.85rem;
}
.see-all:hover {
text-decoration: underline;
}
/* Task sections */
.section {
margin-bottom: 1.5rem;
}
.section-title {
margin: 0 0 0.6rem;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-muted);
}
.section-overdue {
border-left: 3px solid var(--color-danger, #e74c3c);
padding-left: 0.75rem;
}
.section-overdue .section-title {
color: var(--color-danger, #e74c3c);
}
.section-high-priority {
border-left: 3px solid var(--color-warning, #f59e0b);
padding-left: 0.75rem;
}
.cards {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.loading {
color: var(--color-text-secondary);
}
/* Empty states */
.empty-state {
text-align: center;
padding: 1.5rem 0;
@@ -648,4 +641,11 @@ function clearDashboardResponse() {
font-size: 0.9rem;
cursor: pointer;
}
/* Mobile */
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
</style>