adds ability to star chats

This commit is contained in:
2026-05-28 22:47:45 -07:00
parent cb8ea935fa
commit a6c2ec664b
16 changed files with 779 additions and 145 deletions

View File

@@ -68,6 +68,14 @@ export class SybilApiClient {
return data.chat;
}
async updateChatStar(chatId: string, starred: boolean) {
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
method: "PATCH",
body: { starred },
});
return data.chat;
}
async suggestChatTitle(body: { chatId: string; content: string }) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST",
@@ -98,6 +106,14 @@ export class SybilApiClient {
return data.search;
}
async updateSearchStar(searchId: string, starred: boolean) {
const data = await this.request<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
method: "PATCH",
body: { starred },
});
return data.search;
}
async deleteSearch(searchId: string) {
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
}

View File

@@ -20,6 +20,8 @@ type SidebarItem = SidebarSelection & {
title: string;
updatedAt: string;
createdAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -131,6 +133,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
title: getChatTitle(chat),
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -145,6 +149,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
title: getSearchTitle(search),
updatedAt: search.updatedAt,
createdAt: search.createdAt,
starred: search.starred,
starredAt: search.starredAt,
initiatedProvider: null,
initiatedModel: null,
lastUsedProvider: null,
@@ -521,12 +527,13 @@ async function main() {
? ["No chats/searches yet. Press n or /. "]
: items.map((item) => {
const kind = item.kind === "chat" ? "C" : "S";
const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " ";
const title = truncate(item.title, 36);
const initiatedLabel =
item.kind === "chat" && item.initiatedModel
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
: "";
return `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
});
const linesChanged =
@@ -701,7 +708,7 @@ async function main() {
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
let controls =
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [r] rename [d] delete [C-r] refresh [q] quit";
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [s] star [r] rename [d] delete [C-r] refresh [q] quit";
if (!isSearchMode) {
controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`;
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
@@ -952,10 +959,20 @@ async function main() {
pendingTitleGeneration.add(chatId);
try {
const updated = await api.suggestChatTitle({ chatId, content });
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
if (selectedChat?.id === updated.id) {
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
selectedChat = {
...selectedChat,
title: updated.title,
updatedAt: updated.updatedAt,
starred: updated.starred,
starredAt: updated.starredAt,
initiatedProvider: updated.initiatedProvider,
initiatedModel: updated.initiatedModel,
lastUsedProvider: updated.lastUsedProvider,
lastUsedModel: updated.lastUsedModel,
};
}
updateUI();
} catch {
@@ -1006,6 +1023,8 @@ async function main() {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -1182,6 +1201,8 @@ async function main() {
query,
createdAt: nowIso,
updatedAt: nowIso,
starred: false,
starredAt: null,
requestId: null,
latencyMs: null,
error: null,
@@ -1375,6 +1396,57 @@ async function main() {
updateUI();
}
async function handleToggleStarSelection() {
if (!selectedItem) return;
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
const nextStarred = !currentItem?.starred;
setError(null);
if (selectedItem.kind === "chat") {
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
if (!workspaceItems.some((item) => item.type === "chat" && item.id === updated.id)) {
workspaceItems = [chatWorkspaceItem(updated), ...workspaceItems];
}
if (selectedChat?.id === updated.id) {
selectedChat = {
...selectedChat,
title: updated.title,
updatedAt: updated.updatedAt,
starred: updated.starred,
starredAt: updated.starredAt,
initiatedProvider: updated.initiatedProvider,
initiatedModel: updated.initiatedModel,
lastUsedProvider: updated.lastUsedProvider,
lastUsedModel: updated.lastUsedModel,
};
}
} else {
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
searches = searches.map((search) => (search.id === updated.id ? updated : search));
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
if (!workspaceItems.some((item) => item.type === "search" && item.id === updated.id)) {
workspaceItems = [searchWorkspaceItem(updated), ...workspaceItems];
}
if (selectedSearch?.id === updated.id) {
selectedSearch = {
...selectedSearch,
title: updated.title,
query: updated.query,
updatedAt: updated.updatedAt,
starred: updated.starred,
starredAt: updated.starredAt,
};
}
}
updateUI();
}
function cycleProvider() {
const visibleProviders = getVisibleProviders(modelCatalog);
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
@@ -1504,6 +1576,13 @@ async function main() {
});
});
screen.key(["s"], () => {
if (shouldIgnoreGlobalShortcut()) return;
void runAction(async () => {
await handleToggleStarSelection();
});
});
screen.key(["p"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (getIsSearchMode() || isSending) return;

View File

@@ -15,6 +15,8 @@ export type ChatSummary = {
title: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -27,6 +29,8 @@ export type SearchSummary = {
query: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
};
export type ChatWorkspaceItem = ChatSummary & {
@@ -66,6 +70,8 @@ export type ChatDetail = {
title: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -95,6 +101,8 @@ export type SearchDetail = {
query: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
requestId: string | null;
latencyMs: number | null;
error: string | null;