feat(chat): Phase A chrome cleanup — kebabs, search, full-width new chat

Header:
- Remove Research button + modal (skill fires reliably from normal chat)
- Move Summarize-as-Note into a header kebab (disabled until the
  conversation has messages)

Left sidebar:
- Full-width "+ New Chat" primary button
- Search input filters conversations by title (case-insensitive
  substring) with a "no matches" empty state
- Move Select into a sidebar kebab next to search

Shared .btn-kebab + .kebab-menu styles give future conv-level actions
a home. Document mousedown + Escape dismiss both kebabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:28:48 -04:00
parent f0c93ffa3b
commit 035ec0c0dc
+117 -131
View File
@@ -10,6 +10,9 @@ const store = useChatStore();
const summarizing = ref(false); const summarizing = ref(false);
const sidebarOpen = ref(false); const sidebarOpen = ref(false);
const convSearchQuery = ref("");
const headerKebabOpen = ref(false);
const sidebarKebabOpen = ref(false);
let prevConvId: number | null = null; let prevConvId: number | null = null;
@@ -46,7 +49,12 @@ const groupedConversations = computed((): ConvGroup[] => {
const buckets: Record<string, typeof store.conversations> = {}; const buckets: Record<string, typeof store.conversations> = {};
const order: string[] = []; const order: string[] = [];
for (const conv of store.conversations) { const q = convSearchQuery.value.trim().toLowerCase();
const filtered = q
? store.conversations.filter((c) => (c.title || "").toLowerCase().includes(q))
: store.conversations;
for (const conv of filtered) {
const d = new Date(conv.updated_at); const d = new Date(conv.updated_at);
let key: string; let key: string;
if (d >= startOfToday) { if (d >= startOfToday) {
@@ -69,6 +77,7 @@ const groupedConversations = computed((): ConvGroup[] => {
onMounted(async () => { onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown); document.addEventListener("keydown", onGlobalKeydown);
document.addEventListener("mousedown", onDocumentMousedown);
await store.fetchConversations(); await store.fetchConversations();
if (convId.value) { if (convId.value) {
if (store.currentConversation?.id !== convId.value) { if (store.currentConversation?.id !== convId.value) {
@@ -187,36 +196,25 @@ async function handleSummarize() {
} }
} }
// ── Research modal ────────────────────────────────────────────────────────────
const showResearchModal = ref(false);
const researchTopic = ref("");
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null); const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
function toggleResearchModal() { // ── Kebab dismissal ───────────────────────────────────────────────────────────
showResearchModal.value = !showResearchModal.value; function onDocumentMousedown(e: MouseEvent) {
if (showResearchModal.value) { const target = e.target as HTMLElement | null;
researchTopic.value = ""; if (headerKebabOpen.value && !target?.closest(".header-kebab-wrapper")) {
nextTick(() => { headerKebabOpen.value = false;
const el = document.querySelector(".research-topic-input") as HTMLInputElement; }
el?.focus(); if (sidebarKebabOpen.value && !target?.closest(".sidebar-kebab-wrapper")) {
}); sidebarKebabOpen.value = false;
} }
}
function submitResearch() {
const topic = researchTopic.value.trim();
if (!topic) return;
showResearchModal.value = false;
researchTopic.value = "";
// Prefill sends via ChatPanel — user sees it in the input, then it auto-submits
chatPanelRef.value?.send(`Research: ${topic}`);
} }
// ── Keyboard ────────────────────────────────────────────────────────────────── // ── Keyboard ──────────────────────────────────────────────────────────────────
function onGlobalKeydown(e: KeyboardEvent) { function onGlobalKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return; if (e.key !== "Escape") return;
if (showResearchModal.value) { if (headerKebabOpen.value || sidebarKebabOpen.value) {
showResearchModal.value = false; headerKebabOpen.value = false;
sidebarKebabOpen.value = false;
return; return;
} }
if (sidebarOpen.value) { if (sidebarOpen.value) {
@@ -228,6 +226,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown); document.removeEventListener("keydown", onGlobalKeydown);
document.removeEventListener("mousedown", onDocumentMousedown);
if (prevConvId) { if (prevConvId) {
const conv = store.conversations.find((c) => c.id === prevConvId); const conv = store.conversations.find((c) => c.id === prevConvId);
if (conv && conv.message_count === 0) { if (conv && conv.message_count === 0) {
@@ -246,13 +245,35 @@ onUnmounted(() => {
></div> ></div>
<aside class="chat-sidebar" :class="{ open: sidebarOpen }"> <aside class="chat-sidebar" :class="{ open: sidebarOpen }">
<div class="sidebar-top-bar"> <div class="sidebar-top-bar">
<button class="btn-new-conv" @click="newConversation">+ New Chat</button> <button class="btn-new-conv btn-new-conv--full" @click="newConversation">+ New Chat</button>
<button <div class="sidebar-search-row">
class="btn-select-mode" <input
:class="{ active: selectMode }" v-model="convSearchQuery"
@click="toggleSelectMode" class="conv-search-input"
title="Select conversations" type="search"
>Select</button> placeholder="Search conversations…"
aria-label="Search conversations"
/>
<div class="sidebar-kebab-wrapper">
<button
class="btn-kebab"
:class="{ active: sidebarKebabOpen }"
@click="sidebarKebabOpen = !sidebarKebabOpen"
aria-label="List actions"
title="List actions"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
</svg>
</button>
<div v-if="sidebarKebabOpen" class="kebab-menu">
<button
class="kebab-item"
@click="sidebarKebabOpen = false; toggleSelectMode()"
>{{ selectMode ? "Exit select mode" : "Select conversations" }}</button>
</div>
</div>
</div>
</div> </div>
<div v-if="selectMode" class="bulk-bar"> <div v-if="selectMode" class="bulk-bar">
@@ -304,6 +325,10 @@ onUnmounted(() => {
<p v-if="!store.conversations.length" class="empty-msg"> <p v-if="!store.conversations.length" class="empty-msg">
No conversations yet No conversations yet
</p> </p>
<p
v-else-if="convSearchQuery && !groupedConversations.length"
class="empty-msg"
>No matches for {{ convSearchQuery }}</p>
</div> </div>
</aside> </aside>
@@ -319,40 +344,26 @@ onUnmounted(() => {
</button> </button>
<h2>{{ store.currentConversation.title || "New Chat" }}</h2> <h2>{{ store.currentConversation.title || "New Chat" }}</h2>
<!-- Research modal trigger --> <div class="header-kebab-wrapper">
<div class="research-wrapper">
<button <button
class="btn-attach" class="btn-kebab"
@click="toggleResearchModal" :class="{ active: headerKebabOpen }"
:disabled="store.streaming || !store.chatReady" @click="headerKebabOpen = !headerKebabOpen"
title="Research a topic" aria-label="Conversation actions"
title="Conversation actions"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/> <circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
</svg> </svg>
</button> </button>
<div v-if="showResearchModal" class="research-modal"> <div v-if="headerKebabOpen" class="kebab-menu kebab-menu--right">
<div class="research-modal-header">Research topic</div> <button
<input class="kebab-item"
class="research-topic-input" :disabled="!store.currentConversation.messages.length || summarizing || store.streaming"
v-model="researchTopic" @click="headerKebabOpen = false; handleSummarize()"
placeholder="e.g. quantum computing" >{{ summarizing ? "Summarizing…" : "Summarize as Note" }}</button>
@keydown.enter="submitResearch"
@keydown.escape="showResearchModal = false"
/>
<div class="research-modal-actions">
<button class="btn-research-cancel" @click="showResearchModal = false">Cancel</button>
<button class="btn-research-go" @click="submitResearch" :disabled="!researchTopic.trim()">Go</button>
</div>
</div> </div>
</div> </div>
<button
v-if="store.currentConversation.messages.length"
class="btn-summarize"
@click="handleSummarize"
:disabled="summarizing || store.streaming"
>{{ summarizing ? "Summarizing..." : "Summarize as Note" }}</button>
</div> </div>
<ChatPanel <ChatPanel
@@ -397,12 +408,12 @@ onUnmounted(() => {
.sidebar-top-bar { .sidebar-top-bar {
display: flex; display: flex;
flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem; padding: 0.75rem;
} }
.btn-new-conv { .btn-new-conv {
flex: 1;
padding: 0.5rem; padding: 0.5rem;
background: var(--color-primary); background: var(--color-primary);
color: #fff; color: #fff;
@@ -412,23 +423,31 @@ onUnmounted(() => {
font-size: 0.9rem; font-size: 0.9rem;
transition: box-shadow 0.15s, opacity 0.15s; transition: box-shadow 0.15s, opacity 0.15s;
} }
.btn-new-conv--full { width: 100%; }
.btn-new-conv:hover { .btn-new-conv:hover {
opacity: 0.9; opacity: 0.9;
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35); box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
} }
.btn-select-mode { .sidebar-search-row {
background: none; display: flex;
align-items: center;
gap: 0.35rem;
}
.conv-search-input {
flex: 1;
min-width: 0;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--color-text-muted); background: var(--color-bg);
font-size: 0.82rem; color: var(--color-text);
padding: 0.4rem 0.6rem; font-size: 0.85rem;
cursor: pointer; outline: none;
white-space: nowrap; font-family: inherit;
} }
.btn-select-mode:hover, .conv-search-input:focus { border-color: var(--color-primary); }
.btn-select-mode.active { border-color: var(--color-primary); color: var(--color-primary); } .conv-search-input::placeholder { color: var(--color-text-muted); }
.bulk-bar { .bulk-bar {
display: flex; display: flex;
@@ -548,87 +567,54 @@ onUnmounted(() => {
min-width: 0; min-width: 0;
} }
.btn-summarize { /* Kebab button + dropdown menu — shared by header and sidebar */
padding: 0.3rem 0.75rem; .btn-kebab {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.btn-summarize:hover:not(:disabled) {
background: var(--color-primary); color: #fff; border-color: var(--color-primary);
}
.btn-summarize:disabled { opacity: 0.6; cursor: default; }
.btn-attach {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: var(--color-text-muted); color: var(--color-text-muted);
opacity: 0.6; padding: 0.25rem;
padding: 0.2rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
flex-shrink: 0;
} }
.btn-attach:hover { opacity: 1; } .btn-kebab:hover { color: var(--color-text); background: var(--color-bg-secondary); }
.btn-attach:disabled { opacity: 0.25; cursor: default; } .btn-kebab.active { color: var(--color-primary); }
.research-wrapper { position: relative; } .header-kebab-wrapper,
.research-modal { .sidebar-kebab-wrapper {
position: relative;
}
.kebab-menu {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 4px);
right: 0; left: 0;
min-width: 180px;
background: var(--color-bg-card); background: var(--color-bg-card);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: 0 4px 20px var(--color-shadow); box-shadow: 0 4px 20px var(--color-shadow);
padding: 0.75rem; padding: 0.25rem;
min-width: 280px;
z-index: 20; z-index: 20;
} }
.research-modal-header { .kebab-menu--right { left: auto; right: 0; }
font-size: 0.8rem; .kebab-item {
font-weight: 600; display: block;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.research-topic-input {
width: 100%; width: 100%;
padding: 0.45rem 0.65rem; text-align: left;
border: 1px solid var(--color-border); background: none;
border-radius: var(--radius-sm); border: none;
background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-size: 0.9rem; font-size: 0.85rem;
outline: none; padding: 0.45rem 0.65rem;
font-family: inherit; border-radius: var(--radius-sm);
box-sizing: border-box; cursor: pointer;
} }
.research-topic-input:focus { border-color: var(--color-primary); } .kebab-item:hover:not(:disabled) { background: var(--color-bg-secondary); color: var(--color-primary); }
.research-modal-actions { .kebab-item:disabled { opacity: 0.4; cursor: default; }
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
justify-content: flex-end;
}
.btn-research-cancel {
background: none; border: 1px solid var(--color-border);
border-radius: var(--radius-sm); padding: 0.3rem 0.65rem;
font-size: 0.85rem; cursor: pointer; color: var(--color-text-muted);
}
.btn-research-cancel:hover { border-color: var(--color-text-muted); }
.btn-research-go {
background: var(--color-primary); color: #fff; border: none;
border-radius: var(--radius-sm); padding: 0.3rem 0.75rem;
font-size: 0.85rem; cursor: pointer; font-weight: 600;
}
.btn-research-go:disabled { opacity: 0.4; cursor: default; }
.btn-research-go:not(:disabled):hover { opacity: 0.9; }
.no-conversation { .no-conversation {
flex: 1; flex: 1;