feat: display toggles, smart session resume, fix stale transcript pane (#8)

This commit was merged in pull request #8.
This commit is contained in:
2026-10-03 21:22:04 +00:00
parent ffe2c6b7ed
commit ee96f1ee61
8 changed files with 304 additions and 73 deletions
+56 -65
View File
@@ -1,11 +1,10 @@
import { Action, ActionPanel, Icon, List } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useChat } from "../hooks/useChat";
import { buildThreadMarkdown } from "../markdown";
import { buildThreadMarkdown, type TranscriptOptions } from "../markdown";
import { getLamPreferences } from "../preferences";
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
import { buildThread, type LiveState } from "../thread";
import type { ChatTurn, Session, SessionSummary } from "../types";
import { buildThread } from "../thread";import type { Session, SessionSummary } from "../types";
import { recencyGroup, truncate } from "../utils";
/**
@@ -14,17 +13,25 @@ import { recencyGroup, truncate } from "../utils";
* Modelled on Raycast's AI chat, as far as Vicinae allows:
*
* - The sidebar lists conversations by title only, grouped into recency
* sections. Raycast does the same, and it buys the recency cue that
* per-row timestamps were trying to convey without putting a timestamp on
* every single row.
* - The transcript keeps user and assistant visually distinct. Raycast uses
* right-aligned bubbles; markdown offers no alignment control, so a dimmed
* blockquote for the question and body text for the answer is the available
* equivalent.
* sections, which buys the recency cue a timestamp on every row was trying to
* convey while leaving the title room to be readable.
* - User and assistant stay visually distinct. Raycast uses right-aligned
* bubbles; markdown has no alignment control, so a dimmed blockquote for the
* question and body text for the answer is the available equivalent.
* - Raycast pins its composer to the bottom. Vicinae's search bar is fixed at
* the top of the window, so that part cannot be matched.
*
* Most of the rest is a consequence of how Vicinae renders lists:
* ## Why every row carries the same detail
*
* The right pane always shows the *open* conversation, not the highlighted one.
* That is by design rather than laziness: Vicinae exposes no way to set the
* selected row, so when ⌘N starts a new chat, the selection can stay on the row
* the user was on and the pane would keep rendering the previous conversation.
* Rendering the active thread on every row makes the pane follow the chat the
* user is actually in, whatever the selection happens to be. Switching
* conversations is explicit: Return on an empty search bar, or the action panel.
*
* ## Other constraints
*
* - The search bar is the composer. It is the only always-focused text input in
* a `List`, which is what makes "hit a hotkey, then just start typing" work.
@@ -33,15 +40,13 @@ import { recencyGroup, truncate } from "../utils";
* - `Detail` accepts no typed input (`onSearchTextChange` exists only on `List`,
* `Grid`, `Form` and `Dropdown`), so the left column cannot be dropped: it is
* the text box.
*
* Ordering matters. Vicinae resets list selection to the first row whenever the
* model changes (`GenericListView.selectFirstOnReset`), and streaming changes
* the model on every flush. Conversations are therefore ordered by most
* recently updated, which keeps the one being chatted in at row zero where the
* selection lands.
* - Conversations are ordered most-recently-updated first. Vicinae resets list
* selection to the first row on every model change
* (`GenericListView.selectFirstOnReset`), and streaming changes the model on
* every flush, so ordering by recency keeps the chat in progress near the top.
*/
const IDLE: LiveState = { isStreaming: false, response: "", thinking: "" };
const EMPTY_TRANSCRIPT = "**Ask Lam anything**\n\nType a question in the search bar and press Return.";
export interface ChatViewProps {
/** Opens a specific conversation instead of resuming the latest one. */
@@ -50,18 +55,11 @@ export interface ChatViewProps {
initialPrompt?: string;
}
interface StoredConversation {
summary: SessionSummary;
turns: ChatTurn[];
}
interface SessionRow {
id: string;
title: string;
updatedAt: number;
isActive: boolean;
/** Precomputed so a render does not rebuild every stored transcript. */
markdown: string;
}
export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
@@ -70,36 +68,27 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
const { replaceSession } = chat;
const [input, setInput] = useState(initialPrompt ?? "");
const [stored, setStored] = useState<StoredConversation[] | undefined>(undefined);
const [summaries, setSummaries] = useState<SessionSummary[] | undefined>(undefined);
const [resumed, setResumed] = useState(Boolean(initialSession));
const activeId = chat.session.id;
/**
* Loads every conversation in full so each row can carry its own transcript
* as a detail. Only the index is stored separately, so this reads the bodies
* once on mount and after each turn rather than on every render.
*/
const reload = useCallback(() => {
void (async () => {
const summaries = await listSessions();
const conversations = await Promise.all(
summaries.map(async (summary) => {
const session = await loadSession(summary.id);
return { summary, turns: session?.turns ?? [] };
}),
);
setStored(conversations);
})();
void listSessions()
.then(setSummaries)
.catch(() => setSummaries([]));
}, []);
// Refreshed after each turn so the sidebar picks up a newly created chat.
useEffect(() => {
reload();
}, [reload, chat.session.updatedAt]);
/**
* Reopening the command continues the most recent conversation rather than
* starting a fresh chat every time. Starting over is explicit: ⌘N.
* Reopening the command continues the most recent conversation, but only if it
* was used recently. Past the configured idle window it starts a fresh chat
* instead — the previous conversation is never removed, it just drops down the
* sidebar. Setting the preference to "Never" keeps the last chat open forever.
*/
useEffect(() => {
if (initialSession) return;
@@ -109,6 +98,11 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
try {
const [latest] = await listSessions();
if (!latest) return;
const idleMs = Date.now() - latest.updatedAt;
const { resumeTimeoutMs } = preferences;
if (resumeTimeoutMs !== undefined && idleMs > resumeTimeoutMs) return;
const session = await loadSession(latest.id);
if (!cancelled && session) replaceSession(session);
} finally {
@@ -119,7 +113,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
return () => {
cancelled = true;
};
}, [initialSession, replaceSession]);
}, [initialSession, preferences, replaceSession]);
const thread = useMemo(
() =>
@@ -132,34 +126,36 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
[chat.session.turns, chat.isStreaming, chat.streamingResponse, chat.streamingThinking, chat.status],
);
const transcriptOptions = useMemo<TranscriptOptions>(
() => ({ showToolCalls: preferences.showToolCalls, showReasoning: preferences.showReasoning }),
[preferences.showToolCalls, preferences.showReasoning],
);
const threadMarkdown = useMemo(
() =>
thread.length === 0
? "**Ask Lam anything**\n\nType a question in the search bar and press Return."
: buildThreadMarkdown(thread),
[thread],
thread.length === 0 ? EMPTY_TRANSCRIPT : buildThreadMarkdown(thread, transcriptOptions),
[thread, transcriptOptions],
);
const rows: SessionRow[] = useMemo(() => {
const base = stored ?? [];
const base = summaries ?? [];
const mapped: SessionRow[] = base.map((conversation) => ({
id: conversation.summary.id,
title: conversation.summary.title,
updatedAt: conversation.summary.updatedAt,
isActive: conversation.summary.id === activeId,
markdown: buildThreadMarkdown(buildThread(conversation.turns, IDLE)),
const mapped: SessionRow[] = base.map((summary) => ({
id: summary.id,
title: summary.title,
updatedAt: summary.updatedAt,
isActive: summary.id === activeId,
}));
// A conversation that has not finished a turn yet is not in the index.
if (base.some((conversation) => conversation.summary.id === activeId)) return mapped;
if (base.some((summary) => summary.id === activeId)) return mapped;
const active = summarizeSession(chat.session);
return [
{ id: active.id, title: active.title, updatedAt: active.updatedAt, isActive: true, markdown: "" },
{ id: active.id, title: active.title, updatedAt: active.updatedAt, isActive: true },
...mapped,
];
}, [stored, activeId, chat.session]);
}, [summaries, activeId, chat.session]);
/**
* Groups the sidebar into recency sections. Rows are already sorted by
@@ -245,7 +241,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
);
};
if (!resumed || stored === undefined) {
if (!resumed || summaries === undefined) {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
}
@@ -282,12 +278,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
// Title only. Raycast's sidebar shows nothing else, and stacking a
// subtitle plus a timestamp on a 38px row elided the title down to
// a few characters. The check icon is what marks the open chat.
detail={
<List.Item.Detail
markdown={row.isActive ? threadMarkdown : row.markdown}
isLoading={row.isActive && chat.isStreaming}
/>
}
detail={<List.Item.Detail markdown={threadMarkdown} isLoading={chat.isStreaming} />}
actions={rowActions(row)}
/>
))}