Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 21 additions & 19 deletions src/components/ai/AssistChat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,18 @@
<span>Past conversations</span>
<button class="btn btn-xs btn-secondary" @click="newChat"><Icon name="plus" :size="12" /> New chat</button>
</div>
<div v-if="!pastSessions.length" class="history-empty">
<div v-if="store.listing" class="history-empty">
<p>Loading conversations</p>
</div>
<div v-else-if="!store.sessions.length" class="history-empty">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI currently handles 'loading' and 'empty' states for history, but it doesn't display error feedback if the API call fails. Since store.listSessions catches errors and sets a ref, you should display that error to the user.

Suggested change
<div v-else-if="!store.sessions.length" class="history-empty">
<div v-else-if="store.error" class="history-empty">
<Icon name="alert-circle" :size="28" class="text-error" />
<p>{{ store.error }}</p>
</div>
<div v-else-if="!store.sessions.length" class="history-empty">

<Icon name="message-square" :size="28" />
<p>Your past conversations will appear here.</p>
</div>
<button v-for="s in pastSessions" v-else :key="s.id" class="history-item" @click="loadPast(s)">
<button v-for="s in store.sessions" v-else :key="s.id" class="history-item" @click="loadPast(s.id)">
<Icon name="message-square" :size="16" class="history-item-icon" />
<span class="history-item-title">{{ s.title }}</span>
<span class="history-item-meta">{{ s.when }}</span>
<span class="history-item-meta">{{ formatWhen(s.updated_at) }}</span>
</button>
<p class="history-note">History is a preview; saved conversations arrive with persistence.</p>
</div>

<div v-else ref="scrollEl" class="chat-body">
Expand Down Expand Up @@ -265,6 +267,7 @@ const newChat = () => {

const toggleHistory = () => {
view.value = view.value === "history" ? "chat" : "history";
if (view.value === "history") store.listSessions();
};

const sampleQueries = computed(() =>
Expand All @@ -286,22 +289,21 @@ const runSample = (q: string) => {
store.send(q);
};

interface PastSession {
id: string;
title: string;
when: string;
}

// Placeholder list so the history flow is visible; real data arrives with the
// chat-persistence API.
const pastSessions = ref<PastSession[]>([
{ id: "1", title: "Why is wordpress-blog unhealthy?", when: "2 hours ago" },
{ id: "2", title: "Summarize recent security events", when: "Yesterday" },
{ id: "3", title: "Which containers restarted recently?", when: "3 days ago" },
]);
const formatWhen = (iso: string): string => {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "";
const mins = Math.floor((Date.now() - then) / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return new Date(then).toLocaleDateString();
};

const loadPast = (_session: PastSession) => {
// Loading a stored conversation needs the persistence API; return to chat.
const loadPast = async (id: string) => {
await store.loadSession(id);
view.value = "chat";
};

Expand Down
11 changes: 11 additions & 0 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,8 +457,19 @@ export interface AISession {
suggested_actions: AISuggestedAction[];
}

export interface AISessionSummary {
id: string;
scope: "system" | "deployment";
deployment?: string;
status: "ready" | "awaiting_approval";
title: string;
created_at: string;
updated_at: string;
}

export const aiApi = {
status: () => apiClient.get<AIStatus>("/ai/status"),
listSessions: () => apiClient.get<{ sessions: AISessionSummary[] }>("/ai/sessions"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getSession method is called in src/stores/assist.ts (line 206) and mocked in tests, but it has not been added to the aiApi object in this file. This will cause a runtime error when attempting to load a past session.

Suggested change
listSessions: () => apiClient.get<{ sessions: AISessionSummary[] }>("/ai/sessions"),
listSessions: () => apiClient.get<{ sessions: AISessionSummary[] }>("/ai/sessions"),
getSession: (id: string) => apiClient.get<AISession>(`/ai/sessions/${id}`),

createSession: (body: {
scope: "system" | "deployment";
deployment?: string;
Expand Down
34 changes: 34 additions & 0 deletions src/stores/assist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ vi.mock("@/services/api", () => ({
createSession: vi.fn(),
sessionMessage: vi.fn(),
approveSession: vi.fn(),
listSessions: vi.fn(),
getSession: vi.fn(),
},
containersApi: { exec: vi.fn() },
deploymentsApi: { serviceAction: vi.fn(), getServices: vi.fn() },
Expand Down Expand Up @@ -55,6 +57,38 @@ describe("assist store", () => {
expect(store.session).toBeNull();
});

it("lists saved sessions for the history view", async () => {
const { aiApi } = await import("@/services/api");
vi.mocked(aiApi.listSessions).mockResolvedValue({
data: {
sessions: [
{ id: "ais_2", scope: "system", status: "ready", title: "recent one", updated_at: "2026-07-18T10:00:00Z" },
],
},
} as any);

const store = useAssistStore();
await store.listSessions();

expect(store.sessions).toHaveLength(1);
expect(store.sessions[0].title).toBe("recent one");
});

it("resumes a saved session and binds later turns to its scope", async () => {
const { aiApi } = await import("@/services/api");
vi.mocked(aiApi.getSession).mockResolvedValue({
data: session({ id: "ais_9", scope: "deployment", deployment: "shop" }),
} as any);

const store = useAssistStore();
await store.loadSession("ais_9");

expect(aiApi.getSession).toHaveBeenCalledWith("ais_9");
expect(store.session?.id).toBe("ais_9");
expect(store.scope).toBe("deployment");
expect(store.deployment).toBe("shop");
});

it("creates a session from a seed message", async () => {
await enable();
const { aiApi } = await import("@/services/api");
Expand Down
47 changes: 46 additions & 1 deletion src/stores/assist.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { aiApi, containersApi, deploymentsApi, type AISession, type AISuggestedAction } from "@/services/api";
import {
aiApi,
containersApi,
deploymentsApi,
type AISession,
type AISessionSummary,
type AISuggestedAction,
} from "@/services/api";
import { useAIStore } from "@/stores/ai";
import { usePlanFlowStore } from "@/stores/planFlow";

Expand Down Expand Up @@ -41,6 +48,9 @@ export const useAssistStore = defineStore("assist", () => {
// Per-call decisions while an approval batch is open; once every
// pending call is decided the batch is submitted.
const decisions = ref<Record<string, boolean>>({});
// Saved sessions the current user can resume.
const sessions = ref<AISessionSummary[]>([]);
const listing = ref(false);

function reset() {
session.value = null;
Expand Down Expand Up @@ -173,6 +183,37 @@ export const useAssistStore = defineStore("assist", () => {
}
}

// listSessions loads the user's saved conversations for the history view.
async function listSessions() {
listing.value = true;
try {
const { data } = await aiApi.listSessions();
sessions.value = data.sessions || [];
} catch (err: any) {
error.value = err.response?.data?.error || err.message;
} finally {
listing.value = false;
}
}

// loadSession reopens a saved conversation and binds later turns to its scope.
async function loadSession(id: string) {
loading.value = true;
error.value = "";
suggestionOutputs.value = {};
decisions.value = {};
try {
const { data } = await aiApi.getSession(id);
session.value = data;
scope.value = data.scope;
deployment.value = data.deployment;
} catch (err: any) {
error.value = err.response?.data?.error || err.message;
} finally {
loading.value = false;
}
}

function close() {
visible.value = false;
embedded.value = false;
Expand All @@ -192,8 +233,12 @@ export const useAssistStore = defineStore("assist", () => {
runningIndex,
suggestionOutputs,
decisions,
sessions,
listing,
open,
send,
listSessions,
loadSession,
decide,
approveAll,
declineAll,
Expand Down
Loading