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:
+117
-131
@@ -10,6 +10,9 @@ const store = useChatStore();
|
||||
|
||||
const summarizing = ref(false);
|
||||
const sidebarOpen = ref(false);
|
||||
const convSearchQuery = ref("");
|
||||
const headerKebabOpen = ref(false);
|
||||
const sidebarKebabOpen = ref(false);
|
||||
|
||||
let prevConvId: number | null = null;
|
||||
|
||||
@@ -46,7 +49,12 @@ const groupedConversations = computed((): ConvGroup[] => {
|
||||
const buckets: Record<string, typeof store.conversations> = {};
|
||||
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);
|
||||
let key: string;
|
||||
if (d >= startOfToday) {
|
||||
@@ -69,6 +77,7 @@ const groupedConversations = computed((): ConvGroup[] => {
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", onGlobalKeydown);
|
||||
document.addEventListener("mousedown", onDocumentMousedown);
|
||||
await store.fetchConversations();
|
||||
if (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);
|
||||
|
||||
function toggleResearchModal() {
|
||||
showResearchModal.value = !showResearchModal.value;
|
||||
if (showResearchModal.value) {
|
||||
researchTopic.value = "";
|
||||
nextTick(() => {
|
||||
const el = document.querySelector(".research-topic-input") as HTMLInputElement;
|
||||
el?.focus();
|
||||
});
|
||||
// ── Kebab dismissal ───────────────────────────────────────────────────────────
|
||||
function onDocumentMousedown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (headerKebabOpen.value && !target?.closest(".header-kebab-wrapper")) {
|
||||
headerKebabOpen.value = false;
|
||||
}
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
if (showResearchModal.value) {
|
||||
showResearchModal.value = false;
|
||||
if (headerKebabOpen.value || sidebarKebabOpen.value) {
|
||||
headerKebabOpen.value = false;
|
||||
sidebarKebabOpen.value = false;
|
||||
return;
|
||||
}
|
||||
if (sidebarOpen.value) {
|
||||
@@ -228,6 +226,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", onGlobalKeydown);
|
||||
document.removeEventListener("mousedown", onDocumentMousedown);
|
||||
if (prevConvId) {
|
||||
const conv = store.conversations.find((c) => c.id === prevConvId);
|
||||
if (conv && conv.message_count === 0) {
|
||||
@@ -246,13 +245,35 @@ onUnmounted(() => {
|
||||
></div>
|
||||
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
|
||||
<div class="sidebar-top-bar">
|
||||
<button class="btn-new-conv" @click="newConversation">+ New Chat</button>
|
||||
<button
|
||||
class="btn-select-mode"
|
||||
:class="{ active: selectMode }"
|
||||
@click="toggleSelectMode"
|
||||
title="Select conversations"
|
||||
>Select</button>
|
||||
<button class="btn-new-conv btn-new-conv--full" @click="newConversation">+ New Chat</button>
|
||||
<div class="sidebar-search-row">
|
||||
<input
|
||||
v-model="convSearchQuery"
|
||||
class="conv-search-input"
|
||||
type="search"
|
||||
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 v-if="selectMode" class="bulk-bar">
|
||||
@@ -304,6 +325,10 @@ onUnmounted(() => {
|
||||
<p v-if="!store.conversations.length" class="empty-msg">
|
||||
No conversations yet
|
||||
</p>
|
||||
<p
|
||||
v-else-if="convSearchQuery && !groupedConversations.length"
|
||||
class="empty-msg"
|
||||
>No matches for “{{ convSearchQuery }}”</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -319,40 +344,26 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
|
||||
|
||||
<!-- Research modal trigger -->
|
||||
<div class="research-wrapper">
|
||||
<div class="header-kebab-wrapper">
|
||||
<button
|
||||
class="btn-attach"
|
||||
@click="toggleResearchModal"
|
||||
:disabled="store.streaming || !store.chatReady"
|
||||
title="Research a topic"
|
||||
class="btn-kebab"
|
||||
:class="{ active: headerKebabOpen }"
|
||||
@click="headerKebabOpen = !headerKebabOpen"
|
||||
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">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
<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="showResearchModal" class="research-modal">
|
||||
<div class="research-modal-header">Research topic</div>
|
||||
<input
|
||||
class="research-topic-input"
|
||||
v-model="researchTopic"
|
||||
placeholder="e.g. quantum computing"
|
||||
@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 v-if="headerKebabOpen" class="kebab-menu kebab-menu--right">
|
||||
<button
|
||||
class="kebab-item"
|
||||
:disabled="!store.currentConversation.messages.length || summarizing || store.streaming"
|
||||
@click="headerKebabOpen = false; handleSummarize()"
|
||||
>{{ summarizing ? "Summarizing…" : "Summarize as Note" }}</button>
|
||||
</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>
|
||||
|
||||
<ChatPanel
|
||||
@@ -397,12 +408,12 @@ onUnmounted(() => {
|
||||
|
||||
.sidebar-top-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-new-conv {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
@@ -412,23 +423,31 @@ onUnmounted(() => {
|
||||
font-size: 0.9rem;
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-new-conv--full { width: 100%; }
|
||||
.btn-new-conv:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
|
||||
}
|
||||
|
||||
.btn-select-mode {
|
||||
background: none;
|
||||
.sidebar-search-row {
|
||||
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-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-select-mode:hover,
|
||||
.btn-select-mode.active { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.conv-search-input:focus { border-color: var(--color-primary); }
|
||||
.conv-search-input::placeholder { color: var(--color-text-muted); }
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
@@ -548,87 +567,54 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-summarize {
|
||||
padding: 0.3rem 0.75rem;
|
||||
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 {
|
||||
/* Kebab button + dropdown menu — shared by header and sidebar */
|
||||
.btn-kebab {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
padding: 0.2rem;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-attach:hover { opacity: 1; }
|
||||
.btn-attach:disabled { opacity: 0.25; cursor: default; }
|
||||
.btn-kebab:hover { color: var(--color-text); background: var(--color-bg-secondary); }
|
||||
.btn-kebab.active { color: var(--color-primary); }
|
||||
|
||||
.research-wrapper { position: relative; }
|
||||
.research-modal {
|
||||
.header-kebab-wrapper,
|
||||
.sidebar-kebab-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.kebab-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 20px var(--color-shadow);
|
||||
padding: 0.75rem;
|
||||
min-width: 280px;
|
||||
padding: 0.25rem;
|
||||
z-index: 20;
|
||||
}
|
||||
.research-modal-header {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.research-topic-input {
|
||||
.kebab-menu--right { left: auto; right: 0; }
|
||||
.kebab-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.research-topic-input:focus { border-color: var(--color-primary); }
|
||||
.research-modal-actions {
|
||||
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; }
|
||||
.kebab-item:hover:not(:disabled) { background: var(--color-bg-secondary); color: var(--color-primary); }
|
||||
.kebab-item:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.no-conversation {
|
||||
flex: 1;
|
||||
|
||||
Reference in New Issue
Block a user