@@ -64,7 +62,6 @@ function isOverdue(): boolean {
{{ task.due_date }}
-
@@ -77,7 +74,6 @@ function isOverdue(): boolean {
/>
{{ task.title || "Untitled" }}
-
@@ -177,26 +173,6 @@ function isOverdue(): boolean {
color: var(--color-danger, #e74c3c);
font-weight: 600;
}
-.btn-edit-compact {
- flex-shrink: 0;
- padding: 0.15rem 0.45rem;
- font-size: 0.75rem;
- background: none;
- color: var(--color-text-muted);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-sm);
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.15s, color 0.15s;
-}
-.task-card.compact:hover .btn-edit-compact {
- opacity: 1;
-}
-.btn-edit-compact:hover {
- color: var(--color-primary);
- border-color: var(--color-primary);
-}
-
/* Full layout */
.task-top {
display: flex;
@@ -213,21 +189,6 @@ function isOverdue(): boolean {
text-overflow: ellipsis;
white-space: nowrap;
}
-.btn-edit {
- flex-shrink: 0;
- padding: 0.25rem 0.6rem;
- font-size: 0.8rem;
- background: var(--color-bg-card);
- color: var(--color-text-secondary);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-sm);
- cursor: pointer;
- transition: color 0.15s, border-color 0.15s;
-}
-.btn-edit:hover {
- color: var(--color-primary);
- border-color: var(--color-primary);
-}
.task-preview {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index a70af7f..80ec252 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -82,11 +82,6 @@ const router = createRouter({
},
{
path: "/tasks/:id",
- name: "task-view",
- component: () => import("@/views/TaskViewerView.vue"),
- },
- {
- path: "/tasks/:id/edit",
name: "task-edit",
component: () => import("@/views/TaskEditorView.vue"),
},
diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts
index 407efee..b2aff0c 100644
--- a/frontend/src/stores/chat.ts
+++ b/frontend/src/stores/chat.ts
@@ -20,23 +20,53 @@ import type {
ToolPendingRecord,
} from "@/types/chat";
+interface ConvStreamState {
+ streaming: boolean;
+ content: string;
+ thinking: string;
+ toolCalls: ToolCallRecord[];
+ status: string;
+ pendingTool: ToolPendingRecord | null;
+ contextMeta: ContextMeta | null;
+}
+
export const useChatStore = defineStore("chat", () => {
const conversations = ref([]);
const currentConversation = ref(null);
const total = ref(0);
const loading = ref(false);
- const streaming = ref(false);
- const streamingContent = ref("");
- const streamingThinking = ref("");
- const streamingToolCalls = ref([]);
- const streamingStatus = ref("");
- const streamingPendingTool = ref(null);
- const lastContextMeta = ref(null);
+ const convStreams = ref>({});
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
const defaultModel = ref("");
let statusPollTimer: ReturnType | null = null;
+ function _getOrInitStream(id: number): ConvStreamState {
+ if (!convStreams.value[id]) {
+ convStreams.value[id] = {
+ streaming: false, content: "", thinking: "", toolCalls: [],
+ status: "", pendingTool: null, contextMeta: null,
+ };
+ }
+ return convStreams.value[id];
+ }
+
+ // Per-conversation computed getters — same names as old refs, zero callers change
+ const streaming = computed(() => {
+ const id = currentConversation.value?.id;
+ return !!id && (convStreams.value[id]?.streaming ?? false);
+ });
+ const streamingContent = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.content ?? "");
+ const streamingThinking = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.thinking ?? "");
+ const streamingToolCalls = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.toolCalls ?? []);
+ const streamingStatus = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.status ?? "");
+ const streamingPendingTool = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.pendingTool ?? null);
+ const lastContextMeta = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.contextMeta ?? null);
+
+ function isStreamingConv(id: number): boolean {
+ return convStreams.value[id]?.streaming ?? false;
+ }
+
// Chat is usable when Ollama is up and model is at least installed (cold starts still work)
const chatReady = computed(
() => ollamaStatus.value === "available" && (modelStatus.value === "loaded" || modelStatus.value === "cold")
@@ -92,6 +122,7 @@ export const useChatStore = defineStore("chat", () => {
if (currentConversation.value?.id === id) {
currentConversation.value = null;
}
+ delete convStreams.value[id];
} catch (e) {
useToastStore().show("Failed to delete conversation", "error");
throw e;
@@ -128,11 +159,12 @@ export const useChatStore = defineStore("chat", () => {
if (!currentConversation.value) return;
const convId = currentConversation.value.id;
- lastContextMeta.value = null;
+ const s = _getOrInitStream(convId);
+ s.contextMeta = null;
// If a write tool is waiting for confirmation, intercept single-word yes/no
// responses rather than sending them as a new message.
- if (streamingPendingTool.value) {
+ if (s.pendingTool) {
const lower = content.trim().toLowerCase();
const YES = new Set(["yes", "y", "ok", "sure", "proceed", "accept", "confirm", "do it", "go ahead", "yeah", "yep"]);
const NO = new Set(["no", "n", "cancel", "decline", "stop", "nope", "abort", "don't"]);
@@ -158,8 +190,8 @@ export const useChatStore = defineStore("chat", () => {
};
currentConversation.value.messages.push(userMsg);
- streaming.value = true;
- streamingContent.value = "";
+ s.streaming = true;
+ s.content = "";
// Phase 1: POST to start generation
let assistantMessageId: number;
@@ -176,12 +208,16 @@ export const useChatStore = defineStore("chat", () => {
);
assistantMessageId = resp.assistant_message_id;
} catch (e) {
- streaming.value = false;
- streamingContent.value = "";
+ s.streaming = false;
+ s.content = "";
useToastStore().show("Failed to send message", "error");
return;
}
+ // Immediately commit the message count so cleanup watcher never sees it as empty
+ const convInList = conversations.value.find((c) => c.id === convId);
+ if (convInList) convInList.message_count += 2;
+
// Phase 2: Connect to SSE stream with reconnection
await _streamGeneration(convId, assistantMessageId);
}
@@ -201,31 +237,30 @@ export const useChatStore = defineStore("chat", () => {
(lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""),
(event) => {
lastEventId = event.id;
+ const s = convStreams.value[convId];
+ if (!s) return;
switch (event.event) {
case "context":
- lastContextMeta.value = event.data.context as ContextMeta;
+ s.contextMeta = event.data.context as ContextMeta;
break;
case "status":
- streamingStatus.value = event.data.status as string;
+ s.status = event.data.status as string;
break;
case "thinking_chunk":
- streamingThinking.value += event.data.chunk as string;
+ s.thinking += event.data.chunk as string;
break;
case "chunk":
- streamingContent.value += event.data.chunk as string;
- streamingStatus.value = "";
+ s.content += event.data.chunk as string;
+ s.status = "";
break;
case "tool_pending":
- streamingPendingTool.value = event.data.tool_pending as ToolPendingRecord;
+ s.pendingTool = event.data.tool_pending as ToolPendingRecord;
break;
case "tool_call":
// Receiving a tool_call clears the pending confirmation card
- streamingPendingTool.value = null;
- streamingToolCalls.value = [
- ...streamingToolCalls.value,
- event.data.tool_call as ToolCallRecord,
- ];
+ s.pendingTool = null;
+ s.toolCalls = [...s.toolCalls, event.data.tool_call as ToolCallRecord];
break;
case "done":
gotDone = true;
@@ -234,45 +269,44 @@ export const useChatStore = defineStore("chat", () => {
id: event.data.message_id as number,
conversation_id: convId,
role: "assistant",
- content: streamingContent.value,
+ content: s.content,
context_note_id: null,
- tool_calls: streamingToolCalls.value.length
- ? [...streamingToolCalls.value]
- : null,
+ tool_calls: s.toolCalls.length ? [...s.toolCalls] : null,
created_at: new Date().toISOString(),
timing: event.data.timing as GenerationTiming | undefined,
- thinking: streamingThinking.value || undefined,
+ thinking: s.thinking || undefined,
};
if (currentConversation.value?.id === convId) {
currentConversation.value.messages.push(assistantMsg);
}
- streamingContent.value = "";
- streamingThinking.value = "";
- streamingToolCalls.value = [];
- streamingStatus.value = "";
- streamingPendingTool.value = null;
- streaming.value = false;
-
- // Update conversation in list
+ // Update updated_at only — message_count was already incremented at send time
const idx = conversations.value.findIndex((c) => c.id === convId);
if (idx !== -1) {
- conversations.value[idx].message_count += 2;
conversations.value[idx].updated_at = new Date().toISOString();
}
+ // Clear stream state
+ s.content = "";
+ s.thinking = "";
+ s.toolCalls = [];
+ s.status = "";
+ s.pendingTool = null;
+ s.streaming = false;
}
break;
case "error":
gotDone = true;
- streaming.value = false;
- streamingContent.value = "";
- streamingThinking.value = "";
- streamingToolCalls.value = [];
- streamingStatus.value = "";
- streamingPendingTool.value = null;
- useToastStore().show(
- "Chat error: " + (event.data.error as string),
- "error",
- );
+ s.streaming = false;
+ s.content = "";
+ s.thinking = "";
+ s.toolCalls = [];
+ s.status = "";
+ s.pendingTool = null;
+ if (currentConversation.value?.id === convId) {
+ useToastStore().show(
+ "Chat error: " + (event.data.error as string),
+ "error",
+ );
+ }
break;
}
},
@@ -281,16 +315,15 @@ export const useChatStore = defineStore("chat", () => {
// Wait for the stream to close (events are processed via callback above)
await handle.done;
- // If stream ended without done/error, attempt reconnection
+ // If stream ended without done/error, attempt reconnection regardless of which
+ // conv is currently viewed — background streams retry independently
if (!gotDone && attempt < MAX_RETRIES) {
const delay = Math.min(1000 * 2 ** attempt, 10_000);
await new Promise((r) => setTimeout(r, delay));
- if (currentConversation.value?.id === convId) {
- return _streamGeneration(convId, assistantMessageId, lastEventId, attempt + 1);
- }
+ return _streamGeneration(convId, assistantMessageId, lastEventId, attempt + 1);
}
- // Recovery fallback: re-fetch conversation from DB
+ // Recovery fallback: re-fetch conversation from DB only if currently viewing it
if (!gotDone && currentConversation.value?.id === convId) {
useToastStore().show(
"Connection lost — response may be incomplete",
@@ -303,19 +336,24 @@ export const useChatStore = defineStore("chat", () => {
}
}
- streaming.value = false;
- streamingContent.value = "";
- streamingThinking.value = "";
- streamingToolCalls.value = [];
- streamingStatus.value = "";
- streamingPendingTool.value = null;
+ // Final cleanup (guards against done/error not having fired)
+ const s = convStreams.value[convId];
+ if (s) {
+ s.streaming = false;
+ s.content = "";
+ s.thinking = "";
+ s.toolCalls = [];
+ s.status = "";
+ s.pendingTool = null;
+ }
}
async function confirmTool(confirmed: boolean) {
const convId = currentConversation.value?.id;
if (!convId) return;
// Optimistically clear so buttons disappear immediately
- streamingPendingTool.value = null;
+ const s = convStreams.value[convId];
+ if (s) s.pendingTool = null;
try {
await apiPost(`/api/chat/conversations/${convId}/generation/confirm`, { confirmed });
} catch {
@@ -416,6 +454,7 @@ export const useChatStore = defineStore("chat", () => {
modelStatus,
defaultModel,
chatReady,
+ isStreamingConv,
fetchConversations,
createConversation,
fetchConversation,
diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue
index 1ea7423..020cadb 100644
--- a/frontend/src/views/ChatView.vue
+++ b/frontend/src/views/ChatView.vue
@@ -85,8 +85,12 @@ const inputPlaceholder = computed(() => {
onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
await store.fetchConversations();
- if (convId.value && store.currentConversation?.id !== convId.value) {
- await store.fetchConversation(convId.value);
+ if (convId.value) {
+ if (store.currentConversation?.id !== convId.value) {
+ await store.fetchConversation(convId.value);
+ }
+ } else {
+ await startNewConversation();
}
nextTick(() => inputEl.value?.focus());
});
@@ -106,16 +110,15 @@ watch(convId, async (newId) => {
suggestedNotes.value = [];
autoInjectedNotes.value = [];
excludedNoteIds.value = [];
- store.lastContextMeta = null;
if (newId) {
- // Skip re-fetch if this conversation is already loaded (avoids race
- // condition where a stale GET overwrites messages during streaming)
- if (store.currentConversation?.id !== newId) {
+ // Skip re-fetch if this conversation is already loaded or still streaming
+ // (avoids a stale GET overwriting in-progress messages)
+ if (store.currentConversation?.id !== newId && !store.isStreamingConv(newId)) {
await store.fetchConversation(newId);
}
scrollToBottom();
} else {
- store.currentConversation = null;
+ await startNewConversation();
}
nextTick(() => inputEl.value?.focus());
});
@@ -177,10 +180,14 @@ async function selectConversation(id: number) {
router.push(`/chat/${id}`);
}
-async function newConversation() {
+async function startNewConversation() {
const conv = await store.createConversation();
- await store.fetchConversation(conv.id);
router.push(`/chat/${conv.id}`);
+ // watch(convId) will fire and fetch the conversation
+}
+
+async function newConversation() {
+ await startNewConversation();
}
async function removeConversation(id: number) {
diff --git a/frontend/src/views/TasksListView.vue b/frontend/src/views/TasksListView.vue
index 11c25cb..eeefa70 100644
--- a/frontend/src/views/TasksListView.vue
+++ b/frontend/src/views/TasksListView.vue
@@ -39,7 +39,7 @@ function onKeydown(e: KeyboardEvent) {
document.querySelector(".kb-active-item")?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && activeIndex.value >= 0) {
const task = store.tasks[activeIndex.value];
- if (task) router.push(`/tasks/${task.id}/edit`);
+ if (task) router.push(`/tasks/${task.id}`);
}
}
diff --git a/summary.md b/summary.md
index 24634ec..27f321c 100644
--- a/summary.md
+++ b/summary.md
@@ -12,7 +12,13 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
-2026-03-04 — Keyboard navigation overhaul: `e` to edit on viewer pages; `/` to focus search bar (custom event dispatch); `c` to focus chat on home / navigate to chat elsewhere; `j`/`k` vim-style list navigation with visual highlight in Notes and Tasks flat views; `Enter` opens selected item; `a:focus-visible` CSS rule added so router-link cards show visible focus outlines; `SearchBar.vue` exposes `focus()` via `defineExpose`; shortcuts panel updated with Lists and Chat sections. Also in this session: Task cards route directly to edit view; TaskViewerView added sub-task list with progress bar and inline status cycling; NoteViewerView shows project/milestone/parent breadcrumb; Dashboard shows Active Projects in tasks column (2-per-row), 50/50 column layout, overflow fixed with `overflow-x: clip` + `min-width: 0` on all page containers.
+2026-03-04 — Per-conversation streaming state + auto-new-chat on open.
+
+**Chat store refactor (`stores/chat.ts`):** Replaced 7 global stream refs (`streaming`, `streamingContent`, `streamingThinking`, `streamingToolCalls`, `streamingStatus`, `streamingPendingTool`, `lastContextMeta`) with a single `convStreams: Record` map. Each conversation now tracks its own stream state independently. Public API is unchanged — the 7 names are now computed getters derived from the current conversation's slot. Added `isStreamingConv(id)` helper. Key behaviour changes: `message_count` is incremented by 2 immediately after POST 202 (not at `done`) so the cleanup watcher never deletes a conversation that is mid-generation; SSE reconnect retries now run regardless of which conversation is currently viewed (background streams persist); `error` toasts only shown when viewing that conversation; `deleteConversation` cleans up orphaned stream state.
+
+**ChatView.vue:** Removed invalid write to `store.lastContextMeta` (now a read-only computed). Added `!store.isStreamingConv(newId)` guard to the `watch(convId)` fetch so navigating back to a generating conversation shows accumulated content without a stale refetch. Added `startNewConversation()` helper; opening `/chat` with no ID now automatically creates a new conversation and redirects to it (both on mount and when navigating to `/chat` from elsewhere). Deleting a conversation also auto-creates a new one.
+
+**Previous session (same date):** Keyboard navigation overhaul: `e` to edit on viewer pages; `/` to focus search bar; `c` to focus chat on home / navigate to chat elsewhere; `j`/`k` vim-style list navigation; `Enter` opens selected item; `a:focus-visible` focus-ring rule. Task cards route directly to `/tasks/:id` (edit view); `task-view` route removed; `TasksListView` `Enter` key updated accordingly. `TaskViewerView` sub-task list with progress bar and inline status cycling; `NoteViewerView` project/milestone/parent breadcrumb; Dashboard Active Projects, 50/50 layout, overflow fixes.
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with