Compare commits
2 Commits
49e30296b9
...
be6641de24
| Author | SHA1 | Date | |
|---|---|---|---|
| be6641de24 | |||
| 72b3a0902a |
@@ -16,10 +16,23 @@ type IncomingChatMessage = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
function sameMessage(a: IncomingChatMessage, b: IncomingChatMessage) {
|
||||
function sameMessage(
|
||||
a: { role: string; content: string; name?: string | null },
|
||||
b: { role: string; content: string; name?: string | null }
|
||||
) {
|
||||
return a.role === b.role && a.content === b.content && (a.name ?? null) === (b.name ?? null);
|
||||
}
|
||||
|
||||
function isToolCallLogMetadata(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return record.kind === "tool_call";
|
||||
}
|
||||
|
||||
function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
|
||||
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
|
||||
}
|
||||
|
||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||
if (!incoming.length) return;
|
||||
@@ -27,13 +40,13 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
||||
const existing = await prisma.message.findMany({
|
||||
where: { chatId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { role: true, content: true, name: true },
|
||||
select: { role: true, content: true, name: true, metadata: true },
|
||||
});
|
||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant");
|
||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||
|
||||
let sharedPrefix = 0;
|
||||
const max = Math.min(existingNonAssistant.length, incoming.length);
|
||||
while (sharedPrefix < max && sameMessage(existingNonAssistant[sharedPrefix] as IncomingChatMessage, incoming[sharedPrefix])) {
|
||||
while (sharedPrefix < max && sameMessage(existingNonAssistant[sharedPrefix], incoming[sharedPrefix])) {
|
||||
sharedPrefix += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ Compatibility aliases:
|
||||
- `Tab` / `Shift+Tab`: move focus between sidebar, transcript, and composer
|
||||
- `Esc` (in composer): exit input mode and focus sidebar
|
||||
- `Up` / `Down` (in sidebar): move highlight
|
||||
- `Page Up` / `Page Down` (in transcript): scroll the transcript by one page
|
||||
- `Enter` in sidebar: load highlighted conversation/search
|
||||
- `Enter` in composer: send message/search
|
||||
- `n`: new chat draft
|
||||
|
||||
@@ -323,6 +323,35 @@ async function main() {
|
||||
|
||||
const focusables = [sidebar, transcript, composer] as const;
|
||||
|
||||
function getTranscriptViewportHeight() {
|
||||
const lpos = transcript.lpos;
|
||||
if (lpos) {
|
||||
return Math.max(1, lpos.yl - lpos.yi - Number(transcript.iheight ?? 0));
|
||||
}
|
||||
|
||||
return Math.max(1, Number(transcript.height ?? 1) - Number(transcript.iheight ?? 0));
|
||||
}
|
||||
|
||||
function isTranscriptNearBottom() {
|
||||
const viewportHeight = getTranscriptViewportHeight();
|
||||
const maxScroll = Math.max(0, transcript.getScrollHeight() - viewportHeight);
|
||||
if (maxScroll === 0) return true;
|
||||
return maxScroll - transcript.getScroll() <= 1;
|
||||
}
|
||||
|
||||
function queueTranscriptScrollToBottomIfFollowing() {
|
||||
if (isTranscriptNearBottom()) {
|
||||
forceScrollToBottom = true;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollTranscriptByPage(direction: 1 | -1) {
|
||||
forceScrollToBottom = false;
|
||||
const pageSize = Math.max(1, getTranscriptViewportHeight() - 1);
|
||||
transcript.scroll(direction * pageSize);
|
||||
screen.render();
|
||||
}
|
||||
|
||||
function withSuppressedSidebarSelectEvents(fn: () => void) {
|
||||
suppressedSidebarSelectEvents += 1;
|
||||
try {
|
||||
@@ -684,7 +713,7 @@ async function main() {
|
||||
return items.some((item) => item.kind === selection.kind && item.id === selection.id);
|
||||
}
|
||||
|
||||
async function loadSelection(selection: SidebarSelection | null) {
|
||||
async function loadSelection(selection: SidebarSelection | null, options?: { scrollToBottom?: boolean | undefined }) {
|
||||
if (!selection) {
|
||||
selectedChat = null;
|
||||
selectedSearch = null;
|
||||
@@ -714,12 +743,18 @@ async function main() {
|
||||
if (selectionKey(selectedItem) === requestedSelectionKey) {
|
||||
isLoadingSelection = false;
|
||||
}
|
||||
forceScrollToBottom = true;
|
||||
if (options?.scrollToBottom) {
|
||||
forceScrollToBottom = true;
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCollections(options?: { preferredSelection?: SidebarSelection; loadSelection?: boolean }) {
|
||||
async function refreshCollections(options?: {
|
||||
preferredSelection?: SidebarSelection;
|
||||
loadSelection?: boolean;
|
||||
scrollToBottomOnLoad?: boolean | undefined;
|
||||
}) {
|
||||
isLoadingCollections = true;
|
||||
updateUI();
|
||||
|
||||
@@ -748,7 +783,7 @@ async function main() {
|
||||
}
|
||||
|
||||
if (options?.loadSelection) {
|
||||
await loadSelection(selectedItem);
|
||||
await loadSelection(selectedItem, { scrollToBottom: options?.scrollToBottomOnLoad });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,7 +832,7 @@ async function main() {
|
||||
errorMessage = null;
|
||||
forceScrollToBottom = true;
|
||||
updateUI();
|
||||
await loadSelection(selectedItem);
|
||||
await loadSelection(selectedItem, { scrollToBottom: true });
|
||||
}
|
||||
|
||||
function handleCreateChat() {
|
||||
@@ -966,7 +1001,7 @@ async function main() {
|
||||
};
|
||||
}
|
||||
|
||||
forceScrollToBottom = true;
|
||||
queueTranscriptScrollToBottomIfFollowing();
|
||||
updateUI();
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
@@ -982,7 +1017,7 @@ async function main() {
|
||||
|
||||
if (!updated) return;
|
||||
pendingChatState = { ...pendingChatState, messages: nextMessages };
|
||||
forceScrollToBottom = true;
|
||||
queueTranscriptScrollToBottomIfFollowing();
|
||||
updateUI();
|
||||
},
|
||||
onDone: (payload) => {
|
||||
@@ -998,7 +1033,7 @@ async function main() {
|
||||
|
||||
if (!updated) return;
|
||||
pendingChatState = { ...pendingChatState, messages: nextMessages };
|
||||
forceScrollToBottom = true;
|
||||
queueTranscriptScrollToBottomIfFollowing();
|
||||
updateUI();
|
||||
},
|
||||
onError: (payload) => {
|
||||
@@ -1011,15 +1046,17 @@ async function main() {
|
||||
throw new Error(streamErrorMessage);
|
||||
}
|
||||
|
||||
const shouldFollowTranscript = isTranscriptNearBottom();
|
||||
|
||||
await refreshCollections({ preferredSelection: { kind: "chat", id: chatId }, loadSelection: false });
|
||||
|
||||
const currentSelection = selectedItem;
|
||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||||
await loadSelection(currentSelection);
|
||||
await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript });
|
||||
}
|
||||
|
||||
pendingChatState = null;
|
||||
forceScrollToBottom = true;
|
||||
forceScrollToBottom = shouldFollowTranscript;
|
||||
updateUI();
|
||||
}
|
||||
|
||||
@@ -1103,7 +1140,7 @@ async function main() {
|
||||
error: null,
|
||||
results: payload.results,
|
||||
};
|
||||
forceScrollToBottom = true;
|
||||
queueTranscriptScrollToBottomIfFollowing();
|
||||
updateUI();
|
||||
},
|
||||
onSearchError: (payload) => {
|
||||
@@ -1122,7 +1159,7 @@ async function main() {
|
||||
answerCitations: payload.answerCitations,
|
||||
answerError: null,
|
||||
};
|
||||
forceScrollToBottom = true;
|
||||
queueTranscriptScrollToBottomIfFollowing();
|
||||
updateUI();
|
||||
},
|
||||
onAnswerError: (payload) => {
|
||||
@@ -1135,7 +1172,7 @@ async function main() {
|
||||
if (runId !== searchRunCounter) return;
|
||||
selectedSearch = payload.search;
|
||||
selectedChat = null;
|
||||
forceScrollToBottom = true;
|
||||
queueTranscriptScrollToBottomIfFollowing();
|
||||
updateUI();
|
||||
},
|
||||
onError: (payload) => {
|
||||
@@ -1154,11 +1191,13 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const shouldFollowTranscript = isTranscriptNearBottom();
|
||||
|
||||
await refreshCollections({ preferredSelection: { kind: "search", id: searchId }, loadSelection: false });
|
||||
|
||||
const currentSelection = selectedItem;
|
||||
if (currentSelection?.kind === "search" && currentSelection.id === searchId) {
|
||||
await loadSelection(currentSelection);
|
||||
await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,7 +1225,7 @@ async function main() {
|
||||
}
|
||||
|
||||
if (selectedItem) {
|
||||
await loadSelection(selectedItem);
|
||||
await loadSelection(selectedItem, { scrollToBottom: true });
|
||||
}
|
||||
} finally {
|
||||
isSending = false;
|
||||
@@ -1214,7 +1253,7 @@ async function main() {
|
||||
selectedSearch = null;
|
||||
}
|
||||
|
||||
await refreshCollections({ loadSelection: true });
|
||||
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
|
||||
}
|
||||
|
||||
function cycleProvider() {
|
||||
@@ -1262,6 +1301,14 @@ async function main() {
|
||||
updateUI();
|
||||
});
|
||||
|
||||
transcript.key(["pageup"], () => {
|
||||
scrollTranscriptByPage(-1);
|
||||
});
|
||||
|
||||
transcript.key(["pagedown"], () => {
|
||||
scrollTranscriptByPage(1);
|
||||
});
|
||||
|
||||
sidebar.on("select item", (_item, index) => {
|
||||
if (suppressedSidebarSelectEvents > 0) return;
|
||||
const highlightedItem = getSidebarItems()[index];
|
||||
@@ -1389,7 +1436,7 @@ async function main() {
|
||||
}
|
||||
|
||||
await runAction(async () => {
|
||||
await Promise.all([refreshCollections({ loadSelection: true }), refreshModels()]);
|
||||
await Promise.all([refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true }), refreshModels()]);
|
||||
});
|
||||
|
||||
focusComposer();
|
||||
|
||||
Reference in New Issue
Block a user