Per-conv streaming state, immediate message_count commit, auto-new-chat on open
stores/chat.ts: - Replace 7 global stream refs with convStreams: Record<number, ConvStreamState> - Each conversation tracks its own stream state; 7 names become computed getters - Add isStreamingConv(id) public helper - Increment message_count +2 immediately after POST 202 (not at done) so the cleanup watcher never deletes a mid-generation conversation - SSE reconnect retries now run for background conversations regardless of which conv is currently viewed - error toasts only shown when the erroring conv is currently viewed - deleteConversation cleans up orphaned stream state ChatView.vue: - Remove invalid write to store.lastContextMeta (now a read-only computed) - Add !store.isStreamingConv(newId) guard to watch(convId) fetch so navigating back to a generating conversation shows accumulated content without stale refetch - Add startNewConversation() helper; opening /chat with no ID auto-creates a new conversation and redirects to it (on mount and on navigation) - Deleting a conversation also auto-creates a new one Also committing previous-session changes (keyboard nav + task routing): - App.vue: e shortcut only on note-view (not task-view, which is removed) - TaskCard.vue: route directly to /tasks/:id (viewer), remove View buttons - router/index.ts: remove task-view route; /tasks/:id now maps to TaskEditorView - TasksListView.vue: Enter key pushes to /tasks/:id Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -97,7 +97,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
break;
|
break;
|
||||||
case "e": {
|
case "e": {
|
||||||
const name = router.currentRoute.value.name;
|
const name = router.currentRoute.value.name;
|
||||||
if (name === "note-view" || name === "task-view") {
|
if (name === "note-view") {
|
||||||
router.push(router.currentRoute.value.path + "/edit");
|
router.push(router.currentRoute.value.path + "/edit");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import type { Task, TaskStatus } from "@/types/task";
|
import type { Task, TaskStatus } from "@/types/task";
|
||||||
import StatusBadge from "@/components/StatusBadge.vue";
|
import StatusBadge from "@/components/StatusBadge.vue";
|
||||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||||
@@ -12,7 +11,6 @@ const props = defineProps<{
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
projectTitle?: string;
|
projectTitle?: string;
|
||||||
}>();
|
}>();
|
||||||
const router = useRouter();
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"tag-click": [tag: string];
|
"tag-click": [tag: string];
|
||||||
"status-toggle": [id: number, status: TaskStatus];
|
"status-toggle": [id: number, status: TaskStatus];
|
||||||
@@ -49,7 +47,7 @@ function isOverdue(): boolean {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-link :to="`/tasks/${task.id}/edit`" :class="['task-card', { compact }]">
|
<router-link :to="`/tasks/${task.id}`" :class="['task-card', { compact }]">
|
||||||
|
|
||||||
<!-- Compact: single row -->
|
<!-- Compact: single row -->
|
||||||
<template v-if="compact">
|
<template v-if="compact">
|
||||||
@@ -64,7 +62,6 @@ function isOverdue(): boolean {
|
|||||||
<span v-if="task.due_date" :class="['due-compact', { overdue: isOverdue() }]">
|
<span v-if="task.due_date" :class="['due-compact', { overdue: isOverdue() }]">
|
||||||
{{ task.due_date }}
|
{{ task.due_date }}
|
||||||
</span>
|
</span>
|
||||||
<button class="btn-edit-compact" title="View detail" @click.prevent.stop="router.push(`/tasks/${props.task.id}`)">View</button>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Full: original layout -->
|
<!-- Full: original layout -->
|
||||||
@@ -77,7 +74,6 @@ function isOverdue(): boolean {
|
|||||||
/>
|
/>
|
||||||
<PriorityBadge :priority="task.priority!" />
|
<PriorityBadge :priority="task.priority!" />
|
||||||
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
|
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
|
||||||
<button class="btn-edit" title="View detail" @click.prevent.stop="router.push(`/tasks/${props.task.id}`)">View</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="task.body" class="task-preview prose" v-html="renderPreview(task.body)"></div>
|
<div v-if="task.body" class="task-preview prose" v-html="renderPreview(task.body)"></div>
|
||||||
<div class="task-meta">
|
<div class="task-meta">
|
||||||
@@ -177,26 +173,6 @@ function isOverdue(): boolean {
|
|||||||
color: var(--color-danger, #e74c3c);
|
color: var(--color-danger, #e74c3c);
|
||||||
font-weight: 600;
|
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 */
|
/* Full layout */
|
||||||
.task-top {
|
.task-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -213,21 +189,6 @@ function isOverdue(): boolean {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.task-preview {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|||||||
@@ -82,11 +82,6 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/tasks/:id",
|
path: "/tasks/:id",
|
||||||
name: "task-view",
|
|
||||||
component: () => import("@/views/TaskViewerView.vue"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/tasks/:id/edit",
|
|
||||||
name: "task-edit",
|
name: "task-edit",
|
||||||
component: () => import("@/views/TaskEditorView.vue"),
|
component: () => import("@/views/TaskEditorView.vue"),
|
||||||
},
|
},
|
||||||
|
|||||||
+99
-60
@@ -20,23 +20,53 @@ import type {
|
|||||||
ToolPendingRecord,
|
ToolPendingRecord,
|
||||||
} from "@/types/chat";
|
} 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", () => {
|
export const useChatStore = defineStore("chat", () => {
|
||||||
const conversations = ref<Conversation[]>([]);
|
const conversations = ref<Conversation[]>([]);
|
||||||
const currentConversation = ref<ConversationDetail | null>(null);
|
const currentConversation = ref<ConversationDetail | null>(null);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const streaming = ref(false);
|
const convStreams = ref<Record<number, ConvStreamState>>({});
|
||||||
const streamingContent = ref("");
|
|
||||||
const streamingThinking = ref("");
|
|
||||||
const streamingToolCalls = ref<ToolCallRecord[]>([]);
|
|
||||||
const streamingStatus = ref("");
|
|
||||||
const streamingPendingTool = ref<ToolPendingRecord | null>(null);
|
|
||||||
const lastContextMeta = ref<ContextMeta | null>(null);
|
|
||||||
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
|
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
|
||||||
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
|
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
|
||||||
const defaultModel = ref("");
|
const defaultModel = ref("");
|
||||||
let statusPollTimer: ReturnType<typeof setInterval> | null = null;
|
let statusPollTimer: ReturnType<typeof setInterval> | 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)
|
// Chat is usable when Ollama is up and model is at least installed (cold starts still work)
|
||||||
const chatReady = computed(
|
const chatReady = computed(
|
||||||
() => ollamaStatus.value === "available" && (modelStatus.value === "loaded" || modelStatus.value === "cold")
|
() => ollamaStatus.value === "available" && (modelStatus.value === "loaded" || modelStatus.value === "cold")
|
||||||
@@ -92,6 +122,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
if (currentConversation.value?.id === id) {
|
if (currentConversation.value?.id === id) {
|
||||||
currentConversation.value = null;
|
currentConversation.value = null;
|
||||||
}
|
}
|
||||||
|
delete convStreams.value[id];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useToastStore().show("Failed to delete conversation", "error");
|
useToastStore().show("Failed to delete conversation", "error");
|
||||||
throw e;
|
throw e;
|
||||||
@@ -128,11 +159,12 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
if (!currentConversation.value) return;
|
if (!currentConversation.value) return;
|
||||||
|
|
||||||
const convId = currentConversation.value.id;
|
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
|
// If a write tool is waiting for confirmation, intercept single-word yes/no
|
||||||
// responses rather than sending them as a new message.
|
// responses rather than sending them as a new message.
|
||||||
if (streamingPendingTool.value) {
|
if (s.pendingTool) {
|
||||||
const lower = content.trim().toLowerCase();
|
const lower = content.trim().toLowerCase();
|
||||||
const YES = new Set(["yes", "y", "ok", "sure", "proceed", "accept", "confirm", "do it", "go ahead", "yeah", "yep"]);
|
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"]);
|
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);
|
currentConversation.value.messages.push(userMsg);
|
||||||
|
|
||||||
streaming.value = true;
|
s.streaming = true;
|
||||||
streamingContent.value = "";
|
s.content = "";
|
||||||
|
|
||||||
// Phase 1: POST to start generation
|
// Phase 1: POST to start generation
|
||||||
let assistantMessageId: number;
|
let assistantMessageId: number;
|
||||||
@@ -176,12 +208,16 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
);
|
);
|
||||||
assistantMessageId = resp.assistant_message_id;
|
assistantMessageId = resp.assistant_message_id;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
streaming.value = false;
|
s.streaming = false;
|
||||||
streamingContent.value = "";
|
s.content = "";
|
||||||
useToastStore().show("Failed to send message", "error");
|
useToastStore().show("Failed to send message", "error");
|
||||||
return;
|
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
|
// Phase 2: Connect to SSE stream with reconnection
|
||||||
await _streamGeneration(convId, assistantMessageId);
|
await _streamGeneration(convId, assistantMessageId);
|
||||||
}
|
}
|
||||||
@@ -201,31 +237,30 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
(lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""),
|
(lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""),
|
||||||
(event) => {
|
(event) => {
|
||||||
lastEventId = event.id;
|
lastEventId = event.id;
|
||||||
|
const s = convStreams.value[convId];
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
switch (event.event) {
|
switch (event.event) {
|
||||||
case "context":
|
case "context":
|
||||||
lastContextMeta.value = event.data.context as ContextMeta;
|
s.contextMeta = event.data.context as ContextMeta;
|
||||||
break;
|
break;
|
||||||
case "status":
|
case "status":
|
||||||
streamingStatus.value = event.data.status as string;
|
s.status = event.data.status as string;
|
||||||
break;
|
break;
|
||||||
case "thinking_chunk":
|
case "thinking_chunk":
|
||||||
streamingThinking.value += event.data.chunk as string;
|
s.thinking += event.data.chunk as string;
|
||||||
break;
|
break;
|
||||||
case "chunk":
|
case "chunk":
|
||||||
streamingContent.value += event.data.chunk as string;
|
s.content += event.data.chunk as string;
|
||||||
streamingStatus.value = "";
|
s.status = "";
|
||||||
break;
|
break;
|
||||||
case "tool_pending":
|
case "tool_pending":
|
||||||
streamingPendingTool.value = event.data.tool_pending as ToolPendingRecord;
|
s.pendingTool = event.data.tool_pending as ToolPendingRecord;
|
||||||
break;
|
break;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
// Receiving a tool_call clears the pending confirmation card
|
// Receiving a tool_call clears the pending confirmation card
|
||||||
streamingPendingTool.value = null;
|
s.pendingTool = null;
|
||||||
streamingToolCalls.value = [
|
s.toolCalls = [...s.toolCalls, event.data.tool_call as ToolCallRecord];
|
||||||
...streamingToolCalls.value,
|
|
||||||
event.data.tool_call as ToolCallRecord,
|
|
||||||
];
|
|
||||||
break;
|
break;
|
||||||
case "done":
|
case "done":
|
||||||
gotDone = true;
|
gotDone = true;
|
||||||
@@ -234,45 +269,44 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
id: event.data.message_id as number,
|
id: event.data.message_id as number,
|
||||||
conversation_id: convId,
|
conversation_id: convId,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: streamingContent.value,
|
content: s.content,
|
||||||
context_note_id: null,
|
context_note_id: null,
|
||||||
tool_calls: streamingToolCalls.value.length
|
tool_calls: s.toolCalls.length ? [...s.toolCalls] : null,
|
||||||
? [...streamingToolCalls.value]
|
|
||||||
: null,
|
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
timing: event.data.timing as GenerationTiming | undefined,
|
timing: event.data.timing as GenerationTiming | undefined,
|
||||||
thinking: streamingThinking.value || undefined,
|
thinking: s.thinking || undefined,
|
||||||
};
|
};
|
||||||
if (currentConversation.value?.id === convId) {
|
if (currentConversation.value?.id === convId) {
|
||||||
currentConversation.value.messages.push(assistantMsg);
|
currentConversation.value.messages.push(assistantMsg);
|
||||||
}
|
}
|
||||||
streamingContent.value = "";
|
// Update updated_at only — message_count was already incremented at send time
|
||||||
streamingThinking.value = "";
|
|
||||||
streamingToolCalls.value = [];
|
|
||||||
streamingStatus.value = "";
|
|
||||||
streamingPendingTool.value = null;
|
|
||||||
streaming.value = false;
|
|
||||||
|
|
||||||
// Update conversation in list
|
|
||||||
const idx = conversations.value.findIndex((c) => c.id === convId);
|
const idx = conversations.value.findIndex((c) => c.id === convId);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
conversations.value[idx].message_count += 2;
|
|
||||||
conversations.value[idx].updated_at = new Date().toISOString();
|
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;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
gotDone = true;
|
gotDone = true;
|
||||||
streaming.value = false;
|
s.streaming = false;
|
||||||
streamingContent.value = "";
|
s.content = "";
|
||||||
streamingThinking.value = "";
|
s.thinking = "";
|
||||||
streamingToolCalls.value = [];
|
s.toolCalls = [];
|
||||||
streamingStatus.value = "";
|
s.status = "";
|
||||||
streamingPendingTool.value = null;
|
s.pendingTool = null;
|
||||||
useToastStore().show(
|
if (currentConversation.value?.id === convId) {
|
||||||
"Chat error: " + (event.data.error as string),
|
useToastStore().show(
|
||||||
"error",
|
"Chat error: " + (event.data.error as string),
|
||||||
);
|
"error",
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -281,16 +315,15 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
// Wait for the stream to close (events are processed via callback above)
|
// Wait for the stream to close (events are processed via callback above)
|
||||||
await handle.done;
|
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) {
|
if (!gotDone && attempt < MAX_RETRIES) {
|
||||||
const delay = Math.min(1000 * 2 ** attempt, 10_000);
|
const delay = Math.min(1000 * 2 ** attempt, 10_000);
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
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) {
|
if (!gotDone && currentConversation.value?.id === convId) {
|
||||||
useToastStore().show(
|
useToastStore().show(
|
||||||
"Connection lost — response may be incomplete",
|
"Connection lost — response may be incomplete",
|
||||||
@@ -303,19 +336,24 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
streaming.value = false;
|
// Final cleanup (guards against done/error not having fired)
|
||||||
streamingContent.value = "";
|
const s = convStreams.value[convId];
|
||||||
streamingThinking.value = "";
|
if (s) {
|
||||||
streamingToolCalls.value = [];
|
s.streaming = false;
|
||||||
streamingStatus.value = "";
|
s.content = "";
|
||||||
streamingPendingTool.value = null;
|
s.thinking = "";
|
||||||
|
s.toolCalls = [];
|
||||||
|
s.status = "";
|
||||||
|
s.pendingTool = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmTool(confirmed: boolean) {
|
async function confirmTool(confirmed: boolean) {
|
||||||
const convId = currentConversation.value?.id;
|
const convId = currentConversation.value?.id;
|
||||||
if (!convId) return;
|
if (!convId) return;
|
||||||
// Optimistically clear so buttons disappear immediately
|
// Optimistically clear so buttons disappear immediately
|
||||||
streamingPendingTool.value = null;
|
const s = convStreams.value[convId];
|
||||||
|
if (s) s.pendingTool = null;
|
||||||
try {
|
try {
|
||||||
await apiPost(`/api/chat/conversations/${convId}/generation/confirm`, { confirmed });
|
await apiPost(`/api/chat/conversations/${convId}/generation/confirm`, { confirmed });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -416,6 +454,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
modelStatus,
|
modelStatus,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
chatReady,
|
chatReady,
|
||||||
|
isStreamingConv,
|
||||||
fetchConversations,
|
fetchConversations,
|
||||||
createConversation,
|
createConversation,
|
||||||
fetchConversation,
|
fetchConversation,
|
||||||
|
|||||||
@@ -85,8 +85,12 @@ const inputPlaceholder = computed(() => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener("keydown", onGlobalKeydown);
|
document.addEventListener("keydown", onGlobalKeydown);
|
||||||
await store.fetchConversations();
|
await store.fetchConversations();
|
||||||
if (convId.value && store.currentConversation?.id !== convId.value) {
|
if (convId.value) {
|
||||||
await store.fetchConversation(convId.value);
|
if (store.currentConversation?.id !== convId.value) {
|
||||||
|
await store.fetchConversation(convId.value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await startNewConversation();
|
||||||
}
|
}
|
||||||
nextTick(() => inputEl.value?.focus());
|
nextTick(() => inputEl.value?.focus());
|
||||||
});
|
});
|
||||||
@@ -106,16 +110,15 @@ watch(convId, async (newId) => {
|
|||||||
suggestedNotes.value = [];
|
suggestedNotes.value = [];
|
||||||
autoInjectedNotes.value = [];
|
autoInjectedNotes.value = [];
|
||||||
excludedNoteIds.value = [];
|
excludedNoteIds.value = [];
|
||||||
store.lastContextMeta = null;
|
|
||||||
if (newId) {
|
if (newId) {
|
||||||
// Skip re-fetch if this conversation is already loaded (avoids race
|
// Skip re-fetch if this conversation is already loaded or still streaming
|
||||||
// condition where a stale GET overwrites messages during streaming)
|
// (avoids a stale GET overwriting in-progress messages)
|
||||||
if (store.currentConversation?.id !== newId) {
|
if (store.currentConversation?.id !== newId && !store.isStreamingConv(newId)) {
|
||||||
await store.fetchConversation(newId);
|
await store.fetchConversation(newId);
|
||||||
}
|
}
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
} else {
|
} else {
|
||||||
store.currentConversation = null;
|
await startNewConversation();
|
||||||
}
|
}
|
||||||
nextTick(() => inputEl.value?.focus());
|
nextTick(() => inputEl.value?.focus());
|
||||||
});
|
});
|
||||||
@@ -177,10 +180,14 @@ async function selectConversation(id: number) {
|
|||||||
router.push(`/chat/${id}`);
|
router.push(`/chat/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function newConversation() {
|
async function startNewConversation() {
|
||||||
const conv = await store.createConversation();
|
const conv = await store.createConversation();
|
||||||
await store.fetchConversation(conv.id);
|
|
||||||
router.push(`/chat/${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) {
|
async function removeConversation(id: number) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
document.querySelector(".kb-active-item")?.scrollIntoView({ block: "nearest" });
|
document.querySelector(".kb-active-item")?.scrollIntoView({ block: "nearest" });
|
||||||
} else if (e.key === "Enter" && activeIndex.value >= 0) {
|
} else if (e.key === "Enter" && activeIndex.value >= 0) {
|
||||||
const task = store.tasks[activeIndex.value];
|
const task = store.tasks[activeIndex.value];
|
||||||
if (task) router.push(`/tasks/${task.id}/edit`);
|
if (task) router.push(`/tasks/${task.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -12,7 +12,13 @@
|
|||||||
> Include file-level details in the commit body when the change is non-trivial.
|
> Include file-level details in the commit body when the change is non-trivial.
|
||||||
|
|
||||||
## Last Updated
|
## 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<number, ConvStreamState>` 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
|
## Project Overview
|
||||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||||
|
|||||||
Reference in New Issue
Block a user