feat: display toggles, smart session resume, fix stale transcript pane (#8)
This commit was merged in pull request #8.
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user