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
+214 -214
View File
@@ -3,7 +3,7 @@ import { ref, computed, onMounted, nextTick } from "vue";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import type { Note, NoteListResponse } from "@/types/note"; import type { Note, NoteListResponse } from "@/types/note";
import type { Task, TaskListResponse } from "@/types/task"; 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 NoteCard from "@/components/NoteCard.vue";
import TaskCard from "@/components/TaskCard.vue"; import TaskCard from "@/components/TaskCard.vue";
import ToolCallCard from "@/components/ToolCallCard.vue"; import ToolCallCard from "@/components/ToolCallCard.vue";
@@ -18,7 +18,7 @@ const dueTodayTasks = ref<Task[]>([]);
const dueThisWeekTasks = ref<Task[]>([]); const dueThisWeekTasks = ref<Task[]>([]);
const highPriorityTasks = ref<Task[]>([]); const highPriorityTasks = ref<Task[]>([]);
const inProgressTasks = ref<Task[]>([]); const inProgressTasks = ref<Task[]>([]);
const recentChats = ref<Conversation[]>([]); const otherTasks = ref<Task[]>([]);
const loading = ref(true); const loading = ref(true);
const tasksStore = useTasksStore(); const tasksStore = useTasksStore();
@@ -46,7 +46,6 @@ function sortByPriorityThenDate(tasks: Task[]): Task[] {
const pa = PRIORITY_ORDER[a.priority ?? "none"] ?? 0; const pa = PRIORITY_ORDER[a.priority ?? "none"] ?? 0;
const pb = PRIORITY_ORDER[b.priority ?? "none"] ?? 0; const pb = PRIORITY_ORDER[b.priority ?? "none"] ?? 0;
if (pb !== pa) return pb - pa; 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 && b.due_date) return a.due_date.localeCompare(b.due_date);
if (a.due_date) return -1; if (a.due_date) return -1;
if (b.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 () => { onMounted(async () => {
const today = dateStr(0); const today = dateStr(0);
const nextWeek = dateStr(7); const nextWeek = dateStr(7);
const [notesRes, overdueRes, dueSoonRes, highPriRes, inProgressRes, chatsRes] = const [notesRes, overdueRes, dueSoonRes, highPriRes, inProgressRes, otherRes] =
await Promise.allSettled([ 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>( apiGet<TaskListResponse>(
`/api/tasks?due_before=${today}&sort=due_date&order=asc&limit=20` `/api/tasks?due_before=${today}&sort=due_date&order=asc&limit=20`
), ),
@@ -73,22 +86,18 @@ onMounted(async () => {
apiGet<TaskListResponse>( apiGet<TaskListResponse>(
`/api/tasks?status=in_progress&sort=updated_at&order=desc&limit=10` `/api/tasks?status=in_progress&sort=updated_at&order=desc&limit=10`
), ),
apiGet<{ conversations: Conversation[]; total: number }>( apiGet<TaskListResponse>(
"/api/chat/conversations?limit=3&offset=0" `/api/tasks?sort=updated_at&order=desc&limit=50`
), ),
]); ]);
if (notesRes.status === "fulfilled") { if (notesRes.status === "fulfilled") {
recentNotes.value = notesRes.value.notes; 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 // Build seen-set incrementally so each section deduplicates against all above it
const seen = new Set<number>(); const seen = new Set<number>();
// Overdue: past due, not done
if (overdueRes.status === "fulfilled") { if (overdueRes.status === "fulfilled") {
overdueTasks.value = sortByPriorityThenDate( overdueTasks.value = sortByPriorityThenDate(
overdueRes.value.tasks.filter((t) => t.status !== "done") overdueRes.value.tasks.filter((t) => t.status !== "done")
@@ -96,7 +105,6 @@ onMounted(async () => {
for (const t of overdueTasks.value) seen.add(t.id); for (const t of overdueTasks.value) seen.add(t.id);
} }
// Due soon: split into "today" and "this week", exclude done
if (dueSoonRes.status === "fulfilled") { if (dueSoonRes.status === "fulfilled") {
const notDone = dueSoonRes.value.tasks.filter( const notDone = dueSoonRes.value.tasks.filter(
(t) => t.status !== "done" && !seen.has(t.id) (t) => t.status !== "done" && !seen.has(t.id)
@@ -116,7 +124,6 @@ onMounted(async () => {
for (const t of dueThisWeekTasks.value) seen.add(t.id); for (const t of dueThisWeekTasks.value) seen.add(t.id);
} }
// High priority: not done, deduplicated
if (highPriRes.status === "fulfilled") { if (highPriRes.status === "fulfilled") {
highPriorityTasks.value = highPriRes.value.tasks.filter( highPriorityTasks.value = highPriRes.value.tasks.filter(
(t) => t.status !== "done" && !seen.has(t.id) (t) => t.status !== "done" && !seen.has(t.id)
@@ -124,23 +131,41 @@ onMounted(async () => {
for (const t of highPriorityTasks.value) seen.add(t.id); for (const t of highPriorityTasks.value) seen.add(t.id);
} }
// In progress: deduplicated against all above
if (inProgressRes.status === "fulfilled") { if (inProgressRes.status === "fulfilled") {
inProgressTasks.value = inProgressRes.value.tasks.filter( inProgressTasks.value = inProgressRes.value.tasks.filter(
(t) => !seen.has(t.id) (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; loading.value = false;
nextTick(() => chatInputRef.value?.focus()); 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 = [ const allTaskLists = [
() => overdueTasks, () => overdueTasks,
() => dueTodayTasks, () => dueTodayTasks,
() => dueThisWeekTasks, () => dueThisWeekTasks,
() => highPriorityTasks, () => highPriorityTasks,
() => inProgressTasks, () => inProgressTasks,
() => otherTasks,
]; ];
function removeFromAllLists(id: number) { function removeFromAllLists(id: number) {
@@ -168,24 +193,20 @@ function onStatusToggle(id: number, status: TaskStatus) {
} }
const chatInputRef = ref<{ focus: () => void } | null>(null); const chatInputRef = ref<{ focus: () => void } | null>(null);
const chatStore = useChatStore(); const chatStore = useChatStore();
// Warm default model after status fetch
chatStore.fetchStatus().then(() => { chatStore.fetchStatus().then(() => {
if (chatStore.defaultModel) { if (chatStore.defaultModel) {
chatStore.warmModel(chatStore.defaultModel); chatStore.warmModel(chatStore.defaultModel);
} }
}); });
// Dashboard inline response state
const dashboardConvId = ref<number | null>(null); const dashboardConvId = ref<number | null>(null);
const dashboardDone = ref(false); const dashboardDone = ref(false);
const dashboardQuery = ref(""); const dashboardQuery = ref("");
const dashboardFinalContent = ref(""); const dashboardFinalContent = ref("");
const dashboardFinalToolCalls = ref<ToolCallRecord[]>([]); const dashboardFinalToolCalls = ref<ToolCallRecord[]>([]);
// True when the response is pure text with no tool actions — suggest continuing in chat
const isConversational = computed( const isConversational = computed(
() => dashboardDone.value && dashboardFinalToolCalls.value.length === 0 () => dashboardDone.value && dashboardFinalToolCalls.value.length === 0
); );
@@ -208,10 +229,8 @@ async function onChatSubmit(payload: { content: string; contextNoteId?: number }
await chatStore.fetchConversation(conv.id); await chatStore.fetchConversation(conv.id);
dashboardConvId.value = conv.id; dashboardConvId.value = conv.id;
// Stream inline — sendMessage awaits until SSE is complete
await chatStore.sendMessage(payload.content, payload.contextNoteId); 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 msgs = chatStore.currentConversation?.messages ?? [];
const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === "assistant"); const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === "assistant");
dashboardFinalContent.value = lastAssistant?.content ?? ""; dashboardFinalContent.value = lastAssistant?.content ?? "";
@@ -234,93 +253,9 @@ function clearDashboardResponse() {
<template> <template>
<main class="home"> <main class="home">
<h1>Fabled Assistant</h1>
<p v-if="loading" class="loading">Loading...</p> <!-- Chat widget full width at top -->
<section class="chat-section">
<template v-else>
<section v-if="overdueTasks.length" class="section section-overdue">
<div class="section-header">
<h2>Overdue</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>
<!-- Quick action chips -->
<div class="quick-actions"> <div class="quick-actions">
<button <button
v-for="q in QUICK_ACTIONS" v-for="q in QUICK_ACTIONS"
@@ -332,14 +267,13 @@ function clearDashboardResponse() {
{{ q }} {{ q }}
</button> </button>
</div> </div>
<DashboardChatInput ref="chatInputRef" @submit="onChatSubmit" /> <DashboardChatInput ref="chatInputRef" @submit="onChatSubmit" />
</section>
<!-- Inline streaming response --> <!-- Inline response full width -->
<div v-if="dashboardConvId" class="dashboard-response"> <div v-if="dashboardConvId" class="dashboard-response">
<div class="dashboard-response-query">{{ dashboardQuery }}</div> <div class="dashboard-response-query">{{ dashboardQuery }}</div>
<!-- Tool calls show live during streaming, final after done -->
<div <div
v-if="chatStore.streaming && chatStore.streamingToolCalls.length" v-if="chatStore.streaming && chatStore.streamingToolCalls.length"
class="dashboard-tool-calls" class="dashboard-tool-calls"
@@ -358,7 +292,6 @@ function clearDashboardResponse() {
/> />
</div> </div>
<!-- Streaming text -->
<div v-if="chatStore.streaming" class="dashboard-response-text streaming"> <div v-if="chatStore.streaming" class="dashboard-response-text streaming">
<div v-if="chatStore.streamingStatus && !chatStore.streamingContent" class="dashboard-status-line"> <div v-if="chatStore.streamingStatus && !chatStore.streamingContent" class="dashboard-status-line">
<span class="dashboard-status-dot"></span> <span class="dashboard-status-dot"></span>
@@ -367,7 +300,6 @@ function clearDashboardResponse() {
<span v-else-if="chatStore.streamingContent">{{ chatStore.streamingContent }}</span> <span v-else-if="chatStore.streamingContent">{{ chatStore.streamingContent }}</span>
<span v-else class="thinking-dots">...</span> <span v-else class="thinking-dots">...</span>
</div> </div>
<!-- Final text -->
<div v-else-if="dashboardDone && dashboardFinalContent" class="dashboard-response-text"> <div v-else-if="dashboardDone && dashboardFinalContent" class="dashboard-response-text">
{{ dashboardFinalContent }} {{ dashboardFinalContent }}
</div> </div>
@@ -380,30 +312,106 @@ function clearDashboardResponse() {
> >
{{ isConversational ? 'Continue the conversation →' : 'Think it through in Chat →' }} {{ isConversational ? 'Continue the conversation →' : 'Think it through in Chat →' }}
</router-link> </router-link>
<button class="btn-clear" @click="clearDashboardResponse">Clear</button> <button class="btn-clear-response" @click="clearDashboardResponse">Clear</button>
</div> </div>
</div> </div>
<!-- Recent chats --> <p v-if="loading" class="loading">Loading...</p>
<div v-if="recentChats.length" class="recent-chats">
<div class="recent-chats-label">Recent</div> <!-- Two-column grid -->
<div class="cards"> <div v-else class="dashboard-grid">
<router-link
v-for="chat in recentChats" <!-- Left: Tasks -->
:key="chat.id" <div class="col-tasks">
:to="`/chat/${chat.id}`" <div class="col-header">
class="chat-card" <h2>Tasks</h2>
> <router-link to="/tasks" class="see-all">See all</router-link>
<span class="chat-card-title">{{ chat.title || "Untitled" }}</span>
<span class="chat-card-meta">{{ chat.message_count }} messages</span>
</router-link>
</div> </div>
<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>
<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> </div>
</section> </section>
<section class="section"> <section v-if="dueTodayTasks.length" class="section">
<div class="section-header"> <h3 class="section-title">Due Today</h3>
<h2>Recently Edited</h2> <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">
<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 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> <router-link to="/notes" class="see-all">See all</router-link>
</div> </div>
<div v-if="recentNotes.length" class="cards"> <div v-if="recentNotes.length" class="cards">
@@ -417,87 +425,23 @@ function clearDashboardResponse() {
<p class="empty-text">No notes yet.</p> <p class="empty-text">No notes yet.</p>
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link> <router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</div> </div>
</section> </div>
</template>
</div>
</main> </main>
</template> </template>
<style scoped> <style scoped>
.home { .home {
max-width: 1200px; max-width: 1400px;
margin: 2rem auto; margin: 2rem auto;
padding: 0 1rem; 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 { .quick-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -525,7 +469,7 @@ function clearDashboardResponse() {
/* Inline response */ /* Inline response */
.dashboard-response { .dashboard-response {
margin-top: 0.75rem; margin-bottom: 1.5rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -604,7 +548,7 @@ function clearDashboardResponse() {
text-decoration: none; text-decoration: none;
opacity: 0.9; opacity: 0.9;
} }
.btn-clear { .btn-clear-response {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
background: none; background: none;
@@ -612,23 +556,72 @@ function clearDashboardResponse() {
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
} }
.btn-clear:hover { .btn-clear-response:hover {
color: var(--color-text); color: var(--color-text);
} }
/* Recent chats below */ /* Two-column grid */
.recent-chats { .dashboard-grid {
margin-top: 1rem; display: grid;
} grid-template-columns: 3fr 2fr;
.recent-chats-label { gap: 2rem;
font-size: 0.75rem; align-items: start;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
} }
/* 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 { .empty-state {
text-align: center; text-align: center;
padding: 1.5rem 0; padding: 1.5rem 0;
@@ -648,4 +641,11 @@ function clearDashboardResponse() {
font-size: 0.9rem; font-size: 0.9rem;
cursor: pointer; cursor: pointer;
} }
/* Mobile */
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
</style> </style>